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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/components/accounting/BadDebtCollection/badDebtConfig.ts
Normal file
34
src/components/accounting/BadDebtCollection/badDebtConfig.ts
Normal 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: '등록',
|
||||
},
|
||||
};
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
32
src/components/accounting/BillManagement/billConfig.ts
Normal file
32
src/components/accounting/BillManagement/billConfig.ts
Normal 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: '이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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: '이 매입 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src/components/accounting/SalesManagement/salesConfig.ts
Normal file
32
src/components/accounting/SalesManagement/salesConfig.ts
Normal 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: '이 매출 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/components/accounting/VendorLedger/vendorLedgerConfig.ts
Normal file
29
src/components/accounting/VendorLedger/vendorLedgerConfig.ts
Normal 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: '목록',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
'{formData.vendorName}'을(를) 삭제하시겠습니까?
|
||||
<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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
'{formData.vendorName}'을(를) 삭제하시겠습니까?
|
||||
<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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
32
src/components/accounting/VendorManagement/vendorConfig.ts
Normal file
32
src/components/accounting/VendorManagement/vendorConfig.ts
Normal 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: '이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user