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:
유병철
2026-02-10 16:01:23 +09:00
parent 0643d56194
commit 437d5f6834
42 changed files with 1683 additions and 1144 deletions

View File

@@ -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`,

View File

@@ -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,

View 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
View 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;
}