feat(WEB): Phase 6 IntegratedDetailTemplate 마이그레이션 완료

Phase 6 마이그레이션 (41개 컴포넌트 완료):
- 건설/시공: 협력업체, 시공관리, 기성관리, 발주관리, 계약관리 등
- 영업: 견적관리(V2), 고객관리(V2), 수주관리
- 회계: 청구관리, 매입관리, 매출관리, 거래처관리, 악성채권 등
- 생산: 작업지시, 검수관리
- 출고: 출하관리
- 자재: 입고관리, 재고현황
- 고객센터: 문의관리, 이벤트관리, 공지관리
- 인사: 직원관리
- 설정: 권한관리

주요 변경사항:
- 34개 xxxConfig.ts 파일 생성 (설정 기반 페이지 구성)
- PageLayout/PageHeader → IntegratedDetailTemplate 통합
- 일관된 타이틀/버튼 영역 (목록, 상세, 수정, 삭제)
- 1112줄 코드 감소 (중복 제거)

프로젝트 공통화 현황 분석 문서 추가:
- 상세 페이지 62%, 목록 페이지 82% 공통화 달성
- 추가 공통화 기회 및 로드맵 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-20 15:51:02 +09:00
parent 6f457b28f3
commit 61e3a0ed60
71 changed files with 4743 additions and 4402 deletions

View File

