feat: 신규 페이지 구현 및 HR/설정 기능 개선

신규 페이지:
- 회계관리: 거래처, 예상비용, 청구서, 발주서
- 게시판: 공지사항, 자료실, 커뮤니티
- 고객센터: 문의/FAQ
- 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역
- 리포트 (차트 시각화)
- 개발자 테스트 URL 페이지

기능 개선:
- HR 직원관리/휴가관리/카드관리 강화
- IntegratedListTemplateV2 확장
- AuthenticatedLayout 패딩 표준화
- 로그인 페이지 UI 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-19 19:12:34 +09:00
parent d742c0ce26
commit c6b605200d
213 changed files with 32644 additions and 775 deletions

View File

@@ -0,0 +1,944 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { AlertTriangle, 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';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
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 type {
BadDebtRecord,
BadDebtMemo,
Manager,
AttachedFile,
CollectionStatus,
} from './types';
import {
STATUS_SELECT_OPTIONS,
VENDOR_TYPE_LABELS,
} from './types';
interface BadDebtDetailProps {
mode: 'view' | 'edit' | 'new';
recordId?: string;
}
// Mock 담당자 목록
const MANAGER_OPTIONS: Manager[] = [
{ id: 'm1', departmentName: '경영지원팀', name: '홍길동', position: '과장', phone: '010-1234-1234' },
{ id: 'm2', departmentName: '재무팀', name: '김철수', position: '대리', phone: '010-2345-2345' },
{ id: 'm3', departmentName: '영업팀', name: '이영희', position: '차장', phone: '010-3456-3456' },
{ id: 'm4', departmentName: '관리팀', name: '박민수', position: '사원', phone: '010-4567-4567' },
];
// Mock 데이터 가져오기
const getMockRecord = (id: string): BadDebtRecord => ({
id,
vendorId: 'v1',
vendorCode: '1234',
vendorName: '회사명',
businessNumber: '123-12-12345',
representativeName: '대표자명',
vendorType: 'both',
businessType: '업태명',
businessCategory: '업종명',
zipCode: '',
address1: '123 서울특별시 서초구 서초대로 123',
address2: '대한건물 12층 1201호',
phone: '02-1234-1234',
mobile: '010-1234-1234',
fax: '02-1234-1235',
email: 'abc@email.com',
contactName: '담당자명',
contactPhone: '010-1234-1234',
systemManager: '관리자명',
debtAmount: 11000000,
status: 'collecting',
overdueDays: 100,
overdueToggle: true,
occurrenceDate: '2025-12-12',
endDate: null,
assignedManagerId: 'm1',
assignedManager: MANAGER_OPTIONS[0],
settingToggle: true,
files: [
{ id: 'f1', name: 'abc.pdf', url: '#', type: 'businessRegistration' },
{ id: 'f2', name: 'abc.pdf', url: '#', type: 'taxInvoice' },
],
memos: [
{
id: 'memo-1',
content: '2025-12-12 12:21 [홍길동] 메모 내용',
createdAt: '2025-12-12T12:21:00.000Z',
createdBy: '홍길동',
},
],
createdAt: '2025-12-01T00:00:00.000Z',
updatedAt: '2025-12-18T00:00:00.000Z',
});
const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'> => ({
vendorId: '',
vendorCode: '',
vendorName: '',
businessNumber: '',
representativeName: '',
vendorType: 'both',
businessType: '',
businessCategory: '',
zipCode: '',
address1: '',
address2: '',
phone: '',
mobile: '',
fax: '',
email: '',
contactName: '',
contactPhone: '',
systemManager: '',
debtAmount: 0,
status: 'collecting',
overdueDays: 0,
overdueToggle: false,
occurrenceDate: format(new Date(), 'yyyy-MM-dd'),
endDate: null,
assignedManagerId: null,
assignedManager: null,
settingToggle: true,
files: [],
memos: [],
});
export function BadDebtDetail({ mode, recordId }: BadDebtDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// 폼 데이터
const initialData = recordId ? getMockRecord(recordId) : getEmptyRecord();
const [formData, setFormData] = useState(initialData);
// 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// 새 메모 입력
const [newMemo, setNewMemo] = useState('');
// 파일 업로드 상태
const [newBusinessRegistrationFile, setNewBusinessRegistrationFile] = useState<File | null>(null);
const [newTaxInvoiceFile, setNewTaxInvoiceFile] = useState<File | null>(null);
const [newAdditionalFiles, setNewAdditionalFiles] = useState<File[]>([]);
// 필드 변경 핸들러
const handleChange = useCallback((field: string, value: string | number | boolean | null) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 네비게이션 핸들러
const handleBack = useCallback(() => {
router.push('/ko/accounting/bad-debt-collection');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/bad-debt-collection/${recordId}/edit`);
}, [router, recordId]);
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/bad-debt-collection');
} else {
router.push(`/ko/accounting/bad-debt-collection/${recordId}`);
}
}, [router, recordId, isNewMode]);
// 저장 핸들러
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(() => {
console.log('저장:', formData);
setShowSaveDialog(false);
if (isNewMode) {
router.push('/ko/accounting/bad-debt-collection');
} else {
router.push(`/ko/accounting/bad-debt-collection/${recordId}`);
}
}, [formData, router, recordId, isNewMode]);
// 삭제 핸들러
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(() => {
console.log('삭제:', recordId);
setShowDeleteDialog(false);
router.push('/ko/accounting/bad-debt-collection');
}, [router, recordId]);
// 메모 추가 핸들러
const handleAddMemo = useCallback(() => {
if (!newMemo.trim()) return;
const now = new Date();
const dateStr = format(now, 'yyyy-MM-dd');
const timeStr = format(now, 'HH:mm');
const memo: BadDebtMemo = {
id: String(Date.now()),
content: `${dateStr} ${timeStr} [사용자] ${newMemo}`,
createdAt: now.toISOString(),
createdBy: '사용자',
};
setFormData(prev => ({
...prev,
memos: [...prev.memos, memo],
}));
setNewMemo('');
}, [newMemo]);
// 메모 삭제 핸들러
const handleDeleteMemo = useCallback((memoId: string) => {
setFormData(prev => ({
...prev,
memos: prev.memos.filter(m => m.id !== memoId),
}));
}, []);
// 담당자 변경 핸들러
const handleManagerChange = useCallback((managerId: string) => {
const manager = MANAGER_OPTIONS.find(m => m.id === managerId) || null;
setFormData(prev => ({
...prev,
assignedManagerId: managerId,
assignedManager: manager,
}));
}, []);
// 수취 어음 현황 버튼
const handleBillStatus = useCallback(() => {
router.push(`/ko/accounting/bills?vendorId=${formData.vendorId}&type=received`);
}, [router, formData.vendorId]);
// 거래처 미수금 현황 버튼
const handleReceivablesStatus = useCallback(() => {
router.push(`/ko/accounting/receivables-status?highlight=${formData.vendorId}`);
}, [router, formData.vendorId]);
// 파일 다운로드 핸들러
const handleFileDownload = useCallback((fileName: string) => {
console.log('파일 다운로드:', fileName);
// TODO: 실제 다운로드 로직
}, []);
// 기존 파일 삭제 핸들러
const handleDeleteExistingFile = useCallback((fileId: string) => {
setFormData(prev => ({
...prev,
files: prev.files.filter(f => f.id !== fileId),
}));
}, []);
// 추가 서류 추가 핸들러
const handleAddAdditionalFile = useCallback((file: File) => {
setNewAdditionalFiles(prev => [...prev, file]);
}, []);
// 추가 서류 (새 파일) 삭제 핸들러
const handleRemoveNewAdditionalFile = useCallback((index: number) => {
setNewAdditionalFiles(prev => prev.filter((_, i) => i !== index));
}, []);
// 헤더 버튼
const headerActions = 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}>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
</Button>
</div>
);
}
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600">
{isNewMode ? '등록' : '저장'}
</Button>
</div>
);
}, [isViewMode, isNewMode, handleDelete, handleEdit, handleCancel, handleSave]);
// 입력 필드 렌더링 헬퍼
const renderField = (
label: string,
field: string,
value: string | number,
options?: {
required?: boolean;
type?: 'text' | 'tel' | 'email' | 'number';
placeholder?: string;
disabled?: boolean;
}
) => {
const { required, type = 'text', placeholder, disabled } = options || {};
return (
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700">
{label} {required && <span className="text-red-500">*</span>}
</Label>
<Input
type={type}
value={value}
onChange={(e) => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
placeholder={placeholder}
disabled={isViewMode || disabled}
className="bg-white"
/>
</div>
);
};
return (
<PageLayout>
<PageHeader
title="악성채권 추심관리 상세"
description="추심 대상 업체 정보를 표시"
icon={AlertTriangle}
actions={headerActions}
onBack={handleBack}
/>
<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', disabled: true })}
{renderField('거래처 코드', 'vendorCode', formData.vendorCode, { disabled: true })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
{renderField('대표자명', 'representativeName', formData.representativeName)}
{/* 거래처 유형 */}
<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.vendorType === 'both'}
onCheckedChange={(checked) => handleChange('vendorType', checked ? 'both' : 'sales')}
disabled={isViewMode}
className="data-[state=checked]:bg-orange-500"
/>
</div>
<Input
value={VENDOR_TYPE_LABELS[formData.vendorType] || '매출매입'}
disabled
className="bg-gray-50"
/>
</div>
{/* 악성채권 등록 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className="grid grid-cols-2 gap-2">
<Input
value={formData.businessType}
onChange={(e) => handleChange('businessType', e.target.value)}
placeholder="업태"
disabled={isViewMode}
className="bg-white"
/>
<Input
value={formData.businessCategory}
onChange={(e) => handleChange('businessCategory', e.target.value)}
placeholder="업종"
disabled={isViewMode}
className="bg-white"
/>
</div>
</div>
</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>
<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('담당자명', 'contactName', formData.contactName)}
{renderField('담당자 전화', 'contactPhone', formData.contactPhone, { type: 'tel' })}
{renderField('시스템 관리자', 'systemManager', formData.systemManager, { disabled: true })}
</CardContent>
</Card>
{/* 필요 서류 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 사업자등록증 */}
<div>
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-1.5">
{/* 기존 파일 있는 경우 */}
{formData.files.find(f => f.type === 'businessRegistration') && !newBusinessRegistrationFile ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
<span className="truncate">{formData.files.find(f => f.type === 'businessRegistration')?.name}</span>
</div>
<button
type="button"
onClick={() => handleFileDownload(formData.files.find(f => f.type === 'businessRegistration')?.name || '')}
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-blue-600"
title="다운로드"
>
<Download className="h-4 w-4" />
</button>
{!isViewMode && (
<>
<label
htmlFor="business_registration_file"
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600 cursor-pointer"
title="수정"
>
<Upload className="h-4 w-4" />
<input
id="business_registration_file"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={(e) => setNewBusinessRegistrationFile(e.target.files?.[0] || null)}
className="hidden"
/>
</label>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleDeleteExistingFile(formData.files.find(f => f.type === 'businessRegistration')?.id || '')}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
) : newBusinessRegistrationFile ? (
/* 새 파일 선택된 경우 */
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-blue-50 rounded-md border border-blue-200 text-sm">
<FileText className="h-4 w-4 text-blue-600 shrink-0" />
<span className="truncate">{newBusinessRegistrationFile.name}</span>
<span className="text-xs text-blue-500">( )</span>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setNewBusinessRegistrationFile(null)}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="취소"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
/* 파일 없는 경우 */
<div className="flex items-center gap-2">
<label
htmlFor="business_registration_file_new"
className={`flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm ${isViewMode ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-gray-100'} transition-colors`}
>
<Upload className="h-4 w-4 text-gray-500 shrink-0" />
<span className="text-gray-500"> </span>
</label>
{!isViewMode && (
<input
id="business_registration_file_new"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={(e) => setNewBusinessRegistrationFile(e.target.files?.[0] || null)}
className="hidden"
/>
)}
</div>
)}
</div>
</div>
{/* 세금계산서 */}
<div>
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-1.5">
{/* 기존 파일 있는 경우 */}
{formData.files.find(f => f.type === 'taxInvoice') && !newTaxInvoiceFile ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
<FileText className="h-4 w-4 text-green-600 shrink-0" />
<span className="truncate">{formData.files.find(f => f.type === 'taxInvoice')?.name}</span>
</div>
<button
type="button"
onClick={() => handleFileDownload(formData.files.find(f => f.type === 'taxInvoice')?.name || '')}
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-green-600"
title="다운로드"
>
<Download className="h-4 w-4" />
</button>
{!isViewMode && (
<>
<label
htmlFor="tax_invoice_file"
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600 cursor-pointer"
title="수정"
>
<Upload className="h-4 w-4" />
<input
id="tax_invoice_file"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={(e) => setNewTaxInvoiceFile(e.target.files?.[0] || null)}
className="hidden"
/>
</label>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleDeleteExistingFile(formData.files.find(f => f.type === 'taxInvoice')?.id || '')}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
) : newTaxInvoiceFile ? (
/* 새 파일 선택된 경우 */
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-green-50 rounded-md border border-green-200 text-sm">
<FileText className="h-4 w-4 text-green-600 shrink-0" />
<span className="truncate">{newTaxInvoiceFile.name}</span>
<span className="text-xs text-green-500">( )</span>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setNewTaxInvoiceFile(null)}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="취소"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
/* 파일 없는 경우 */
<div className="flex items-center gap-2">
<label
htmlFor="tax_invoice_file_new"
className={`flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm ${isViewMode ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-gray-100'} transition-colors`}
>
<Upload className="h-4 w-4 text-gray-500 shrink-0" />
<span className="text-gray-500"> </span>
</label>
{!isViewMode && (
<input
id="tax_invoice_file_new"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={(e) => setNewTaxInvoiceFile(e.target.files?.[0] || null)}
className="hidden"
/>
)}
</div>
)}
</div>
</div>
{/* 추가 서류 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<Label className="text-sm font-medium text-gray-700"> </Label>
{!isViewMode && (
<label
htmlFor="additional_file"
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-orange-600 border border-orange-200 rounded-md cursor-pointer hover:bg-orange-50 transition-colors"
>
<Plus className="h-4 w-4" />
<input
id="additional_file"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleAddAdditionalFile(file);
e.target.value = '';
}
}}
className="hidden"
/>
</label>
)}
</div>
<div className="space-y-2">
{/* 기존 추가 서류 */}
{formData.files.filter(f => f.type === 'additional').map((file) => (
<div key={file.id} className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-gray-50 rounded-md border text-sm">
<FileText className="h-4 w-4 text-gray-600 shrink-0" />
<span className="truncate">{file.name}</span>
</div>
<button
type="button"
onClick={() => handleFileDownload(file.name)}
className="inline-flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground text-gray-600"
title="다운로드"
>
<Download className="h-4 w-4" />
</button>
{!isViewMode && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleDeleteExistingFile(file.id)}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
{/* 새로 추가된 파일 */}
{newAdditionalFiles.map((file, index) => (
<div key={`new-${index}`} className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 px-3 py-2 bg-orange-50 rounded-md border border-orange-200 text-sm">
<FileText className="h-4 w-4 text-orange-600 shrink-0" />
<span className="truncate">{file.name}</span>
<span className="text-xs text-orange-500">( )</span>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleRemoveNewAdditionalFile(index)}
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
title="취소"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{/* 파일 없는 경우 안내 */}
{formData.files.filter(f => f.type === 'additional').length === 0 && newAdditionalFiles.length === 0 && (
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-md border text-sm text-gray-400">
<FileText className="h-4 w-4 shrink-0" />
<span> </span>
</div>
)}
</div>
</div>
</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.debtAmount}
onChange={(e) => handleChange('debtAmount', Number(e.target.value))}
disabled={isViewMode}
className="bg-white"
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Select
value={formData.status}
onValueChange={(val) => handleChange('status', val as CollectionStatus)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{STATUS_SELECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 연체일수 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex items-center gap-2">
<Input
type="number"
value={formData.overdueDays}
onChange={(e) => handleChange('overdueDays', Number(e.target.value))}
disabled={isViewMode}
className="bg-white w-[100px]"
/>
<span className="text-sm text-gray-500"></span>
</div>
</div>
{/* 본사 담당자 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Select
value={formData.assignedManagerId || ''}
onValueChange={handleManagerChange}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="담당자 선택">
{formData.assignedManager && (
<span>{formData.assignedManager.departmentName} {formData.assignedManager.name} {formData.assignedManager.position} {formData.assignedManager.phone}</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{MANAGER_OPTIONS.map((manager) => (
<SelectItem key={manager.id} value={manager.id}>
{manager.departmentName} {manager.name} {manager.position} {manager.phone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 악성채권 발생일 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input
type="date"
value={formData.occurrenceDate}
onChange={(e) => handleChange('occurrenceDate', e.target.value)}
disabled={isViewMode}
className="bg-white"
/>
</div>
{/* 악성채권 종료일 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input
type="date"
value={formData.endDate || ''}
onChange={(e) => handleChange('endDate', e.target.value || null)}
disabled={isViewMode}
className="bg-white"
placeholder="-"
/>
</div>
</div>
{/* 연동 버튼 */}
<div className="flex gap-2 pt-4">
<Button
variant="outline"
onClick={handleBillStatus}
className="border-red-200 text-red-600 hover:bg-red-50"
>
<Receipt className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
onClick={handleReceivablesStatus}
className="border-red-200 text-red-600 hover:bg-red-50"
>
<CreditCard className="h-4 w-4 mr-2" />
</Button>
</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-orange-500 hover:bg-orange-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
)}
{/* 메모 리스트 */}
{formData.memos.length > 0 ? (
<div className="space-y-2">
{formData.memos.map((memo) => (
<div
key={memo.id}
className="flex items-start justify-between p-3 bg-gray-50 rounded-lg"
>
<p className="text-sm text-gray-700 flex-1">{memo.content}</p>
{!isViewMode && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={() => handleDeleteMemo(memo.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4"> .</p>
)}
</CardContent>
</Card>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&apos;{formData.vendorName}&apos; ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-orange-500 hover:bg-orange-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,495 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import {
AlertTriangle,
Pencil,
Trash2,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
type StatCard,
} from '@/components/templates/IntegratedListTemplateV2';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import type {
BadDebtRecord,
CollectionStatus,
SortOption,
} from './types';
import {
COLLECTION_STATUS_LABELS,
STATUS_FILTER_OPTIONS,
STATUS_BADGE_STYLES,
SORT_OPTIONS,
} from './types';
// ===== Mock 데이터 생성 =====
const generateMockData = (): BadDebtRecord[] => {
const statuses: CollectionStatus[] = ['collecting', 'legalAction', 'recovered', 'badDebt'];
const vendors = [
{ id: 'v1', code: 'V0001', name: '(주)삼성전자', bizNum: '123-45-67890' },
{ id: 'v2', code: 'V0002', name: '현대자동차', bizNum: '234-56-78901' },
{ id: 'v3', code: 'V0003', name: 'LG전자', bizNum: '345-67-89012' },
{ id: 'v4', code: 'V0004', name: 'SK하이닉스', bizNum: '456-78-90123' },
{ id: 'v5', code: 'V0005', name: '네이버', bizNum: '567-89-01234' },
{ id: 'v6', code: 'V0006', name: '카카오', bizNum: '678-90-12345' },
{ id: 'v7', code: 'V0007', name: '쿠팡', bizNum: '789-01-23456' },
];
const managers = ['홍길동', '김철수', '이영희', '박민수', '최지현'];
const amounts = [10000000, 25000000, 5000000, 30000000, 15000000, 8000000, 40000000];
return Array.from({ length: 20 }, (_, i) => {
const vendor = vendors[i % vendors.length];
const occurrenceDate = new Date(2025, 10 - (i % 3), 1 + (i * 2) % 28);
const overdueDays = Math.floor((new Date().getTime() - occurrenceDate.getTime()) / (1000 * 60 * 60 * 24));
return {
id: `bad-debt-${i + 1}`,
vendorId: vendor.id,
vendorCode: vendor.code,
vendorName: vendor.name,
businessNumber: vendor.bizNum,
representativeName: `대표자${i + 1}`,
vendorType: 'both',
businessType: '제조업',
businessCategory: '전자제품',
zipCode: '06234',
address1: '서울특별시 서초구 서초대로 123',
address2: '대한건물 12층 1201호',
phone: '02-1234-1234',
mobile: '010-1234-1234',
fax: '02-1234-1235',
email: 'abc@email.com',
contactName: '담당자명',
contactPhone: '010-1234-1234',
systemManager: '관리자명',
debtAmount: amounts[i % amounts.length],
status: statuses[i % statuses.length],
overdueDays: overdueDays > 0 ? overdueDays : 100 + (i * 10),
overdueToggle: i % 2 === 0,
occurrenceDate: format(occurrenceDate, 'yyyy-MM-dd'),
endDate: statuses[i % statuses.length] === 'recovered' ? format(new Date(), 'yyyy-MM-dd') : null,
assignedManagerId: `manager-${i % 5}`,
assignedManager: {
id: `manager-${i % 5}`,
departmentName: '경영지원팀',
name: managers[i % managers.length],
position: '과장',
phone: '010-1234-1234',
},
settingToggle: true,
files: [],
memos: [
{
id: `memo-${i}-1`,
content: '2025-12-12 12:21 [홍길동] 메모 내용',
createdAt: '2025-12-12T12:21:00.000Z',
createdBy: '홍길동',
},
],
createdAt: '2025-12-01T00:00:00.000Z',
updatedAt: '2025-12-18T00:00:00.000Z',
};
});
};
// 거래처 목록 추출 (필터용)
const getVendorOptions = (data: BadDebtRecord[]) => {
const vendorMap = new Map<string, string>();
data.forEach(item => {
vendorMap.set(item.vendorId, item.vendorName);
});
return [
{ value: 'all', label: '전체' },
...Array.from(vendorMap.entries()).map(([id, name]) => ({
value: id,
label: name,
})),
];
};
export function BadDebtCollection() {
const router = useRouter();
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [vendorFilter, setVendorFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// Mock 데이터
const [data, setData] = useState<BadDebtRecord[]>(generateMockData);
// 거래처 옵션
const vendorOptions = useMemo(() => getVendorOptions(data), [data]);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
return newSet;
});
}, []);
// ===== 필터링된 데이터 =====
const filteredData = useMemo(() => {
let result = data.filter(item =>
item.vendorName.includes(searchQuery) ||
item.vendorCode.includes(searchQuery) ||
item.businessNumber.includes(searchQuery)
);
// 거래처 필터
if (vendorFilter !== 'all') {
result = result.filter(item => item.vendorId === vendorFilter);
}
// 상태 필터
if (statusFilter !== 'all') {
result = result.filter(item => item.status === statusFilter);
}
// 정렬
switch (sortOption) {
case 'latest':
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
result.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
}
return result;
}, [data, searchQuery, vendorFilter, statusFilter, sortOption]);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredData.slice(startIndex, startIndex + itemsPerPage);
}, [filteredData, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
// ===== 전체 선택 핸들러 =====
const toggleSelectAll = useCallback(() => {
if (selectedItems.size === filteredData.length && filteredData.length > 0) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(filteredData.map(item => item.id)));
}
}, [selectedItems.size, filteredData]);
// ===== 액션 핸들러 =====
const handleRowClick = useCallback((item: BadDebtRecord) => {
router.push(`/ko/accounting/bad-debt-collection/${item.id}`);
}, [router]);
const handleEdit = useCallback((item: BadDebtRecord) => {
router.push(`/ko/accounting/bad-debt-collection/${item.id}/edit`);
}, [router]);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(() => {
if (deleteTargetId) {
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
}, [deleteTargetId]);
// 설정 토글 핸들러
const handleSettingToggle = useCallback((id: string, checked: boolean) => {
setData(prev => prev.map(item =>
item.id === id ? { ...item, settingToggle: checked } : item
));
}, []);
// ===== 통계 카드 =====
const statCards: StatCard[] = useMemo(() => {
const totalAmount = data.reduce((sum, d) => sum + d.debtAmount, 0);
const collectingAmount = data.filter(d => d.status === 'collecting').reduce((sum, d) => sum + d.debtAmount, 0);
const legalActionAmount = data.filter(d => d.status === 'legalAction').reduce((sum, d) => sum + d.debtAmount, 0);
const recoveredAmount = data.filter(d => d.status === 'recovered').reduce((sum, d) => sum + d.debtAmount, 0);
return [
{ label: '총 악성채권', value: `${totalAmount.toLocaleString()}`, icon: AlertTriangle, iconColor: 'text-red-500' },
{ label: '추심중', value: `${collectingAmount.toLocaleString()}`, icon: AlertTriangle, iconColor: 'text-orange-500' },
{ label: '법적조치', value: `${legalActionAmount.toLocaleString()}`, icon: AlertTriangle, iconColor: 'text-red-600' },
{ label: '회수완료', value: `${recoveredAmount.toLocaleString()}`, icon: AlertTriangle, iconColor: 'text-green-500' },
];
}, [data]);
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
{ key: 'vendorName', label: '거래처' },
{ key: 'debtAmount', label: '채권금액', className: 'text-right w-[140px]' },
{ key: 'occurrenceDate', label: '발생일', className: 'text-center w-[110px]' },
{ key: 'overdueDays', label: '연체일수', className: 'text-center w-[100px]' },
{ key: 'managerName', label: '담당자', className: 'w-[100px]' },
{ key: 'status', label: '상태', className: 'text-center w-[100px]' },
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
{ key: 'actions', label: '작업', className: 'text-center w-[120px]' },
], []);
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: BadDebtRecord, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
</TableCell>
{/* No. */}
<TableCell className="text-center text-sm text-gray-500">{globalIndex}</TableCell>
{/* 거래처 */}
<TableCell className="font-medium">{item.vendorName}</TableCell>
{/* 채권금액 */}
<TableCell className="text-right font-medium text-red-600">
{item.debtAmount.toLocaleString()}
</TableCell>
{/* 발생일 */}
<TableCell className="text-center">{item.occurrenceDate}</TableCell>
{/* 연체일수 */}
<TableCell className="text-center">{item.overdueDays}</TableCell>
{/* 담당자 */}
<TableCell>{item.assignedManager?.name || '-'}</TableCell>
{/* 상태 */}
<TableCell className="text-center">
<Badge variant="outline" className={STATUS_BADGE_STYLES[item.status]}>
{COLLECTION_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
{/* 설정 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Switch
checked={item.settingToggle}
onCheckedChange={(checked) => handleSettingToggle(item.id, checked)}
/>
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDeleteClick(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection, handleRowClick, handleEdit, handleDeleteClick, handleSettingToggle]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: BadDebtRecord,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<ListMobileCard
id={item.id}
title={item.vendorName}
headerBadges={
<Badge variant="outline" className={STATUS_BADGE_STYLES[item.status]}>
{COLLECTION_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="채권금액" value={`${item.debtAmount.toLocaleString()}`} className="text-red-600" />
<InfoField label="연체일수" value={`${item.overdueDays}`} />
<InfoField label="발생일" value={item.occurrenceDate} />
<InfoField label="담당자" value={item.assignedManager?.name || '-'} />
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 w-full">
<Button variant="outline" className="flex-1" onClick={() => handleRowClick(item)}>
<Eye className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" className="flex-1" onClick={() => handleEdit(item)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handleDeleteClick(item.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
onClick={() => handleRowClick(item)}
/>
);
}, [handleRowClick, handleEdit, handleDeleteClick]);
// ===== 테이블 헤더 액션 (3개 필터) =====
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 거래처 필터 */}
<Select value={vendorFilter} onValueChange={setVendorFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{vendorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="악성채권 추심관리"
description="연체 및 악성채권 현황을 추적하고 관리합니다"
icon={AlertTriangle}
stats={statCards}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="거래처명, 거래처코드, 사업자번호 검색..."
tableHeaderActions={tableHeaderActions}
tableColumns={tableColumns}
data={paginatedData}
totalCount={filteredData.length}
allData={filteredData}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item: BadDebtRecord) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,121 @@
// ===== 악성채권 추심관리 타입 정의 =====
// 추심 상태
export type CollectionStatus = 'collecting' | 'legalAction' | 'recovered' | 'badDebt';
// 정렬 옵션
export type SortOption = 'latest' | 'oldest';
// 담당자 정보
export interface Manager {
id: string;
departmentName: string;
name: string;
position: string;
phone: string;
}
// 메모
export interface BadDebtMemo {
id: string;
content: string;
createdAt: string;
createdBy: string;
}
// 첨부 파일
export interface AttachedFile {
id: string;
name: string;
url: string;
type: 'businessRegistration' | 'taxInvoice' | 'additional';
}
// 악성채권 레코드
export interface BadDebtRecord {
id: string;
// 거래처 기본 정보
vendorId: string;
vendorCode: string;
vendorName: string;
businessNumber: string;
representativeName: string;
vendorType: 'sales' | 'purchase' | 'both';
businessType: string;
businessCategory: string;
// 연락처 정보
zipCode: string;
address1: string;
address2: string;
phone: string;
mobile: string;
fax: string;
email: string;
// 담당자 정보
contactName: string;
contactPhone: string;
systemManager: string;
// 악성채권 정보
debtAmount: number;
status: CollectionStatus;
overdueDays: number;
overdueToggle: boolean;
occurrenceDate: string;
endDate: string | null;
assignedManagerId: string | null;
assignedManager: Manager | null;
settingToggle: boolean;
// 첨부 파일
files: AttachedFile[];
// 메모
memos: BadDebtMemo[];
// 타임스탬프
createdAt: string;
updatedAt: string;
}
// ===== 상태 라벨 =====
export const COLLECTION_STATUS_LABELS: Record<CollectionStatus, string> = {
collecting: '추심중',
legalAction: '법적조치',
recovered: '회수완료',
badDebt: '대손처리',
};
// ===== 상태 필터 옵션 =====
export const STATUS_FILTER_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'collecting', label: '추심중' },
{ value: 'legalAction', label: '법적조치' },
{ value: 'recovered', label: '회수완료' },
{ value: 'badDebt', label: '대손처리' },
] as const;
// ===== 상태 셀렉트 옵션 (상세 페이지용, 전체 제외) =====
export const STATUS_SELECT_OPTIONS = [
{ value: 'collecting', label: '추심중' },
{ value: 'legalAction', label: '법적조치' },
{ value: 'recovered', label: '회수완료' },
{ value: 'badDebt', label: '대손처리' },
] as const;
// ===== 상태 Badge 스타일 =====
export const STATUS_BADGE_STYLES: Record<CollectionStatus, string> = {
collecting: 'border-orange-300 text-orange-600 bg-orange-50',
legalAction: 'border-red-300 text-red-600 bg-red-50',
recovered: 'border-green-300 text-green-600 bg-green-50',
badDebt: 'border-gray-300 text-gray-600 bg-gray-50',
};
// ===== 정렬 옵션 =====
export const SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
] as const;
// ===== 거래처 유형 라벨 =====
export const VENDOR_TYPE_LABELS: Record<string, string> = {
sales: '매출',
purchase: '매입',
both: '매출매입',
};