Files
sam-react-prod/src/components/accounting/BadDebtCollection/index.tsx
유병철 5f956540e8 fix(WEB): 회계/결재/레이아웃 버그 수정 및 UI 개선
- BadDebtCollection/ReceivablesStatus 리스트 로직 수정
- DraftBox 결재 기안함 개선
- Sidebar/AuthenticatedLayout 레이아웃 보완
- IntegratedListTemplateV2 수정
- table UI 컴포넌트 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:37:28 +09:00

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} />;
}