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:
944
src/components/accounting/BadDebtCollection/BadDebtDetail.tsx
Normal file
944
src/components/accounting/BadDebtCollection/BadDebtDetail.tsx
Normal 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>
|
||||
'{formData.vendorName}'의 악성채권 기록을 삭제하시겠습니까?
|
||||
<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>
|
||||
);
|
||||
}
|
||||
495
src/components/accounting/BadDebtCollection/index.tsx
Normal file
495
src/components/accounting/BadDebtCollection/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
src/components/accounting/BadDebtCollection/types.ts
Normal file
121
src/components/accounting/BadDebtCollection/types.ts
Normal 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: '매출매입',
|
||||
};
|
||||
Reference in New Issue
Block a user