@@ -1,10 +1,15 @@
'use client';
/**
* 악성채권 추심관리 상세 페이지
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*/
import { useState, useCallback, useMemo } from 'react';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { AlertTriangle, Plus, X, FileText, Receipt, CreditCard, Upload, Download, Trash2 } from 'lucide-react';
import { Plus, X, FileText, Receipt, CreditCard, Upload, Download, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -28,14 +33,13 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { badDebtConfig } from './badDebtConfig';
import { toast } from 'sonner';
import type {
BadDebtRecord,
BadDebtMemo,
Manager,
AttachedFile,
CollectionStatus,
} from './types';
import {
@@ -130,10 +134,6 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
}, []);
// 네비게이션 핸들러
const handleBack = useCallback(() => {
router.push('/ko/accounting/bad-debt-collection');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/bad-debt-collection/${recordId}?mode=edit`);
}, [router, recordId]);
@@ -331,31 +331,44 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
setNewAdditionalFiles(prev => prev.filter((_, i) => i !== index));
}, []);
// 헤더 버튼
const headerActions = useMemo(() => {
// 동적 config (mode에 따라 title 변경)
const dynamicConfig = useMemo(() => {
const titleMap: Record<string, string> = {
new: '악성채권 등록',
edit: '악성채권 수정',
view: '악성채권 추심관리 상세',
};
return {
...badDebtConfig,
title: titleMap[mode] || badDebtConfig.title,
};
}, [mode]);
// 커스텀 헤더 액션 (저장 확인 다이얼로그 패턴 유지)
const customHeaderActions = useMemo(() => {
if (isViewMode) {
return (
<div className="flex gap-2">
<>
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
{isLoading ? '처리중...' : '삭제'}
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
</Button>
</div>
</>
);
}
return (
<div className="flex gap-2">
<>
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
</Button>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading ? '처리중...' : (isNewMode ? '등록' : '저장')}
</Button>
</div>
</>
);
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave]);
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave, mode]);
// 입력 필드 렌더링 헬퍼
const renderField = (
@@ -387,17 +400,9 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
);
};
return (
<PageLayout>
<PageHeader
title="악성채권 추심관리 상세"
description="추심 대상 업체 정보를 표시"
icon={AlertTriangle}
actions={headerActions}
onBack={handleBack}
/>
<div className="space-y-6">
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
@@ -956,6 +961,40 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
</CardContent>
</Card>
</div>
), [
formData,
isViewMode,
isNewMode,
newMemo,
newBusinessRegistrationFile,
newTaxInvoiceFile,
newAdditionalFiles,
handleChange,
handleAddMemo,
handleDeleteMemo,
handleManagerChange,
handleBillStatus,
handleReceivablesStatus,
handleFileDownload,
handleDeleteExistingFile,
handleAddAdditionalFile,
handleRemoveNewAdditionalFile,
openPostcode,
renderField,
]);
return (
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode={isNewMode ? 'create' : (isViewMode ? 'view' : 'edit')}
initialData={formData}
itemId={recordId}
isLoading={isLoading}
headerActions={customHeaderActions}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -1000,6 +1039,6 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { AlertTriangle } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 악성채권 추심관리 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 renderView/renderForm에서 처리
*
* 특이사항:
* - view/edit/new 모드 지원
* - 저장 확인 다이얼로그 (커스텀 headerActions 사용)
* - 파일 업로드/다운로드
* - 메모 추가/삭제
*/
export const badDebtConfig: DetailConfig = {
title: '악성채권 추심관리 상세',
description: '추심 대상 업체 정보를 표시',
icon: AlertTriangle,
basePath: '/accounting/bad-debt-collection',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
editLabel: '수정',
deleteLabel: '삭제',
cancelLabel: '취소',
saveLabel: '저장',
createLabel: '등록',
},
};

View File

@@ -2,15 +2,8 @@
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
FileText,
Plus,
X,
Loader2,
List,
} from 'lucide-react';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -21,16 +14,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Table,
TableBody,
@@ -39,9 +22,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { billConfig } from './billConfig';
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
import {
BILL_TYPE_OPTIONS,
@@ -68,8 +51,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
// ===== 로딩 상태 =====
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
@@ -84,7 +65,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
const [status, setStatus] = useState<BillStatus>('stored');
const [note, setNote] = useState('');
const [installments, setInstallments] = useState<InstallmentRecord[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// ===== 거래처 목록 로드 =====
useEffect(() => {
@@ -127,36 +107,36 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
}, [billId, router]);
// ===== 저장 핸들러 =====
const handleSave = useCallback(async () => {
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
if (!billNumber.trim()) {
toast.error('어음번호를 입력해주세요.');
return;
return { success: false, error: '어음번호를 입력해주세요.' };
}
if (!vendorId) {
toast.error('거래처를 선택해주세요.');
return;
return { success: false, error: '거래처를 선택해주세요.' };
}
if (amount <= 0) {
toast.error('금액을 입력해주세요.');
return;
return { success: false, error: '금액을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < installments.length; i++) {
const inst = installments[i];
if (!inst.date) {
toast.error(`차수 ${i + 1}번의 일자를 입력해주세요.`);
return;
const errorMsg = `차수 ${i + 1}번의 일자를 입력해주세요.`;
toast.error(errorMsg);
return { success: false, error: errorMsg };
}
if (inst.amount <= 0) {
toast.error(`차수 ${i + 1}번의 금액을 입력해주세요.`);
return;
const errorMsg = `차수 ${i + 1}번의 금액을 입력해주세요.`;
toast.error(errorMsg);
return { success: false, error: errorMsg };
}
}
setIsSaving(true);
const billData: Partial<BillRecord> = {
billNumber,
billType,
@@ -177,8 +157,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
result = await updateBill(billId, billData);
}
setIsSaving(false);
if (result.success) {
toast.success(isNewMode ? '어음이 등록되었습니다.' : '어음이 수정되었습니다.');
if (isNewMode) {
@@ -186,42 +164,24 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
} else {
router.push(`/ko/accounting/bills/${billId}`);
}
return { success: true };
} else {
toast.error(result.error || '저장에 실패했습니다.');
return { success: false, error: result.error || '저장에 실패했습니다.' };
}
}, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode, clients]);
// ===== 취소 핸들러 =====
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/bills');
} else {
router.push(`/ko/accounting/bills/${billId}`);
}
}, [router, billId, isNewMode]);
// ===== 목록으로 이동 =====
const handleBack = useCallback(() => {
router.push('/ko/accounting/bills');
}, [router]);
// ===== 수정 모드로 이동 =====
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/bills/${billId}?mode=edit`);
}, [router, billId]);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async () => {
setIsDeleting(true);
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
const result = await deleteBill(billId);
setIsDeleting(false);
setShowDeleteDialog(false);
if (result.success) {
toast.success('어음이 삭제되었습니다.');
router.push('/ko/accounting/bills');
return { success: true };
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: false, error: result.error || '삭제에 실패했습니다.' };
}
}, [billId, router]);
@@ -251,60 +211,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
// ===== 상태 옵션 (구분에 따라 변경) =====
const statusOptions = getBillStatusOptions(billType);
// ===== 로딩 중 =====
if (isLoading) {
return (
<PageLayout>
<ContentLoadingSpinner text="어음 정보를 불러오는 중..." />
</PageLayout>
);
}
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title={isNewMode ? '어음 등록' : isViewMode ? '어음 상세' : '어음 수정'}
description="어음 및 수취어음 상세 현황을 관리합니다"
icon={FileText}
/>
{/* 헤더 액션 버튼 */}
<div className="flex items-center justify-end gap-2 mb-6">
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => setShowDeleteDialog(true)}
>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
) : (
<>
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-blue-500 hover:bg-blue-600"
>
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isNewMode ? '등록' : '저장'}
</Button>
</>
)}
</div>
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = () => (
<>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
@@ -522,29 +431,31 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
</Table>
</CardContent>
</Card>
</>
);
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-500 hover:bg-red-600"
>
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
// ===== 템플릿 모드 및 동적 설정 =====
const templateMode = isNewMode ? 'create' : mode;
const dynamicConfig = {
...billConfig,
title: isNewMode ? '어음 등록' : '어음 상세',
actions: {
...billConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={{}}
itemId={billId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -0,0 +1,32 @@
import { FileText } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 어음 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 기존 BillDetail의 renderView/renderForm에서 처리
* (차수 관리 테이블 등 특수 기능 유지)
*/
export const billConfig: DetailConfig = {
title: '어음 상세',
description: '어음 및 수취어음 상세 현황을 관리합니다',
icon: FileText,
basePath: '/accounting/bills',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
submitLabel: '저장',
cancelLabel: '취소',
deleteConfirmMessage: {
title: '어음 삭제',
description: '이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
};

View File

@@ -1,7 +1,6 @@
'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -24,19 +23,9 @@ import {
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { FileText, Plus, X, Eye, Receipt, List } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { FileText, Plus, X, Eye } from 'lucide-react';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { purchaseConfig } from './purchaseConfig';
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type { ProposalDocumentData, ExpenseReportDocumentData } from '@/components/approval/DocumentDetail/types';
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
@@ -73,7 +62,6 @@ const createEmptyItem = (): PurchaseItem => ({
});
export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
@@ -100,7 +88,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
// ===== 다이얼로그 상태 =====
const [documentModalOpen, setDocumentModalOpen] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// ===== 거래처 목록 로드 =====
useEffect(() => {
@@ -203,11 +190,11 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
});
}, []);
// ===== 저장 =====
const handleSave = useCallback(async () => {
// ===== 저장 (IntegratedDetailTemplate 호환) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!vendorId) {
toast.warning('거래처를 선택해주세요.');
return;
return { success: false, error: '거래처를 선택해주세요.' };
}
setIsSaving(true);
@@ -232,94 +219,42 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
if (result?.success) {
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
router.push('/ko/accounting/purchase');
return { success: true };
} else {
toast.error(result?.error || '저장에 실패했습니다.');
return { success: false, error: result?.error || '저장에 실패했습니다.' };
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
return { success: false, error: '저장 중 오류가 발생했습니다.' };
} finally {
setIsSaving(false);
}
}, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId, router]);
}, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId]);
const handleBack = useCallback(() => {
router.push('/ko/accounting/purchase');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/purchase/${purchaseId}?mode=edit`);
}, [router, purchaseId]);
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/purchase');
} else {
router.push(`/ko/accounting/purchase/${purchaseId}`);
}
}, [router, purchaseId, isNewMode]);
// ===== 삭제 =====
const handleDelete = useCallback(async () => {
if (!purchaseId) return;
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!purchaseId) return { success: false, error: 'ID가 없습니다.' };
try {
const result = await deletePurchase(purchaseId);
setShowDeleteDialog(false);
if (result.success) {
toast.success('매입이 삭제되었습니다.');
router.push('/ko/accounting/purchase');
return { success: true };
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: false, error: result.error || '삭제에 실패했습니다.' };
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, [purchaseId, router]);
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title={isNewMode ? '매입 등록' : '매입 상세'}
description="매입 상세를 등록하고 관리합니다"
icon={Receipt}
/>
{/* 헤더 액션 버튼 */}
<div className="flex items-center justify-end gap-2 mb-6">
{/* view 모드: [목록] [삭제] [수정] */}
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => setShowDeleteDialog(true)}
>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
) : (
/* edit/new 모드: [취소] [저장/등록] */
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
{isSaving ? '저장 중...' : isNewMode ? '등록' : '저장'}
</Button>
</>
)}
</div>
}, [purchaseId]);
// ===== 폼 내용 렌더링 =====
const renderFormContent = () => (
<>
<div className="space-y-6">
{/* ===== 기본 정보 섹션 ===== */}
<Card>
@@ -732,26 +667,33 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
/>
)}
{/* ===== 삭제 확인 다이얼로그 ===== */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
</>
);
// ===== 모드 변환 =====
const templateMode = isNewMode ? 'create' : mode;
// ===== 동적 config =====
const dynamicConfig = {
...purchaseConfig,
title: isNewMode ? '매입 등록' : '매입 상세',
actions: {
...purchaseConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={{}}
itemId={purchaseId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={purchaseId && !isNewMode ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -0,0 +1,32 @@
import { Receipt } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 매입 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 기존 PurchaseDetail의 renderView/renderForm에서 처리
* (품목 테이블, 품의서/지출결의서, 세금계산서 등 특수 기능 유지)
*/
export const purchaseConfig: DetailConfig = {
title: '매입 상세',
description: '매입 상세를 등록하고 관리합니다',
icon: Receipt,
basePath: '/accounting/purchase',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
submitLabel: '저장',
cancelLabel: '취소',
deleteConfirmMessage: {
title: '매입 삭제',
description: '이 매입 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
};

View File

@@ -1,19 +1,13 @@
'use client';
import { useState, useCallback, useMemo, useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { format } from 'date-fns';
import {
Receipt,
Save,
Trash2,
Plus,
X,
Send,
FileText,
List,
} from 'lucide-react';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -37,15 +31,15 @@ import {
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
// 삭제 다이얼로그는 IntegratedDetailTemplate이 처리함
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { salesConfig } from './salesConfig';
import type { SalesRecord, SalesItem, SalesType } from './types';
import { SALES_TYPE_OPTIONS } from './types';
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
@@ -77,8 +71,6 @@ const createEmptyItem = (): SalesItem => ({
});
export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
@@ -100,8 +92,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const [transactionStatementIssued, setTransactionStatementIssued] = useState(false);
const [note, setNote] = useState('');
// ===== 알림 다이얼로그 상태 =====
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// ===== 알림 다이얼로그 상태 (이메일 발송용) =====
const [showEmailAlert, setShowEmailAlert] = useState(false);
const [emailAlertMessage, setEmailAlertMessage] = useState('');
@@ -200,11 +191,11 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
});
}, []);
// ===== 저장 =====
const handleSave = useCallback(async () => {
// ===== 저장 (IntegratedDetailTemplate 호환) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!vendorId) {
toast.warning('거래처를 선택해주세요.');
return;
return { success: false, error: '거래처를 선택해주세요.' };
}
setIsSaving(true);
@@ -231,56 +222,38 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
if (result?.success) {
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
router.push('/ko/accounting/sales');
return { success: true };
} else {
toast.error(result?.error || '저장에 실패했습니다.');
return { success: false, error: result?.error || '저장에 실패했습니다.' };
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
return { success: false, error: '저장 중 오류가 발생했습니다.' };
} finally {
setIsSaving(false);
}
}, [salesDate, vendorId, salesType, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId, router]);
}, [salesDate, vendorId, salesType, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
// ===== 삭제 =====
const handleDelete = useCallback(async () => {
if (!salesId) return;
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!salesId) return { success: false, error: 'ID가 없습니다.' };
try {
const result = await deleteSale(salesId);
setShowDeleteDialog(false);
if (result.success) {
toast.success('매출이 삭제되었습니다.');
router.push('/ko/accounting/sales');
return { success: true };
} else {
toast.error(result.error || '삭제에 실패했습니다.');
return { success: false, error: result.error || '삭제에 실패했습니다.' };
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
}
}, [salesId, router]);
// ===== 목록으로 이동 =====
const handleBack = useCallback(() => {
router.push('/ko/accounting/sales');
}, [router]);
// ===== 수정 모드로 이동 =====
const handleEdit = useCallback(() => {
if (salesId) {
router.push(`/ko/accounting/sales/${salesId}?mode=edit`);
}
}, [router, salesId]);
// ===== 취소 (수정/등록 모드에서) =====
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/sales');
} else if (salesId) {
router.push(`/ko/accounting/sales/${salesId}`);
}
}, [router, salesId, isNewMode]);
}, [salesId]);
// ===== 거래명세서 발행 =====
const handleSendTransactionStatement = useCallback(() => {
@@ -296,57 +269,9 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
return amount.toLocaleString();
};
// ===== 로딩 상태 =====
if (isLoading) {
return (
<PageLayout>
<ContentLoadingSpinner text="매출 정보를 불러오는 중..." />
</PageLayout>
);
}
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title={isNewMode ? '매출 상세_직접 등록' : '매출 상세'}
description="매출 상세를 등록하고 관리합니다"
icon={Receipt}
/>
{/* 헤더 액션 버튼 */}
<div className="flex items-center justify-end gap-2 mb-6">
{/* view 모드: [목록] [삭제] [수정] */}
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => setShowDeleteDialog(true)}
>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
) : (
/* edit/new 모드: [취소] [저장/등록] */
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600">
{isNewMode ? '등록' : '저장'}
</Button>
</>
)}
</div>
// ===== 폼 내용 렌더링 =====
const renderFormContent = () => (
<>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
@@ -610,24 +535,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
</CardContent>
</Card>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 이메일 발송 알림 다이얼로그 */}
<AlertDialog open={showEmailAlert} onOpenChange={setShowEmailAlert}>
<AlertDialogContent>
@@ -642,6 +549,33 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
</>
);
// ===== 모드 변환 =====
const templateMode = isNewMode ? 'create' : mode;
// ===== 동적 config =====
const dynamicConfig = {
...salesConfig,
title: isNewMode ? '매출 상세_직접 등록' : '매출 상세',
actions: {
...salesConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={{}}
itemId={salesId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={salesId ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -0,0 +1,32 @@
import { Receipt } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 매출 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 기존 SalesDetail의 renderView/renderForm에서 처리
* (품목 테이블, 세금계산서, 거래명세서 등 특수 기능 유지)
*/
export const salesConfig: DetailConfig = {
title: '매출 상세',
description: '매출 상세를 등록하고 관리합니다',
icon: Receipt,
basePath: '/accounting/sales',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
submitLabel: '저장',
cancelLabel: '취소',
deleteConfirmMessage: {
title: '매출 삭제',
description: '이 매출 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
},
},
};

View File

@@ -1,9 +1,14 @@
'use client';
/**
* 거래처원장 상세 페이지
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import { FileText, Download, Pencil, List } from 'lucide-react';
import { Download, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Card, CardContent } from '@/components/ui/card';
@@ -15,7 +20,8 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { PageLayout } from '@/components/organisms/PageLayout';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { vendorLedgerConfig } from './vendorLedgerConfig';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import type { VendorLedgerDetail as VendorLedgerDetailType, TransactionEntry, VendorLedgerSummary } from './types';
import { getVendorLedgerDetail, exportVendorLedgerDetailPdf } from './actions';
@@ -82,10 +88,6 @@ export function VendorLedgerDetail({
}, [loadData]);
// ===== 핸들러 =====
const handleBack = useCallback(() => {
router.push('/ko/accounting/vendor-ledger');
}, [router]);
const handlePdfDownload = useCallback(async () => {
const result = await exportVendorLedgerDetailPdf(vendorId, {
startDate,
@@ -135,49 +137,39 @@ export function VendorLedgerDetail({
return vendorDetail.transactions;
}, [vendorDetail]);
// 로딩 상태 표시
if (isLoading && !vendorDetail) {
// 커스텀 헤더 액션 (PDF 다운로드 버튼)
const customHeaderActions = useMemo(() => {
return (
<PageLayout>
<ContentLoadingSpinner text="거래처 원장을 불러오는 중..." />
</PageLayout>
<Button
variant="outline"
size="sm"
onClick={handlePdfDownload}
>
<Download className="mr-2 h-4 w-4" />
PDF
</Button>
);
}
}, [handlePdfDownload]);
// 데이터 없음
if (!vendorDetail) {
return (
<PageLayout>
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => {
// 로딩 상태 표시
if (isLoading && !vendorDetail) {
return <ContentLoadingSpinner text="거래처 원장을 불러오는 중..." />;
}
// 데이터 없음
if (!vendorDetail) {
return (
<div className="flex flex-col items-center justify-center h-64">
<p className="text-gray-500 mb-4"> .</p>
<Button variant="outline" onClick={handleBack}>
</Button>
</div>
</PageLayout>
);
}
);
}
return (
<PageLayout>
{/* 헤더 */}
<div className="flex items-center gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 rounded-lg">
<FileText className="h-5 w-5 text-orange-600" />
</div>
<div>
<h1 className="text-xl font-bold"> ()</h1>
<p className="text-sm text-gray-500"> .</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 기간 선택 영역 */}
return (
<div className="space-y-6">
{/* 기간 선택 영역 */}
<div className="mb-6">
<DateRangeSelector
startDate={startDate}
@@ -361,6 +353,33 @@ export function VendorLedgerDetail({
)}
</CardContent>
</Card>
</PageLayout>
</div>
);
}, [
isLoading,
vendorDetail,
summary,
transactions,
startDate,
endDate,
setStartDate,
setEndDate,
handlePdfDownload,
handleEditTransaction,
formatAmount,
getRowStyle,
]);
return (
<IntegratedDetailTemplate
config={vendorLedgerConfig}
mode="view"
initialData={vendorDetail || {}}
itemId={vendorId}
isLoading={isLoading && !vendorDetail}
headerActions={customHeaderActions}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -0,0 +1,29 @@
import { FileText } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 거래처원장 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 renderView에서 처리
*
* 특이사항:
* - view 모드만 지원
* - 기간 선택 기능 (DateRangeSelector)
* - PDF 다운로드 버튼
* - 판매/수금 내역 테이블
*/
export const vendorLedgerConfig: DetailConfig = {
title: '거래처원장 상세 (거래명세서별)',
description: '거래처 상세 내역을 조회합니다.',
icon: FileText,
basePath: '/accounting/vendor-ledger',
fields: [], // renderView 사용으로 필드 정의 불필요
gridColumns: 2,
actions: {
showBack: true,
showDelete: false,
showEdit: false,
backLabel: '목록',
},
};

View File

@@ -1,13 +1,14 @@
'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useRouter } from 'next/navigation';
import { Building2, Trash2, Plus, X } from 'lucide-react';
import { Plus, X } from 'lucide-react';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { toast } from 'sonner';
import { getClientById, createClient, updateClient, deleteClient } from './actions';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { vendorConfig } from './vendorConfig';
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
@@ -28,24 +29,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Alert, AlertDescription } from '@/components/ui/alert';
import type {
Vendor,
VendorCategory,
CreditRating,
TransactionGrade,
BadDebtStatus,
VendorMemo,
} from './types';
import {
@@ -106,12 +92,14 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// IntegratedDetailTemplate 모드 변환
const templateMode = isNewMode ? 'create' : mode;
// 폼 데이터
const [formData, setFormData] = useState<Omit<Vendor, 'id' | 'createdAt' | 'updatedAt'> | Vendor>(getEmptyVendor());
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// API에서 데이터 로드 (view/edit 모드)
useEffect(() => {
@@ -147,10 +135,6 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
},
});
// 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// Validation 에러 상태
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
@@ -180,86 +164,6 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 네비게이션 핸들러
const handleBack = useCallback(() => {
router.push('/ko/accounting/vendors');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/vendors/${vendorId}?mode=edit`);
}, [router, vendorId]);
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/vendors');
} else {
router.push(`/ko/accounting/vendors/${vendorId}`);
}
}, [router, vendorId, isNewMode]);
// 저장 핸들러
const handleSave = useCallback(() => {
if (!validateForm()) {
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
// 에러 초기화
setValidationErrors({});
setShowSaveDialog(true);
}, [validateForm]);
const handleConfirmSave = useCallback(async () => {
setIsSaving(true);
try {
const result = isNewMode
? await createClient(formData)
: await updateClient(vendorId!, formData);
if (result.success) {
toast.success(isNewMode ? '거래처가 등록되었습니다.' : '거래처가 수정되었습니다.');
setShowSaveDialog(false);
if (isNewMode) {
router.push('/ko/accounting/vendors');
} else {
router.push(`/ko/accounting/vendors/${vendorId}`);
}
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [formData, router, vendorId, isNewMode]);
// 삭제 핸들러
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!vendorId) return;
setIsSaving(true);
try {
const result = await deleteClient(vendorId);
if (result.success) {
toast.success('거래처가 삭제되었습니다.');
setShowDeleteDialog(false);
router.push('/ko/accounting/vendors');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [router, vendorId]);
// 메모 추가 핸들러
const handleAddMemo = useCallback(() => {
if (!newMemo.trim()) return;
@@ -286,39 +190,45 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
}));
}, []);
// 헤더 버튼
const headerActions = useMemo(() => {
if (isViewMode) {
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack} disabled={isSaving}>
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={handleDelete}
disabled={isSaving}
>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
</Button>
</div>
);
// 저장 핸들러 (IntegratedDetailTemplate용)
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
window.scrollTo({ top: 0, behavior: 'smooth' });
return { success: false, error: '입력 내용을 확인해주세요.' };
}
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
</Button>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
{isSaving ? '처리중...' : isNewMode ? '등록' : '저장'}
</Button>
</div>
);
}, [isViewMode, isNewMode, isSaving, handleBack, handleDelete, handleEdit, handleCancel, handleSave]);
try {
const result = isNewMode
? await createClient(formData)
: await updateClient(vendorId!, formData);
if (result.success) {
router.refresh();
return { success: true };
} else {
return { success: false, error: result.error || '저장에 실패했습니다.' };
}
} catch {
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}, [formData, validateForm, isNewMode, vendorId, router]);
// 삭제 핸들러 (IntegratedDetailTemplate용)
const handleDelete = useCallback(async () => {
if (!vendorId) return { success: false, error: 'ID가 없습니다.' };
try {
const result = await deleteClient(vendorId);
if (result.success) {
router.refresh();
return { success: true };
} else {
return { success: false, error: result.error || '삭제에 실패했습니다.' };
}
} catch {
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}, [vendorId, router]);
// 입력 필드 렌더링 헬퍼
const renderField = (
@@ -383,349 +293,306 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
);
};
// ===== 로딩 상태 =====
if (isLoading) {
return (
<PageLayout>
<ContentLoadingSpinner text="거래처 정보를 불러오는 중..." />
</PageLayout>
);
}
// 폼 콘텐츠 렌더링 (View/Edit 공통)
const renderFormContent = () => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(validationErrors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('사업자등록번호', 'businessNumber', formData.businessNumber, { required: true, placeholder: '000-00-00000' })}
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성' })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
{renderField('대표자명', 'representativeName', formData.representativeName)}
{renderSelectField('거래처 유형', 'category', formData.category, VENDOR_CATEGORY_SELECTOR_OPTIONS, true)}
{renderField('업태', 'businessType', formData.businessType)}
{renderField('업종', 'businessCategory', formData.businessCategory)}
</CardContent>
</Card>
{/* 연락처 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 주소 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex gap-2">
<Input
value={formData.zipCode}
onChange={(e) => handleChange('zipCode', e.target.value)}
placeholder="우편번호"
disabled={isViewMode}
className="w-[120px] bg-white"
/>
<Button variant="outline" disabled={isViewMode} onClick={openPostcode} className="shrink-0">
</Button>
</div>
<Input
value={formData.address1}
onChange={(e) => handleChange('address1', e.target.value)}
placeholder="기본주소"
disabled={isViewMode}
className="bg-white"
/>
<Input
value={formData.address2}
onChange={(e) => handleChange('address2', e.target.value)}
placeholder="상세주소"
disabled={isViewMode}
className="bg-white"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('전화번호', 'phone', formData.phone, { type: 'tel', placeholder: '02-0000-0000' })}
{renderField('모바일', 'mobile', formData.mobile, { type: 'tel', placeholder: '010-0000-0000' })}
{renderField('팩스', 'fax', formData.fax, { type: 'tel', placeholder: '02-0000-0000' })}
{renderField('이메일', 'email', formData.email, { type: 'email' })}
</div>
</CardContent>
</Card>
{/* 담당자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('담당자명', 'managerName', formData.managerName)}
{renderField('담당자 전화', 'managerPhone', formData.managerPhone, { type: 'tel' })}
{renderField('시스템 관리자', 'systemManager', formData.systemManager)}
</CardContent>
</Card>
{/* 회사 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 회사 로고 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<p className="text-sm text-gray-500">750 X 250px, 10MB PNG, JPEG, GIF</p>
{!isViewMode && (
<Button variant="outline" className="mt-2">
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('매입 결제일', 'purchasePaymentDay', String(formData.purchasePaymentDay), PAYMENT_DAY_OPTIONS)}
{renderSelectField('매출 결제일', 'salesPaymentDay', String(formData.salesPaymentDay), PAYMENT_DAY_OPTIONS)}
</div>
</CardContent>
</Card>
{/* 신용/거래 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg">/ </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('신용등급', 'creditRating', formData.creditRating, CREDIT_RATING_SELECTOR_OPTIONS)}
{renderSelectField('거래등급', 'transactionGrade', formData.transactionGrade, TRANSACTION_GRADE_SELECTOR_OPTIONS)}
{renderField('세금계산서 이메일', 'taxInvoiceEmail', formData.taxInvoiceEmail, { type: 'email' })}
{renderSelectField('입금계좌 은행', 'bankName', formData.bankName, BANK_OPTIONS)}
{renderField('계좌', 'accountNumber', formData.accountNumber, { placeholder: '계좌번호' })}
{renderField('예금주', 'accountHolder', formData.accountHolder)}
</CardContent>
</Card>
{/* 추가 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 미수금 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
type="number"
value={formData.outstandingAmount}
onChange={(e) => handleChange('outstandingAmount', Number(e.target.value))}
disabled={isViewMode}
className="bg-white"
/>
</div>
{/* 연체 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700"></Label>
<Switch
checked={formData.overdueToggle}
onCheckedChange={(checked) => handleChange('overdueToggle', checked)}
disabled={isViewMode}
className="data-[state=checked]:bg-orange-500"
/>
</div>
<Input
type="number"
value={formData.overdueDays}
onChange={(e) => handleChange('overdueDays', Number(e.target.value))}
disabled={isViewMode}
className="bg-white"
placeholder="일"
/>
</div>
{/* 미지급 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
type="number"
value={formData.unpaidAmount}
onChange={(e) => handleChange('unpaidAmount', Number(e.target.value))}
disabled={isViewMode}
className="bg-white"
/>
</div>
{/* 악성채권 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700"></Label>
<Switch
checked={formData.badDebtToggle}
onCheckedChange={(checked) => handleChange('badDebtToggle', checked)}
disabled={isViewMode}
className="data-[state=checked]:bg-orange-500"
/>
</div>
<Select
value={formData.badDebtStatus}
onValueChange={(val) => handleChange('badDebtStatus', val)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="-" />
</SelectTrigger>
<SelectContent>
{BAD_DEBT_STATUS_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 메모 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 메모 입력 */}
{!isViewMode && (
<div className="flex gap-2">
<Textarea
value={newMemo}
onChange={(e) => setNewMemo(e.target.value)}
placeholder="메모를 입력하세요..."
className="flex-1 bg-white"
rows={2}
/>
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
)}
{/* 메모 리스트 */}
{formData.memos.length > 0 ? (
<div className="space-y-2">
{formData.memos.map((memo) => (
<div
key={memo.id}
className="flex items-start justify-between p-3 bg-gray-50 rounded-lg"
>
<p className="text-sm text-gray-700 flex-1">{memo.content}</p>
{!isViewMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={() => handleDeleteMemo(memo.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4"> .</p>
)}
</CardContent>
</Card>
</div>
);
// config 동적 수정 (등록 모드일 때 타이틀 변경)
const dynamicConfig = {
...vendorConfig,
title: isNewMode ? '거래처 등록' : '거래처 상세',
description: isNewMode ? '새로운 거래처를 등록합니다' : '거래처 상세 정보 및 신용등급을 관리합니다',
actions: {
...vendorConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<PageLayout>
<PageHeader
title="거래처 상세"
description="거래처 상세 정보 및 신용등급을 관리합니다"
icon={Building2}
actions={headerActions}
onBack={handleBack}
/>
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(validationErrors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('사업자등록번호', 'businessNumber', formData.businessNumber, { required: true, placeholder: '000-00-00000' })}
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성' })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
{renderField('대표자명', 'representativeName', formData.representativeName)}
{renderSelectField('거래처 유형', 'category', formData.category, VENDOR_CATEGORY_SELECTOR_OPTIONS, true)}
{renderField('업태', 'businessType', formData.businessType)}
{renderField('업종', 'businessCategory', formData.businessCategory)}
</CardContent>
</Card>
{/* 연락처 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 주소 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex gap-2">
<Input
value={formData.zipCode}
onChange={(e) => handleChange('zipCode', e.target.value)}
placeholder="우편번호"
disabled={isViewMode}
className="w-[120px] bg-white"
/>
<Button variant="outline" disabled={isViewMode} onClick={openPostcode} className="shrink-0">
</Button>
</div>
<Input
value={formData.address1}
onChange={(e) => handleChange('address1', e.target.value)}
placeholder="기본주소"
disabled={isViewMode}
className="bg-white"
/>
<Input
value={formData.address2}
onChange={(e) => handleChange('address2', e.target.value)}
placeholder="상세주소"
disabled={isViewMode}
className="bg-white"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('전화번호', 'phone', formData.phone, { type: 'tel', placeholder: '02-0000-0000' })}
{renderField('모바일', 'mobile', formData.mobile, { type: 'tel', placeholder: '010-0000-0000' })}
{renderField('팩스', 'fax', formData.fax, { type: 'tel', placeholder: '02-0000-0000' })}
{renderField('이메일', 'email', formData.email, { type: 'email' })}
</div>
</CardContent>
</Card>
{/* 담당자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('담당자명', 'managerName', formData.managerName)}
{renderField('담당자 전화', 'managerPhone', formData.managerPhone, { type: 'tel' })}
{renderField('시스템 관리자', 'systemManager', formData.systemManager)}
</CardContent>
</Card>
{/* 회사 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 회사 로고 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<p className="text-sm text-gray-500">750 X 250px, 10MB PNG, JPEG, GIF</p>
{!isViewMode && (
<Button variant="outline" className="mt-2">
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('매입 결제일', 'purchasePaymentDay', String(formData.purchasePaymentDay), PAYMENT_DAY_OPTIONS)}
{renderSelectField('매출 결제일', 'salesPaymentDay', String(formData.salesPaymentDay), PAYMENT_DAY_OPTIONS)}
</div>
</CardContent>
</Card>
{/* 신용/거래 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg">/ </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('신용등급', 'creditRating', formData.creditRating, CREDIT_RATING_SELECTOR_OPTIONS)}
{renderSelectField('거래등급', 'transactionGrade', formData.transactionGrade, TRANSACTION_GRADE_SELECTOR_OPTIONS)}
{renderField('세금계산서 이메일', 'taxInvoiceEmail', formData.taxInvoiceEmail, { type: 'email' })}
{renderSelectField('입금계좌 은행', 'bankName', formData.bankName, BANK_OPTIONS)}
{renderField('계좌', 'accountNumber', formData.accountNumber, { placeholder: '계좌번호' })}
{renderField('예금주', 'accountHolder', formData.accountHolder)}
</CardContent>
</Card>
{/* 추가 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 미수금 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
type="number"
value={formData.outstandingAmount}
onChange={(e) => handleChange('outstandingAmount', Number(e.target.value))}
disabled={isViewMode}
className="bg-white"
/>
</div>
{/* 연체 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700"></Label>
<Switch
checked={formData.overdueToggle}
onCheckedChange={(checked) => handleChange('overdueToggle', checked)}
disabled={isViewMode}
className="data-[state=checked]:bg-orange-500"
/>
</div>
<Input
type="number"
value={formData.overdueDays}
onChange={(e) => handleChange('overdueDays', Number(e.target.value))}
disabled={isViewMode}
className="bg-white"
placeholder="일"
/>
</div>
{/* 미지급 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
type="number"
value={formData.unpaidAmount}
onChange={(e) => handleChange('unpaidAmount', Number(e.target.value))}
disabled={isViewMode}
className="bg-white"
/>
</div>
{/* 악성채권 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-gray-700"></Label>
<Switch
checked={formData.badDebtToggle}
onCheckedChange={(checked) => handleChange('badDebtToggle', checked)}
disabled={isViewMode}
className="data-[state=checked]:bg-orange-500"
/>
</div>
<Select
value={formData.badDebtStatus}
onValueChange={(val) => handleChange('badDebtStatus', val)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="-" />
</SelectTrigger>
<SelectContent>
{BAD_DEBT_STATUS_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 메모 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 메모 입력 */}
{!isViewMode && (
<div className="flex gap-2">
<Textarea
value={newMemo}
onChange={(e) => setNewMemo(e.target.value)}
placeholder="메모를 입력하세요..."
className="flex-1 bg-white"
rows={2}
/>
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
)}
{/* 메모 리스트 */}
{formData.memos.length > 0 ? (
<div className="space-y-2">
{formData.memos.map((memo) => (
<div
key={memo.id}
className="flex items-start justify-between p-3 bg-gray-50 rounded-lg"
>
<p className="text-sm text-gray-700 flex-1">{memo.content}</p>
{!isViewMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={() => handleDeleteMemo(memo.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4"> .</p>
)}
</CardContent>
</Card>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&apos;{formData.vendorName}&apos;() ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSaving}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isSaving}
>
{isSaving ? '삭제중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isNewMode ? '거래처 등록' : '수정 확인'}
</AlertDialogTitle>
<AlertDialogDescription>
{isNewMode
? '거래처를 등록하시겠습니까?'
: '정말 수정하시겠습니까?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSaving}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-blue-500 hover:bg-blue-600"
disabled={isSaving}
>
{isSaving ? '처리중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={formData as unknown as Record<string, unknown>}
itemId={vendorId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={vendorId ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, Plus, X, Loader2, List } from 'lucide-react';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -15,19 +15,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { vendorConfig } from './vendorConfig';
import type { Vendor, VendorMemo } from './types';
import {
VENDOR_CATEGORY_SELECTOR_OPTIONS,
@@ -112,16 +101,12 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// IntegratedDetailTemplate 모드 변환
const templateMode = isNewMode ? 'create' : mode;
// 폼 데이터
const [formData, setFormData] = useState(initialData || getEmptyVendor());
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// 새 메모 입력
const [newMemo, setNewMemo] = useState('');
@@ -140,101 +125,6 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 네비게이션 핸들러
const handleBack = useCallback(() => {
router.push('/ko/accounting/vendors');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/vendors/${vendorId}?mode=edit`);
}, [router, vendorId]);
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/vendors');
} else {
router.push(`/ko/accounting/vendors/${vendorId}`);
}
}, [router, vendorId, isNewMode]);
// 저장 핸들러
const handleSave = useCallback(() => {
// 필수 필드 검증
if (!formData.vendorName.trim()) {
toast.error('거래처명을 입력해주세요.');
return;
}
setShowSaveDialog(true);
}, [formData.vendorName]);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
const apiData = transformFrontendToApi(formData);
const url = isNewMode
? '/api/proxy/clients'
: `/api/proxy/clients/${vendorId}`;
const method = isNewMode ? 'POST' : 'PUT';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiData),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.message || '저장에 실패했습니다.');
}
toast.success(isNewMode ? '거래처가 등록되었습니다.' : '수정이 완료되었습니다.');
setShowSaveDialog(false);
if (isNewMode) {
router.push('/ko/accounting/vendors');
} else {
router.push(`/ko/accounting/vendors/${vendorId}`);
}
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [formData, isNewMode, vendorId, router]);
// 삭제 핸들러
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/proxy/clients/${vendorId}`, {
method: 'DELETE',
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.message || '삭제에 실패했습니다.');
}
toast.success('거래처가 삭제되었습니다.');
setShowDeleteDialog(false);
router.push('/ko/accounting/vendors');
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [vendorId, router]);
// 메모 추가 핸들러
const handleAddMemo = useCallback(() => {
if (!newMemo.trim()) return;
@@ -261,36 +151,60 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
}));
}, []);
// 헤더 버튼
const headerActions = useMemo(() => {
if (isViewMode) {
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete}>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</div>
);
// 저장 핸들러 (IntegratedDetailTemplate용)
const handleSubmit = useCallback(async () => {
// 필수 필드 검증
if (!formData.vendorName.trim()) {
return { success: false, error: '거래처명을 입력해주세요.' };
}
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isNewMode ? '등록' : '저장'}
</Button>
</div>
);
}, [isViewMode, isNewMode, isLoading, handleBack, handleDelete, handleEdit, handleCancel, handleSave]);
try {
const apiData = transformFrontendToApi(formData);
const url = isNewMode
? '/api/proxy/clients'
: `/api/proxy/clients/${vendorId}`;
const method = isNewMode ? 'POST' : 'PUT';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiData),
});
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '저장에 실패했습니다.' };
}
router.refresh();
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : '저장에 실패했습니다.' };
}
}, [formData, isNewMode, vendorId, router]);
// 삭제 핸들러 (IntegratedDetailTemplate용)
const handleDelete = useCallback(async () => {
try {
const response = await fetch(`/api/proxy/clients/${vendorId}`, {
method: 'DELETE',
});
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '삭제에 실패했습니다.' };
}
router.refresh();
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : '삭제에 실패했습니다.' };
}
}, [vendorId, router]);
// 입력 필드 렌더링 헬퍼
const renderField = (
@@ -355,286 +269,249 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
);
};
return (
<PageLayout>
<PageHeader
title={isNewMode ? '거래처 등록' : '거래처 상세'}
description={isNewMode ? '새로운 거래처를 등록합니다' : '거래처 상세 정보 및 신용등급을 관리합니다'}
icon={Building2}
actions={headerActions}
onBack={handleBack}
/>
// 폼 콘텐츠 렌더링 (View/Edit 공통)
const renderFormContent = () => (
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('사업자등록번호', 'businessNumber', formData.businessNumber, { required: true, placeholder: '000-00-00000' })}
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
{renderField('대표자명', 'representativeName', formData.representativeName)}
{renderSelectField('거래처 유형', 'category', formData.category, VENDOR_CATEGORY_SELECTOR_OPTIONS, true)}
{renderField('업태', 'businessType', formData.businessType)}
{renderField('업종', 'businessCategory', formData.businessCategory)}
</CardContent>
</Card>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('사업자등록번호', 'businessNumber', formData.businessNumber, { required: true, placeholder: '000-00-00000' })}
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
{renderField('대표자명', 'representativeName', formData.representativeName)}
{renderSelectField('거래처 유형', 'category', formData.category, VENDOR_CATEGORY_SELECTOR_OPTIONS, true)}
{renderField('업태', 'businessType', formData.businessType)}
{renderField('업종', 'businessCategory', formData.businessCategory)}
</CardContent>
</Card>
{/* 연락처 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 주소 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex gap-2">
<Input
value={formData.zipCode}
onChange={(e) => handleChange('zipCode', e.target.value)}
placeholder="우편번호"
disabled={isViewMode}
className="w-[120px] bg-white"
/>
<Button variant="outline" disabled={isViewMode} className="shrink-0">
</Button>
</div>
{/* 연락처 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 주소 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex gap-2">
<Input
value={formData.address1}
onChange={(e) => handleChange('address1', e.target.value)}
placeholder="기본주소"
value={formData.zipCode}
onChange={(e) => handleChange('zipCode', e.target.value)}
placeholder="우편번호"
disabled={isViewMode}
className="bg-white"
/>
<Input
value={formData.address2}
onChange={(e) => handleChange('address2', e.target.value)}
placeholder="상세주소"
disabled={isViewMode}
className="bg-white"
className="w-[120px] bg-white"
/>
<Button variant="outline" disabled={isViewMode} className="shrink-0">
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('전화번호', 'phone', formData.phone, { type: 'tel', placeholder: '02-0000-0000' })}
{renderField('모바일', 'mobile', formData.mobile, { type: 'tel', placeholder: '010-0000-0000' })}
{renderField('팩스', 'fax', formData.fax, { type: 'tel', placeholder: '02-0000-0000' })}
{renderField('이메일', 'email', formData.email, { type: 'email' })}
</div>
</CardContent>
</Card>
<Input
value={formData.address1}
onChange={(e) => handleChange('address1', e.target.value)}
placeholder="기본주소"
disabled={isViewMode}
className="bg-white"
/>
<Input
value={formData.address2}
onChange={(e) => handleChange('address2', e.target.value)}
placeholder="상세주소"
disabled={isViewMode}
className="bg-white"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('전화번호', 'phone', formData.phone, { type: 'tel', placeholder: '02-0000-0000' })}
{renderField('모바일', 'mobile', formData.mobile, { type: 'tel', placeholder: '010-0000-0000' })}
{renderField('팩스', 'fax', formData.fax, { type: 'tel', placeholder: '02-0000-0000' })}
{renderField('이메일', 'email', formData.email, { type: 'email' })}
</div>
</CardContent>
</Card>
{/* 담당자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('담당자명', 'managerName', formData.managerName)}
{renderField('담당자 전화', 'managerPhone', formData.managerPhone, { type: 'tel' })}
{renderField('시스템 관리자', 'systemManager', formData.systemManager)}
</CardContent>
</Card>
{/* 담당자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderField('담당자명', 'managerName', formData.managerName)}
{renderField('담당자 전화', 'managerPhone', formData.managerPhone, { type: 'tel' })}
{renderField('시스템 관리자', 'systemManager', formData.systemManager)}
</CardContent>
</Card>
{/* 회사 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 회사 로고 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
{formData.logoUrl ? (
<img
src={formData.logoUrl}
alt="회사 로고"
className="max-h-[100px] max-w-[300px] object-contain mx-auto"
/>
) : (
<>
<p className="text-sm text-gray-500">750 X 250px, 10MB PNG, JPEG, GIF</p>
{!isViewMode && (
<Button variant="outline" className="mt-2">
</Button>
)}
</>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('매입 결제일', 'purchasePaymentDay', String(formData.purchasePaymentDay), PAYMENT_DAY_OPTIONS)}
{renderSelectField('매출 결제일', 'salesPaymentDay', String(formData.salesPaymentDay), PAYMENT_DAY_OPTIONS)}
</div>
</CardContent>
</Card>
{/* 신용/거래 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg">/ </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('신용등급', 'creditRating', formData.creditRating, CREDIT_RATING_SELECTOR_OPTIONS)}
{renderSelectField('거래등급', 'transactionGrade', formData.transactionGrade, TRANSACTION_GRADE_SELECTOR_OPTIONS)}
{renderField('세금계산서 이메일', 'taxInvoiceEmail', formData.taxInvoiceEmail, { type: 'email' })}
{renderSelectField('입금계좌 은행', 'bankName', formData.bankName, BANK_OPTIONS)}
{renderField('계좌', 'accountNumber', formData.accountNumber, { placeholder: '계좌번호' })}
{renderField('예금주', 'accountHolder', formData.accountHolder)}
</CardContent>
</Card>
{/* 추가 정보 - 보기 모드에서만 표시 (계산된 값) */}
{!isNewMode && (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 미수금 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
type="text"
value={formData.outstandingAmount?.toLocaleString() + '원'}
disabled
className="bg-gray-50"
/>
</div>
{/* 악성채권 금액 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input
type="text"
value={formData.badDebtAmount ? formData.badDebtAmount.toLocaleString() + '원' : '-'}
disabled
className="bg-gray-50"
/>
</div>
{/* 악성채권 상태 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className={`px-3 py-2 rounded-md text-sm ${
formData.badDebtToggle
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-500'
}`}>
{formData.badDebtToggle ? '악성채권' : '정상'}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* 메모 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 메모 입력 */}
{!isViewMode && (
<div className="flex gap-2">
<Textarea
value={newMemo}
onChange={(e) => setNewMemo(e.target.value)}
placeholder="메모를 입력하세요..."
className="flex-1 bg-white"
rows={2}
{/* 회사 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 회사 로고 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
{formData.logoUrl ? (
<img
src={formData.logoUrl}
alt="회사 로고"
className="max-h-[100px] max-w-[300px] object-contain mx-auto"
/>
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
)}
{/* 메모 리스트 */}
{formData.memos.length > 0 ? (
) : (
<>
<p className="text-sm text-gray-500">750 X 250px, 10MB PNG, JPEG, GIF</p>
{!isViewMode && (
<Button variant="outline" className="mt-2">
</Button>
)}
</>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('매입 결제일', 'purchasePaymentDay', String(formData.purchasePaymentDay), PAYMENT_DAY_OPTIONS)}
{renderSelectField('매출 결제일', 'salesPaymentDay', String(formData.salesPaymentDay), PAYMENT_DAY_OPTIONS)}
</div>
</CardContent>
</Card>
{/* 신용/거래 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg">/ </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('신용등급', 'creditRating', formData.creditRating, CREDIT_RATING_SELECTOR_OPTIONS)}
{renderSelectField('거래등급', 'transactionGrade', formData.transactionGrade, TRANSACTION_GRADE_SELECTOR_OPTIONS)}
{renderField('세금계산서 이메일', 'taxInvoiceEmail', formData.taxInvoiceEmail, { type: 'email' })}
{renderSelectField('입금계좌 은행', 'bankName', formData.bankName, BANK_OPTIONS)}
{renderField('계좌', 'accountNumber', formData.accountNumber, { placeholder: '계좌번호' })}
{renderField('예금주', 'accountHolder', formData.accountHolder)}
</CardContent>
</Card>
{/* 추가 정보 - 보기 모드에서만 표시 (계산된 값) */}
{!isNewMode && (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 미수금 */}
<div className="space-y-2">
{formData.memos.map((memo) => (
<div
key={memo.id}
className="flex items-start justify-between p-3 bg-gray-50 rounded-lg"
>
<p className="text-sm text-gray-700 flex-1">{memo.content}</p>
{!isViewMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={() => handleDeleteMemo(memo.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
type="text"
value={formData.outstandingAmount?.toLocaleString() + '원'}
disabled
className="bg-gray-50"
/>
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4"> .</p>
)}
{/* 악성채권 금액 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input
type="text"
value={formData.badDebtAmount ? formData.badDebtAmount.toLocaleString() + '원' : '-'}
disabled
className="bg-gray-50"
/>
</div>
{/* 악성채권 상태 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className={`px-3 py-2 rounded-md text-sm ${
formData.badDebtToggle
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-500'
}`}>
{formData.badDebtToggle ? '악성채권' : '정상'}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&apos;{formData.vendorName}&apos;() ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isNewMode ? '거래처 등록' : '수정 확인'}
</AlertDialogTitle>
<AlertDialogDescription>
{isNewMode
? '거래처를 등록하시겠습니까?'
: '정말 수정하시겠습니까? 확인 클릭 시 "수정이 완료되었습니다." 알림이 표시됩니다.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
{/* 메모 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 메모 입력 */}
{!isViewMode && (
<div className="flex gap-2">
<Textarea
value={newMemo}
onChange={(e) => setNewMemo(e.target.value)}
placeholder="메모를 입력하세요..."
className="flex-1 bg-white"
rows={2}
/>
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
)}
{/* 메모 리스트 */}
{formData.memos.length > 0 ? (
<div className="space-y-2">
{formData.memos.map((memo) => (
<div
key={memo.id}
className="flex items-start justify-between p-3 bg-gray-50 rounded-lg"
>
<p className="text-sm text-gray-700 flex-1">{memo.content}</p>
{!isViewMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={() => handleDeleteMemo(memo.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4"> .</p>
)}
</CardContent>
</Card>
</div>
);
}
// config 동적 수정 (등록 모드일 때 타이틀 변경)
const dynamicConfig = {
...vendorConfig,
title: isNewMode ? '거래처 등록' : '거래처',
description: isNewMode ? '새로운 거래처를 등록합니다' : '거래처 상세 정보 및 신용등급을 관리합니다',
actions: {
...vendorConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={initialData as Record<string, unknown>}
itemId={vendorId}
onSubmit={handleSubmit}
onDelete={vendorId ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -0,0 +1,32 @@
import { Building2 } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
/**
* 거래처 상세 페이지 Config
*
* 참고: 이 config는 타이틀/버튼 영역만 정의
* 폼 내용은 기존 VendorDetailClient의 renderView/renderForm에서 처리
* (메모 시스템, 우편번호, 이미지 업로드 등 특수 기능 유지)
*/
export const vendorConfig: DetailConfig = {
title: '거래처',
description: '거래처 상세 정보 및 신용등급을 관리합니다',
icon: Building2,
basePath: '/accounting/vendors',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
gridColumns: 2,
actions: {
showBack: true,
showDelete: true,
showEdit: true,
backLabel: '목록',
deleteLabel: '삭제',
editLabel: '수정',
submitLabel: '저장',
cancelLabel: '취소',
deleteConfirmMessage: {
title: '거래처 삭제',
description: '이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
};