- BadDebtCollection/ReceivablesStatus 리스트 로직 수정 - DraftBox 결재 기안함 개선 - Sidebar/AuthenticatedLayout 레이아웃 보완 - IntegratedListTemplateV2 수정 - table UI 컴포넌트 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
'use client';
|
|
|
|
export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
|
|
|
|
/**
|
|
* 악성채권 추심관리 - UniversalListPage 마이그레이션
|
|
*
|
|
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
|
* - 클라이언트 사이드 필터링 (검색, 거래처, 상태, 정렬)
|
|
* - Stats 카드 (API 통계 또는 로컬 계산)
|
|
* - tableHeaderActions: 3개 Select 필터
|
|
* - Switch 토글 (설정)
|
|
* - 삭제 다이얼로그 (deleteConfirmMessage)
|
|
*/
|
|
|
|
import { useState, useMemo, useCallback, useTransition } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { AlertTriangle } from 'lucide-react';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { TableRow, TableCell } from '@/components/ui/table';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { MobileCard } from '@/components/organisms/MobileCard';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
type SelectionHandlers,
|
|
type RowClickHandlers,
|
|
type StatCard,
|
|
} from '@/components/templates/UniversalListPage';
|
|
import type { BadDebtRecord, SortOption } from './types';
|
|
import {
|
|
COLLECTION_STATUS_LABELS,
|
|
STATUS_FILTER_OPTIONS,
|
|
STATUS_BADGE_STYLES,
|
|
SORT_OPTIONS,
|
|
} from './types';
|
|
import { formatNumber } from '@/lib/utils/amount';
|
|
import { applyFilters, enumFilter } from '@/lib/utils/search';
|
|
import { deleteBadDebt, toggleBadDebt } from './actions';
|
|
|
|
// ===== 테이블 컬럼 정의 =====
|
|
const tableColumns = [
|
|
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
|
{ key: 'vendorName', label: '거래처', className: 'w-[100px]' },
|
|
{ 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]' },
|
|
];
|
|
|
|
// ===== 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 Array.from(vendorMap.entries()).map(([id, name]) => ({
|
|
value: id,
|
|
label: name,
|
|
}));
|
|
};
|
|
|
|
export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollectionProps) {
|
|
const router = useRouter();
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
|
const [data, setData] = useState<BadDebtRecord[]>(initialData);
|
|
const [vendorFilter, setVendorFilter] = useState<string>('all');
|
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
|
|
|
// 거래처 옵션
|
|
const vendorOptions = useMemo(() => getVendorOptions(data), [data]);
|
|
|
|
// ===== 핸들러 =====
|
|
const handleRowClick = useCallback(
|
|
(item: BadDebtRecord) => {
|
|
router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=view`);
|
|
},
|
|
[router]
|
|
);
|
|
|
|
// 설정 토글 핸들러 (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);
|
|
}
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
// ===== 통계 계산 =====
|
|
const statsData = useMemo(() => {
|
|
if (initialSummary) {
|
|
return {
|
|
totalAmount: initialSummary.total_amount,
|
|
collectingAmount: initialSummary.collecting_amount,
|
|
legalActionAmount: initialSummary.legal_action_amount,
|
|
recoveredAmount: initialSummary.recovered_amount,
|
|
};
|
|
}
|
|
|
|
// 로컬 데이터로 계산 (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 { totalAmount, collectingAmount, legalActionAmount, recoveredAmount };
|
|
}, [data, initialSummary]);
|
|
|
|
// ===== UniversalListPage Config =====
|
|
const config: UniversalListConfig<BadDebtRecord> = useMemo(
|
|
() => ({
|
|
// 페이지 기본 정보
|
|
title: '악성채권 추심관리',
|
|
description: '연체 및 악성채권 현황을 추적하고 관리합니다',
|
|
icon: AlertTriangle,
|
|
basePath: '/accounting/bad-debt-collection',
|
|
|
|
// ID 추출
|
|
idField: 'id',
|
|
|
|
// API 액션
|
|
actions: {
|
|
getList: async () => {
|
|
return {
|
|
success: true,
|
|
data: data,
|
|
totalCount: data.length,
|
|
};
|
|
},
|
|
deleteItem: async (id: string) => {
|
|
const result = await deleteBadDebt(id);
|
|
if (result.success) {
|
|
setData((prev) => prev.filter((item) => item.id !== id));
|
|
}
|
|
return { success: result.success, error: result.error };
|
|
},
|
|
},
|
|
|
|
// 테이블 컬럼
|
|
columns: tableColumns,
|
|
|
|
// 클라이언트 사이드 필터링
|
|
clientSideFiltering: true,
|
|
itemsPerPage: 20,
|
|
|
|
// 검색 필터
|
|
searchPlaceholder: '거래처명, 거래처코드, 사업자번호 검색...',
|
|
searchFilter: (item, searchValue) => {
|
|
const search = searchValue.toLowerCase();
|
|
return (
|
|
item.vendorName.toLowerCase().includes(search) ||
|
|
item.vendorCode.toLowerCase().includes(search) ||
|
|
item.businessNumber.toLowerCase().includes(search)
|
|
);
|
|
},
|
|
|
|
// 필터 설정 (모바일용)
|
|
filterConfig: [
|
|
{
|
|
key: 'vendor',
|
|
label: '거래처',
|
|
type: 'single',
|
|
options: vendorOptions,
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: '상태',
|
|
type: 'single',
|
|
options: STATUS_FILTER_OPTIONS.filter((o) => o.value !== 'all').map((o) => ({
|
|
value: o.value,
|
|
label: o.label,
|
|
})),
|
|
},
|
|
{
|
|
key: 'sortBy',
|
|
label: '정렬',
|
|
type: 'single',
|
|
options: SORT_OPTIONS.map((o) => ({
|
|
value: o.value,
|
|
label: o.label,
|
|
})),
|
|
},
|
|
],
|
|
initialFilters: {
|
|
vendor: 'all',
|
|
status: 'all',
|
|
sortBy: 'latest',
|
|
},
|
|
filterTitle: '악성채권 필터',
|
|
|
|
// 커스텀 필터 함수
|
|
customFilterFn: (items) => {
|
|
if (!items || items.length === 0) return items;
|
|
return applyFilters([...items], [
|
|
enumFilter('vendorId', vendorFilter),
|
|
enumFilter('status', statusFilter),
|
|
]);
|
|
},
|
|
|
|
// 커스텀 정렬 함수
|
|
customSortFn: (items) => {
|
|
const sorted = [...items];
|
|
|
|
switch (sortOption) {
|
|
case 'oldest':
|
|
sorted.sort(
|
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
);
|
|
break;
|
|
default: // latest
|
|
sorted.sort(
|
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
);
|
|
break;
|
|
}
|
|
|
|
return sorted;
|
|
},
|
|
|
|
// 테이블 헤더 액션 (3개 필터)
|
|
tableHeaderActions: () => (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* 거래처 필터 */}
|
|
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
|
<SelectTrigger className="min-w-[150px] w-auto">
|
|
<SelectValue placeholder="전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
{vendorOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 상태 필터 */}
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="min-w-[120px] w-auto">
|
|
<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="min-w-[120px] w-auto">
|
|
<SelectValue placeholder="정렬" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SORT_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
),
|
|
|
|
// Stats 카드
|
|
computeStats: (): StatCard[] => [
|
|
{
|
|
label: '총 악성채권',
|
|
value: `${formatNumber(statsData.totalAmount)}원`,
|
|
icon: AlertTriangle,
|
|
iconColor: 'text-red-500',
|
|
},
|
|
{
|
|
label: '추심중',
|
|
value: `${formatNumber(statsData.collectingAmount)}원`,
|
|
icon: AlertTriangle,
|
|
iconColor: 'text-orange-500',
|
|
},
|
|
{
|
|
label: '법적조치',
|
|
value: `${formatNumber(statsData.legalActionAmount)}원`,
|
|
icon: AlertTriangle,
|
|
iconColor: 'text-red-600',
|
|
},
|
|
{
|
|
label: '회수완료',
|
|
value: `${formatNumber(statsData.recoveredAmount)}원`,
|
|
icon: AlertTriangle,
|
|
iconColor: 'text-green-500',
|
|
},
|
|
],
|
|
|
|
// 삭제 확인 메시지
|
|
deleteConfirmMessage: {
|
|
title: '악성채권 삭제',
|
|
description: '이 악성채권 기록을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
|
|
},
|
|
|
|
// 테이블 행 렌더링
|
|
renderTableRow: (
|
|
item: BadDebtRecord,
|
|
index: number,
|
|
globalIndex: number,
|
|
handlers: SelectionHandlers & RowClickHandlers<BadDebtRecord>
|
|
) => (
|
|
<TableRow
|
|
key={item.id}
|
|
className="hover:bg-muted/50 cursor-pointer"
|
|
onClick={() => handleRowClick(item)}
|
|
>
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
|
</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">
|
|
{formatNumber(item.debtAmount)}원
|
|
</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>
|
|
</TableRow>
|
|
),
|
|
|
|
// 모바일 카드 렌더링
|
|
renderMobileCard: (
|
|
item: BadDebtRecord,
|
|
index: number,
|
|
globalIndex: number,
|
|
handlers: SelectionHandlers & RowClickHandlers<BadDebtRecord>
|
|
) => (
|
|
<MobileCard
|
|
key={item.id}
|
|
title={item.vendorName}
|
|
subtitle={`채권금액: ${formatNumber(item.debtAmount)}원`}
|
|
badge={COLLECTION_STATUS_LABELS[item.status]}
|
|
badgeVariant="outline"
|
|
badgeClassName={STATUS_BADGE_STYLES[item.status]}
|
|
isSelected={handlers.isSelected}
|
|
onToggle={handlers.onToggle}
|
|
onClick={() => handleRowClick(item)}
|
|
details={[
|
|
{ label: '연체일수', value: `${item.overdueDays}일` },
|
|
{ label: '발생일', value: item.occurrenceDate },
|
|
{ label: '담당자', value: item.assignedManager?.name || '-' },
|
|
]}
|
|
/>
|
|
),
|
|
}),
|
|
[
|
|
data,
|
|
vendorOptions,
|
|
vendorFilter,
|
|
statusFilter,
|
|
sortOption,
|
|
statsData,
|
|
handleRowClick,
|
|
handleSettingToggle,
|
|
isPending,
|
|
]
|
|
);
|
|
|
|
return <UniversalListPage config={config} initialData={data} />;
|
|
} |