refactor(WEB): SearchableSelectionModal 공통화 및 actions lookup 통합
- SearchableSelectionModal<T> 제네릭 컴포넌트 추출 (organisms) - 검색 모달 5개 리팩토링: SupplierSearch, QuotationSelect, SalesOrderSelect, OrderSelect, ItemSearch - shared-lookups API 유틸 추가 (거래처/품목/수주 등 공통 조회) - create-crud-service 확장 (lookup, search 메서드) - actions.ts 20+개 파일 lookup 패턴 통일 - 공통 페이지 패턴 가이드 문서 추가 - CLAUDE.md Common Component Usage Rules 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 정형적인 CRUD actions.ts 파일의 보일러플레이트를 제거합니다.
|
||||
* executeServerAction 위에 한 단계 더 추상화하여
|
||||
* getList / create / update / remove / reorder 함수를 자동 생성합니다.
|
||||
* getList / getById / create / update / remove / bulkDelete / reorder 함수를 자동 생성합니다.
|
||||
*
|
||||
* 주의: 이 파일은 'use server'가 아닙니다.
|
||||
* 각 도메인의 actions.ts ('use server')에서 import하여 사용합니다.
|
||||
@@ -20,10 +20,12 @@
|
||||
* defaultCreateBody: { type: 'rank' },
|
||||
* });
|
||||
* export async function getRanks(params?) { return service.getList(params); }
|
||||
* export async function getRankById(id) { return service.getById(id); }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { executeServerAction, type ActionResult } from './execute-server-action';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 설정 타입 =====
|
||||
export interface CrudServiceConfig<TApi, TFrontend> {
|
||||
@@ -37,6 +39,8 @@ export interface CrudServiceConfig<TApi, TFrontend> {
|
||||
defaultQueryParams?: Record<string, string>;
|
||||
/** 생성 시 body에 추가할 기본값 (예: { type: 'rank' }) */
|
||||
defaultCreateBody?: Record<string, unknown>;
|
||||
/** 수정 시 HTTP 메서드 (기본: 'PUT') */
|
||||
updateMethod?: 'PUT' | 'PATCH';
|
||||
}
|
||||
|
||||
// ===== 서비스 반환 타입 =====
|
||||
@@ -46,14 +50,18 @@ export interface CrudService<TFrontend> {
|
||||
q?: string;
|
||||
}): Promise<ActionResult<TFrontend[]>>;
|
||||
|
||||
getById(id: number | string): Promise<ActionResult<TFrontend>>;
|
||||
|
||||
create(body: Record<string, unknown>): Promise<ActionResult<TFrontend>>;
|
||||
|
||||
update(
|
||||
id: number,
|
||||
id: number | string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<ActionResult<TFrontend>>;
|
||||
|
||||
remove(id: number): Promise<ActionResult>;
|
||||
remove(id: number | string): Promise<ActionResult>;
|
||||
|
||||
bulkDelete(ids: (number | string)[]): Promise<ActionResult>;
|
||||
|
||||
reorder(
|
||||
items: { id: number; sort_order: number }[]
|
||||
@@ -70,6 +78,7 @@ export function createCrudService<TApi, TFrontend>(
|
||||
entityName,
|
||||
defaultQueryParams,
|
||||
defaultCreateBody,
|
||||
updateMethod = 'PUT',
|
||||
} = config;
|
||||
|
||||
// API URL은 호출 시점에 resolve (SSR 안전)
|
||||
@@ -97,6 +106,14 @@ export function createCrudService<TApi, TFrontend>(
|
||||
});
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
return executeServerAction({
|
||||
url: `${getBaseUrl()}/${id}`,
|
||||
transform,
|
||||
errorMessage: `${entityName} 조회에 실패했습니다.`,
|
||||
});
|
||||
},
|
||||
|
||||
async create(body) {
|
||||
return executeServerAction({
|
||||
url: getBaseUrl(),
|
||||
@@ -110,7 +127,7 @@ export function createCrudService<TApi, TFrontend>(
|
||||
async update(id, body) {
|
||||
return executeServerAction({
|
||||
url: `${getBaseUrl()}/${id}`,
|
||||
method: 'PUT',
|
||||
method: updateMethod,
|
||||
body,
|
||||
transform,
|
||||
errorMessage: `${entityName} 수정에 실패했습니다.`,
|
||||
@@ -125,6 +142,26 @@ export function createCrudService<TApi, TFrontend>(
|
||||
});
|
||||
},
|
||||
|
||||
async bulkDelete(ids) {
|
||||
try {
|
||||
const results = await Promise.all(ids.map((id) =>
|
||||
executeServerAction({
|
||||
url: `${getBaseUrl()}/${id}`,
|
||||
method: 'DELETE',
|
||||
errorMessage: `${entityName} 삭제에 실패했습니다.`,
|
||||
})
|
||||
));
|
||||
const failed = results.filter((r) => !r.success);
|
||||
if (failed.length > 0) {
|
||||
return { success: false, error: `${failed.length}개 ${entityName} 삭제에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
return { success: false, error: `${entityName} 일괄 삭제에 실패했습니다.` };
|
||||
}
|
||||
},
|
||||
|
||||
async reorder(items) {
|
||||
return executeServerAction({
|
||||
url: `${getBaseUrl()}/reorder`,
|
||||
|
||||
@@ -5,6 +5,23 @@ export { ApiClient, withTokenRefresh } from './client';
|
||||
export { serverFetch } from './fetch-wrapper';
|
||||
export { AUTH_CONFIG } from './auth/auth-config';
|
||||
|
||||
// 공용 API 타입 및 페이지네이션 유틸리티
|
||||
export {
|
||||
toPaginationMeta,
|
||||
type PaginatedApiResponse,
|
||||
type PaginationMeta,
|
||||
type PaginatedResult,
|
||||
type SelectOption,
|
||||
} from './types';
|
||||
|
||||
// 공용 룩업 헬퍼 (거래처/계좌 조회)
|
||||
export {
|
||||
fetchVendorOptions,
|
||||
fetchBankAccountOptions,
|
||||
fetchBankAccountDetailOptions,
|
||||
type BankAccountOption,
|
||||
} from './shared-lookups';
|
||||
|
||||
// 공통 코드 타입 및 유틸리티
|
||||
export {
|
||||
toCommonCodeOptions,
|
||||
|
||||
86
src/lib/api/shared-lookups.ts
Normal file
86
src/lib/api/shared-lookups.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 공용 룩업(셀렉트 옵션) 조회 헬퍼
|
||||
*
|
||||
* 여러 도메인(입금/출금/매입/예상비용)에서 동일하게 사용하는
|
||||
* 거래처/계좌 조회 로직을 하나로 통합합니다.
|
||||
*
|
||||
* 주의: 이 파일은 'use server'가 아닙니다.
|
||||
* 각 도메인의 actions.ts ('use server')에서 import하여 사용합니다.
|
||||
*/
|
||||
|
||||
import { executeServerAction, type ActionResult } from './execute-server-action';
|
||||
import type { SelectOption } from './types';
|
||||
|
||||
// ===== 계좌 상세 옵션 =====
|
||||
export interface BankAccountOption {
|
||||
id: string;
|
||||
bankName: string;
|
||||
accountName: string;
|
||||
accountNumber: string;
|
||||
}
|
||||
|
||||
// ===== API 내부 타입 =====
|
||||
interface ClientApiItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BankAccountApiItem {
|
||||
id: number;
|
||||
bank_name: string;
|
||||
account_name: string;
|
||||
account_number: string;
|
||||
}
|
||||
|
||||
type PaginatedOrArray<T> = { data?: T[] } | T[];
|
||||
|
||||
function extractArray<T>(data: PaginatedOrArray<T>): T[] {
|
||||
return Array.isArray(data) ? data : (data as { data?: T[] })?.data || [];
|
||||
}
|
||||
|
||||
// ===== 거래처 목록 조회 =====
|
||||
export async function fetchVendorOptions(): Promise<ActionResult<SelectOption[]>> {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/clients?per_page=100`,
|
||||
transform: (data: PaginatedOrArray<ClientApiItem>) => {
|
||||
const clients = extractArray(data);
|
||||
return clients.map(c => ({ id: String(c.id), name: c.name }));
|
||||
},
|
||||
errorMessage: '거래처 조회에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
// ===== 계좌 목록 조회 (간단: id + name) =====
|
||||
export async function fetchBankAccountOptions(): Promise<ActionResult<SelectOption[]>> {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
|
||||
transform: (data: PaginatedOrArray<BankAccountApiItem>) => {
|
||||
const accounts = extractArray(data);
|
||||
return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` }));
|
||||
},
|
||||
errorMessage: '계좌 조회에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
// ===== 계좌 목록 조회 (상세: bankName, accountName, accountNumber) =====
|
||||
export async function fetchBankAccountDetailOptions(): Promise<ActionResult<BankAccountOption[]>> {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
|
||||
transform: (data: PaginatedOrArray<BankAccountApiItem>) => {
|
||||
const accounts = extractArray(data);
|
||||
return accounts.map(a => ({
|
||||
id: String(a.id),
|
||||
bankName: a.bank_name,
|
||||
accountName: a.account_name,
|
||||
accountNumber: a.account_number,
|
||||
}));
|
||||
},
|
||||
errorMessage: '은행 계좌 조회에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
47
src/lib/api/types.ts
Normal file
47
src/lib/api/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 공용 API 타입 및 페이지네이션 유틸리티
|
||||
*
|
||||
* 25+ 개 action 파일에서 반복 정의되던 PaginatedResponse 타입과
|
||||
* 페이지네이션 변환 헬퍼를 하나로 통합합니다.
|
||||
*/
|
||||
|
||||
// ===== API 페이지네이션 응답 (Laravel 표준) =====
|
||||
export interface PaginatedApiResponse<T> {
|
||||
data: T[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== 프론트엔드 페이지네이션 메타 (camelCase) =====
|
||||
export interface PaginationMeta {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== 프론트엔드 페이지네이션 결과 =====
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
// ===== 페이지네이션 변환 헬퍼 =====
|
||||
export function toPaginationMeta(
|
||||
data: { current_page?: number; last_page?: number; per_page?: number; total?: number } | undefined | null
|
||||
): PaginationMeta {
|
||||
return {
|
||||
currentPage: data?.current_page || 1,
|
||||
lastPage: data?.last_page || 1,
|
||||
perPage: data?.per_page || 20,
|
||||
total: data?.total || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 셀렉트 옵션 (공용 룩업용) =====
|
||||
export interface SelectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
Reference in New Issue
Block a user