feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용

Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금

Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입

신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들

총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-19 17:31:28 +09:00
parent 1a6cde2d36
commit 1d7b028693
109 changed files with 6811 additions and 2562 deletions

View File

@@ -0,0 +1,229 @@
'use client';
/**
* 거래처(영업) 상세 클라이언트 컴포넌트 V2
* IntegratedDetailTemplate 기반 마이그레이션
*
* 클라이언트 사이드 데이터 페칭 (useClientList 훅 활용)
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Client, ClientFormData } from '@/hooks/useClientList';
import { useClientList, transformClientToApiCreate, transformClientToApiUpdate } from '@/hooks/useClientList';
import { clientDetailConfig } from './clientDetailConfig';
import { toast } from 'sonner';
interface ClientDetailClientV2Props {
clientId?: string;
initialMode?: DetailMode;
}
// 8자리 영문+숫자 코드 생성
function generateCode(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
const { fetchClient, createClient, updateClient, deleteClient } = useClientList();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
const isNewMode = !clientId || clientId === 'new';
const [mode, setMode] = useState<DetailMode>(() => {
if (isNewMode) return 'create';
if (initialMode) return initialMode;
if (modeFromQuery === 'edit') return 'edit';
return 'view';
});
const [clientData, setClientData] = useState<Client | null>(null);
const [isLoading, setIsLoading] = useState(!isNewMode);
const [generatedCode, setGeneratedCode] = useState<string>('');
// 데이터 로드
useEffect(() => {
const loadData = async () => {
if (isNewMode) {
// 신규 등록 시 코드 생성
const code = generateCode();
setGeneratedCode(code);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const data = await fetchClient(clientId!);
if (data) {
setClientData(data);
} else {
toast.error('거래처를 불러오는데 실패했습니다.');
router.push(clientDetailConfig.basePath);
}
} catch (error) {
console.error('거래처 조회 실패:', error);
toast.error('거래처를 불러오는데 실패했습니다.');
router.push(clientDetailConfig.basePath);
} finally {
setIsLoading(false);
}
};
loadData();
}, [clientId, isNewMode, router, fetchClient]);
// URL 쿼리 변경 감지
useEffect(() => {
if (!isNewMode && modeFromQuery === 'edit') {
setMode('edit');
} else if (!isNewMode && !modeFromQuery) {
setMode('view');
}
}, [modeFromQuery, isNewMode]);
// 모드 변경 핸들러
const handleModeChange = useCallback(
(newMode: DetailMode) => {
setMode(newMode);
if (newMode === 'edit' && clientId) {
router.push(`${clientDetailConfig.basePath}/${clientId}?mode=edit`);
} else if (newMode === 'view' && clientId) {
router.push(`${clientDetailConfig.basePath}/${clientId}`);
}
},
[router, clientId]
);
// 저장 핸들러
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
// formData를 ClientFormData로 변환
const clientFormData: ClientFormData = {
clientCode: (formData.clientCode as string) || generatedCode,
name: formData.name as string,
businessNo: formData.businessNo as string,
representative: formData.representative as string,
phone: formData.phone as string,
address: formData.address as string,
email: formData.email as string,
businessType: formData.businessType as string,
businessItem: formData.businessItem as string,
isActive: formData.isActive === 'true',
clientType: (formData.clientType as ClientFormData['clientType']) || '매입',
mobile: formData.mobile as string,
fax: formData.fax as string,
managerName: formData.managerName as string,
managerTel: formData.managerTel as string,
systemManager: formData.systemManager as string,
accountId: formData.accountId as string || '',
accountPassword: formData.accountPassword as string || '',
purchasePaymentDay: '말일',
salesPaymentDay: '말일',
taxAgreement: false,
taxAmount: '',
taxStartDate: '',
taxEndDate: '',
badDebt: false,
badDebtAmount: '',
badDebtReceiveDate: '',
badDebtEndDate: '',
badDebtProgress: '',
memo: formData.memo as string || '',
};
if (isNewMode) {
const result = await createClient(clientFormData);
if (result) {
toast.success('거래처가 등록되었습니다.');
router.push(clientDetailConfig.basePath);
return { success: true };
}
return { success: false, error: '거래처 등록에 실패했습니다.' };
} else {
const result = await updateClient(clientId!, clientFormData);
if (result) {
toast.success('거래처가 수정되었습니다.');
router.push(`${clientDetailConfig.basePath}/${clientId}`);
return { success: true };
}
return { success: false, error: '거래처 수정에 실패했습니다.' };
}
} catch (error) {
console.error('저장 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' };
}
},
[isNewMode, clientId, generatedCode, router, createClient, updateClient]
);
// 삭제 핸들러
const handleDelete = useCallback(
async (id: string | number) => {
try {
const result = await deleteClient(String(id));
if (result) {
toast.success('거래처가 삭제되었습니다.');
router.push(clientDetailConfig.basePath);
return { success: true };
}
return { success: false, error: '거래처 삭제에 실패했습니다.' };
} catch (error) {
console.error('삭제 실패:', error);
return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' };
}
},
[router, deleteClient]
);
// 취소 핸들러
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push(clientDetailConfig.basePath);
} else {
setMode('view');
router.push(`${clientDetailConfig.basePath}/${clientId}`);
}
}, [router, clientId, isNewMode]);
// 초기 데이터 (신규 등록 시 코드 포함)
const initialData = isNewMode
? ({ code: generatedCode } as Client)
: clientData || undefined;
// 타이틀 동적 설정
const dynamicConfig = {
...clientDetailConfig,
title:
mode === 'create'
? '거래처'
: mode === 'edit'
? clientData?.name || '거래처'
: clientData?.name || '거래처 상세',
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={mode}
initialData={initialData}
itemId={clientId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={handleDelete}
onCancel={handleCancel}
onModeChange={handleModeChange}
/>
);
}

View File

@@ -0,0 +1,257 @@
'use server';
/**
* 거래처(영업) 서버 액션
*
* API Endpoints:
* - GET /api/v1/clients/{id} - 상세 조회
* - POST /api/v1/clients - 등록
* - PUT /api/v1/clients/{id} - 수정
* - DELETE /api/v1/clients/{id} - 삭제
*
* 🚨 401 에러 시 __authError: true 반환 → 클라이언트에서 로그인 페이지로 리다이렉트
*/
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { Client, ClientFormData, ClientApiResponse } from '@/hooks/useClientList';
import {
transformClientFromApi,
transformClientToApiCreate,
transformClientToApiUpdate,
} from '@/hooks/useClientList';
// ===== 응답 타입 =====
interface ActionResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
__authError?: boolean;
}
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// ===== 거래처 단건 조회 =====
export async function getClientById(id: string): Promise<ActionResponse<Client>> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
console.log('[ClientActions] GET client URL:', url);
const { response, error } = await serverFetch(url, { method: 'GET' });
// 🚨 401 인증 에러
if (error?.__authError) {
console.error('[ClientActions] Auth error:', error);
return { success: false, __authError: true };
}
if (!response) {
console.error('[ClientActions] No response, error:', error);
return {
success: false,
error: error?.message || '서버 응답이 없습니다.',
};
}
// 응답 텍스트 먼저 읽기
const responseText = await response.text();
console.log('[ClientActions] Response status:', response.status);
console.log('[ClientActions] Response text:', responseText);
if (!response.ok) {
console.error('[ClientActions] GET client error:', response.status);
return {
success: false,
error: `API 오류: ${response.status}`,
};
}
// JSON 파싱
let result: ApiResponse<ClientApiResponse>;
try {
result = JSON.parse(responseText);
} catch {
console.error('[ClientActions] JSON parse error');
return {
success: false,
error: 'JSON 파싱 오류',
};
}
if (!result.success || !result.data) {
console.error('[ClientActions] API returned error:', result);
return {
success: false,
error: result.message || '거래처를 찾을 수 없습니다.',
};
}
return {
success: true,
data: transformClientFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ClientActions] getClientById error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '거래처 조회 중 오류가 발생했습니다.',
};
}
}
// ===== 거래처 생성 =====
export async function createClient(
formData: Partial<ClientFormData>
): Promise<ActionResponse<Client>> {
try {
const apiData = transformClientToApiCreate(formData as ClientFormData);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients`;
console.log('[ClientActions] POST client request:', apiData);
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify(apiData),
});
// 🚨 401 인증 에러
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!response) {
return {
success: false,
error: error?.message || '서버 오류가 발생했습니다.',
};
}
const result: ApiResponse<ClientApiResponse> = await response.json();
console.log('[ClientActions] POST client response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '거래처 생성에 실패했습니다.',
};
}
return {
success: true,
data: transformClientFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ClientActions] createClient error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '거래처 생성 중 오류가 발생했습니다.',
};
}
}
// ===== 거래처 수정 =====
export async function updateClient(
id: string,
formData: Partial<ClientFormData>
): Promise<ActionResponse<Client>> {
try {
const apiData = transformClientToApiUpdate(formData as ClientFormData);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
console.log('[ClientActions] PUT client request:', apiData);
const { response, error } = await serverFetch(url, {
method: 'PUT',
body: JSON.stringify(apiData),
});
// 🚨 401 인증 에러
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!response) {
return {
success: false,
error: error?.message || '서버 오류가 발생했습니다.',
};
}
const result: ApiResponse<ClientApiResponse> = await response.json();
console.log('[ClientActions] PUT client response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '거래처 수정에 실패했습니다.',
};
}
return {
success: true,
data: transformClientFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ClientActions] updateClient error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '거래처 수정 중 오류가 발생했습니다.',
};
}
}
// ===== 거래처 삭제 =====
export async function deleteClient(id: string): Promise<ActionResponse> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
const { response, error } = await serverFetch(url, { method: 'DELETE' });
// 🚨 401 인증 에러
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!response) {
return {
success: false,
error: error?.message || '서버 오류가 발생했습니다.',
};
}
const result = await response.json();
console.log('[ClientActions] DELETE client response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '거래처 삭제에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ClientActions] deleteClient error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '거래처 삭제 중 오류가 발생했습니다.',
};
}
}
// ===== 거래처 코드 생성 (8자리 영문+숫자) =====
export async function generateClientCode(): Promise<string> {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}

View File

@@ -0,0 +1,276 @@
/**
* 거래처(영업) 상세 페이지 설정
* IntegratedDetailTemplate V2 마이그레이션
*/
import { Building2 } from 'lucide-react';
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
import type { ClientFormData, Client } from '@/hooks/useClientList';
// ===== 거래처 유형 옵션 =====
const CLIENT_TYPE_OPTIONS = [
{ value: '매입', label: '매입' },
{ value: '매출', label: '매출' },
{ value: '매입매출', label: '매입매출' },
];
// ===== 상태 옵션 =====
const STATUS_OPTIONS = [
{ value: 'true', label: '활성' },
{ value: 'false', label: '비활성' },
];
// ===== 필드 정의 =====
export const clientFields: FieldDefinition[] = [
// 기본 정보
{
key: 'businessNo',
label: '사업자등록번호',
type: 'text',
required: true,
placeholder: '10자리 숫자 (예: 123-45-67890)',
validation: [
{ type: 'required', message: '사업자등록번호를 입력해주세요.' },
{
type: 'custom',
message: '사업자등록번호는 10자리 숫자여야 합니다.',
validate: (value) => {
const digits = String(value || '').replace(/-/g, '').trim();
return /^\d{10}$/.test(digits);
},
},
],
},
{
key: 'clientCode',
label: '거래처 코드',
type: 'text',
disabled: true,
helpText: '자동 생성됩니다',
},
{
key: 'name',
label: '거래처명',
type: 'text',
required: true,
placeholder: '거래처명 입력',
validation: [
{ type: 'required', message: '거래처명을 입력해주세요.' },
{ type: 'minLength', value: 2, message: '거래처명은 2자 이상 입력해주세요.' },
],
},
{
key: 'representative',
label: '대표자명',
type: 'text',
required: true,
placeholder: '대표자명 입력',
validation: [
{ type: 'required', message: '대표자명을 입력해주세요.' },
{ type: 'minLength', value: 2, message: '대표자명은 2자 이상 입력해주세요.' },
],
},
{
key: 'clientType',
label: '거래처 유형',
type: 'radio',
required: true,
options: CLIENT_TYPE_OPTIONS,
},
{
key: 'businessType',
label: '업태',
type: 'text',
placeholder: '제조업, 도소매업 등',
},
{
key: 'businessItem',
label: '종목',
type: 'text',
placeholder: '철강, 건설 등',
},
// 연락처 정보
{
key: 'address',
label: '주소',
type: 'text',
placeholder: '주소 입력',
gridSpan: 2,
},
{
key: 'phone',
label: '전화번호',
type: 'tel',
placeholder: '02-1234-5678',
},
{
key: 'mobile',
label: '모바일',
type: 'tel',
placeholder: '010-1234-5678',
},
{
key: 'fax',
label: '팩스',
type: 'tel',
placeholder: '02-1234-5678',
},
{
key: 'email',
label: '이메일',
type: 'email',
placeholder: 'example@company.com',
validation: [
{
type: 'custom',
message: '올바른 이메일 형식이 아닙니다.',
validate: (value) => {
if (!value) return true; // 선택 필드
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value));
},
},
],
},
// 담당자 정보
{
key: 'managerName',
label: '담당자명',
type: 'text',
placeholder: '담당자명 입력',
},
{
key: 'managerTel',
label: '담당자 전화',
type: 'tel',
placeholder: '010-1234-5678',
},
{
key: 'systemManager',
label: '시스템 관리자',
type: 'text',
placeholder: '시스템 관리자명',
},
// 기타 정보
{
key: 'memo',
label: '메모',
type: 'textarea',
placeholder: '메모 입력',
gridSpan: 2,
},
{
key: 'isActive',
label: '상태',
type: 'radio',
options: STATUS_OPTIONS,
defaultValue: 'true',
},
];
// ===== 섹션 정의 =====
export const clientSections: SectionDefinition[] = [
{
id: 'basicInfo',
title: '기본 정보',
description: '거래처의 기본 정보를 입력하세요',
fields: ['businessNo', 'clientCode', 'name', 'representative', 'clientType', 'businessType', 'businessItem'],
},
{
id: 'contactInfo',
title: '연락처 정보',
description: '거래처의 연락처 정보를 입력하세요',
fields: ['address', 'phone', 'mobile', 'fax', 'email'],
},
{
id: 'managerInfo',
title: '담당자 정보',
description: '거래처 담당자 정보를 입력하세요',
fields: ['managerName', 'managerTel', 'systemManager'],
},
{
id: 'otherInfo',
title: '기타 정보',
description: '추가 정보를 입력하세요',
fields: ['memo', 'isActive'],
},
];
// ===== 설정 =====
export const clientDetailConfig: DetailConfig<Client> = {
title: '거래처',
description: '거래처 정보를 관리합니다',
icon: Building2,
basePath: '/ko/sales/client-management-sales-admin',
fields: clientFields,
sections: clientSections,
gridColumns: 2,
actions: {
submitLabel: '저장',
cancelLabel: '취소',
showDelete: true,
deleteLabel: '삭제',
showEdit: true,
editLabel: '수정',
showBack: true,
backLabel: '목록',
deleteConfirmMessage: {
title: '거래처 삭제',
description: '이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
transformInitialData: (data: Client) => ({
businessNo: data.businessNo || '',
clientCode: data.code || '',
name: data.name || '',
representative: data.representative || '',
clientType: data.clientType || '매입',
businessType: data.businessType || '',
businessItem: data.businessItem || '',
address: data.address || '',
phone: data.phone || '',
mobile: data.mobile || '',
fax: data.fax || '',
email: data.email || '',
managerName: data.managerName || '',
managerTel: data.managerTel || '',
systemManager: data.systemManager || '',
memo: data.memo || '',
isActive: data.status === '활성' ? 'true' : 'false',
}),
transformSubmitData: (formData): Partial<ClientFormData> => ({
clientCode: formData.clientCode as string,
name: formData.name as string,
businessNo: formData.businessNo as string,
representative: formData.representative as string,
clientType: formData.clientType as ClientFormData['clientType'],
businessType: formData.businessType as string,
businessItem: formData.businessItem as string,
address: formData.address as string,
phone: formData.phone as string,
mobile: formData.mobile as string,
fax: formData.fax as string,
email: formData.email as string,
managerName: formData.managerName as string,
managerTel: formData.managerTel as string,
systemManager: formData.systemManager as string,
memo: formData.memo as string,
isActive: formData.isActive === 'true',
// 기본값 설정
purchasePaymentDay: '말일',
salesPaymentDay: '말일',
taxAgreement: false,
taxAmount: '',
taxStartDate: '',
taxEndDate: '',
badDebt: false,
badDebtAmount: '',
badDebtReceiveDate: '',
badDebtEndDate: '',
badDebtProgress: '',
accountId: '',
accountPassword: '',
}),
};