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

@@ -2,6 +2,7 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { Account, AccountFormData, AccountStatus } from './types';
import { BANK_LABELS } from './types';
@@ -23,13 +24,7 @@ interface BankAccountApiData {
updated_at?: string;
}
interface BankAccountPaginatedResponse {
data: BankAccountApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type BankAccountPaginatedResponse = PaginatedApiResponse<BankAccountApiData>;
// ===== 데이터 변환 =====
function transformApiToFrontend(apiData: BankAccountApiData): Account {

View File

@@ -2,19 +2,14 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { PaymentApiData, PaymentHistory } from './types';
import { transformApiToFrontend } from './utils';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PaymentPaginatedResponse {
data: PaymentApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
type PaymentPaginatedResponse = PaginatedApiResponse<PaymentApiData>;
interface PaymentStatementApiData {
statement_no: string;

View File

@@ -14,23 +14,12 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { Popup, PopupFormData } from './types';
import { transformApiToFrontend, transformFrontendToApi, type PopupApiData } from './utils';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ============================================
// API 응답 타입 정의
// ============================================
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
// ============================================
// API 함수
// ============================================
@@ -52,7 +41,7 @@ export async function getPopups(params?: {
const result = await executeServerAction({
url: `${API_URL}/api/v1/popups?${searchParams.toString()}`,
transform: (data: PaginatedResponse<PopupApiData>) => data.data.map(transformApiToFrontend),
transform: (data: PaginatedApiResponse<PopupApiData>) => data.data.map(transformApiToFrontend),
errorMessage: '팝업 목록 조회에 실패했습니다.',
});
return result.data || [];

View File

@@ -1,11 +1,8 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { createCrudService, type ActionResult } from '@/lib/api/create-crud-service';
import type { Title } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PositionApiData {
id: number;
@@ -18,60 +15,43 @@ interface PositionApiData {
updated_at?: string;
}
// ===== 데이터 변환: API → Frontend =====
function transformApiToFrontend(apiData: PositionApiData): Title {
return {
id: apiData.id,
name: apiData.name,
order: apiData.sort_order,
isActive: apiData.is_active,
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
// ===== CRUD 서비스 생성 =====
const titleService = createCrudService<PositionApiData, Title>({
basePath: '/api/v1/positions',
transform: (api) => ({
id: api.id,
name: api.name,
order: api.sort_order,
isActive: api.is_active,
createdAt: api.created_at,
updatedAt: api.updated_at,
}),
entityName: '직책',
defaultQueryParams: { type: 'title' },
defaultCreateBody: { type: 'title' },
});
// ===== Server Action 래퍼 =====
// ===== 직책 목록 조회 =====
export async function getTitles(params?: {
is_active?: boolean;
q?: string;
}): Promise<ActionResult<Title[]>> {
const searchParams = new URLSearchParams();
searchParams.set('type', 'title');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
return executeServerAction({
url: `${API_URL}/api/v1/positions?${searchParams.toString()}`,
transform: (data: PositionApiData[]) => data.map(transformApiToFrontend),
errorMessage: '직책 목록 조회에 실패했습니다.',
});
return titleService.getList(params);
}
// ===== 직책 생성 =====
export async function createTitle(data: {
name: string;
sort_order?: number;
is_active?: boolean;
}): Promise<ActionResult<Title>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions`,
method: 'POST',
body: {
type: 'title',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
},
transform: transformApiToFrontend,
errorMessage: '직책 생성에 실패했습니다.',
return titleService.create({
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
});
}
// ===== 직책 수정 =====
export async function updateTitle(
id: number,
data: {
@@ -80,32 +60,15 @@ export async function updateTitle(
is_active?: boolean;
}
): Promise<ActionResult<Title>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'PUT',
body: data,
transform: transformApiToFrontend,
errorMessage: '직책 수정에 실패했습니다.',
});
return titleService.update(id, data);
}
// ===== 직책 삭제 =====
export async function deleteTitle(id: number): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'DELETE',
errorMessage: '직책 삭제에 실패했습니다.',
});
return titleService.remove(id);
}
// ===== 직책 순서 변경 =====
export async function reorderTitles(
items: { id: number; sort_order: number }[]
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/reorder`,
method: 'PUT',
body: { items },
errorMessage: '순서 변경에 실패했습니다.',
});
}
return titleService.reorder(items);
}