- actions.ts 신규 생성 (서버 액션) - page.tsx 서버 컴포넌트로 전환 - index.tsx initialData props 패턴 적용 - Mock 데이터 제거, 실제 API 호출로 대체
470 lines
17 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|