Files
sam-react-prod/src/components/accounting/BadDebtCollection/index.tsx
hskwon 346fe4c426 feat: 악성채권 추심관리 API 연동
- actions.ts 신규 생성 (서버 액션)
- page.tsx 서버 컴포넌트로 전환
- index.tsx initialData props 패턴 적용
- Mock 데이터 제거, 실제 API 호출로 대체
2025-12-23 17:17:55 +09:00

470 lines
17 KiB
TypeScript

'use client';
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useRouter } from 'next/navigation';
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,
SortOption,
} from './types';
import {
COLLECTION_STATUS_LABELS,
STATUS_FILTER_OPTIONS,
STATUS_BADGE_STYLES,
SORT_OPTIONS,
} from './types';
import { deleteBadDebt, toggleBadDebt } from './actions';
// ===== Props 타입 정의 =====
interface BadDebtCollectionProps {
initialData: BadDebtRecord[];
initialSummary?: {
total_amount: number;
collecting_amount: number;
legal_action_amount: number;
recovered_amount: number;
bad_debt_amount: number;
} | null;
}
// 거래처 목록 추출 (필터용)
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({ initialData, initialSummary }: BadDebtCollectionProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// ===== 상태 관리 =====
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);
// 데이터 (서버에서 받은 초기 데이터 사용)
const [data, setData] = useState<BadDebtRecord[]>(initialData);
// 거래처 옵션
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) {
startTransition(async () => {
const result = await deleteBadDebt(deleteTargetId);
if (result.success) {
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
console.error('[BadDebtCollection] Delete failed:', result.error);
}
});
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
}, [deleteTargetId]);
// 설정 토글 핸들러 (API 호출)
const handleSettingToggle = useCallback((id: string, checked: boolean) => {
// Optimistic update
setData(prev => prev.map(item =>
item.id === id ? { ...item, settingToggle: checked } : item
));
startTransition(async () => {
const result = await toggleBadDebt(id);
if (!result.success) {
// Rollback on error
setData(prev => prev.map(item =>
item.id === id ? { ...item, settingToggle: !checked } : item
));
console.error('[BadDebtCollection] Toggle failed:', result.error);
}
});
}, []);
// ===== 통계 카드 (API 통계 또는 로컬 계산) =====
const statCards: StatCard[] = useMemo(() => {
if (initialSummary) {
// API 통계 데이터 사용
return [
{ label: '총 악성채권', value: `${initialSummary.total_amount.toLocaleString()}`, icon: AlertTriangle, iconColor: 'text-red-500' },
{ label: '추심중', value: `${initialSummary.collecting_amount.toLocaleString()}`, icon: AlertTriangle, iconColor: 'text-orange-500' },
{ label: '법적조치', value: `${initialSummary.legal_action_amount.toLocaleString()}`, icon: AlertTriangle, iconColor: 'text-red-600' },
{ label: '회수완료', value: `${initialSummary.recovered_amount.toLocaleString()}`, icon: AlertTriangle, iconColor: 'text-green-500' },
];
}
// 로컬 데이터로 계산 (fallback)
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, initialSummary]);
// ===== 테이블 컬럼 =====
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)}
disabled={isPending}
/>
</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, isPending]);
// ===== 모바일 카드 렌더링 =====
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"
disabled={isPending}
>
{isPending ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}