feat(WEB): UniversalListPage 전체 마이그레이션 및 코드 정리

- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선
- 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션
- 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등)
- 미들웨어 토큰 갱신 로직 개선
- AuthenticatedLayout 구조 개선
- claudedocs 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-16 15:19:09 +09:00
parent 8639eee5df
commit ad493bcea6
90 changed files with 19864 additions and 20305 deletions

View File

@@ -1,27 +1,23 @@
'use client';
/**
* 악성채권 추심관리 - 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,
Pencil,
Trash2,
Eye,
} from 'lucide-react';
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,
@@ -30,16 +26,15 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MobileCard } from '@/components/molecules/MobileCard';
import {
IntegratedListTemplateV2,
type TableColumn,
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type StatCard,
} from '@/components/templates/IntegratedListTemplateV2';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import type {
BadDebtRecord,
SortOption,
} from './types';
} from '@/components/templates/UniversalListPage';
import type { BadDebtRecord, SortOption } from './types';
import {
COLLECTION_STATUS_LABELS,
STATUS_FILTER_OPTIONS,
@@ -48,6 +43,19 @@ import {
} from './types';
import { deleteBadDebt, toggleBadDebt } from './actions';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ 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]' },
];
// ===== Props 타입 정의 =====
interface BadDebtCollectionProps {
initialData: BadDebtRecord[];
@@ -63,407 +71,421 @@ interface BadDebtCollectionProps {
// 거래처 목록 추출 (필터용)
const getVendorOptions = (data: BadDebtRecord[]) => {
const vendorMap = new Map<string, string>();
data.forEach(item => {
data.forEach((item) => {
vendorMap.set(item.vendorId, item.vendorName);
});
return [
{ value: 'all', label: '전체' },
...Array.from(vendorMap.entries()).map(([id, name]) => ({
value: id,
label: name,
})),
];
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();
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<SortOption>('latest');
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [data, setData] = useState<BadDebtRecord[]>(initialData);
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 [sortOption, setSortOption] = useState<SortOption>('latest');
// 거래처 옵션
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 handleRowClick = useCallback(
(item: BadDebtRecord) => {
router.push(`/ko/accounting/bad-debt-collection/${item.id}`);
},
[router]
);
// ===== 필터링된 데이터 =====
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]);
const handleEdit = useCallback(
(item: BadDebtRecord) => {
router.push(`/ko/accounting/bad-debt-collection/${item.id}/edit`);
},
[router]
);
// 설정 토글 핸들러 (API 호출)
const handleSettingToggle = useCallback((id: string, checked: boolean) => {
// Optimistic update
setData(prev => prev.map(item =>
item.id === id ? { ...item, settingToggle: checked } : item
));
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);
}
});
}, []);
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(() => {
// ===== 통계 계산 =====
const statsData = 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' },
];
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);
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' },
];
return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount };
}, [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]' },
], []);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<BadDebtRecord> = useMemo(
() => ({
// 페이지 기본 정보
title: '악성채권 추심관리',
description: '연체 및 악성채권 현황을 추적하고 관리합니다',
icon: AlertTriangle,
basePath: '/accounting/bad-debt-collection',
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: BadDebtRecord, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
// ID 추출
idField: '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]);
// 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 };
},
},
// ===== 모바일 카드 렌더링 =====
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>
// 테이블 컬럼
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) => {
let result = [...items];
// 거래처 필터
if (vendorFilter !== 'all') {
result = result.filter((item) => item.vendorId === vendorFilter);
}
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>
// 상태 필터
if (statusFilter !== 'all') {
result = result.filter((item) => item.status === statusFilter);
}
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
return result;
},
// 커스텀 정렬 함수
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;
}
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>
return sorted;
},
{/* 상태 필터 */}
<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>
// 테이블 헤더 액션 (3개 필터)
tableHeaderActions: () => (
<div className="flex items-center gap-2 flex-wrap">
{/* 거래처 필터 */}
<Select value={vendorFilter} onValueChange={setVendorFilter}>
<SelectTrigger className="w-[150px]">
<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={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>
);
{/* 상태 필터 */}
<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>
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,
}}
/>
{/* 정렬 */}
<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>
),
{/* 삭제 확인 다이얼로그 */}
<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"
// Stats 카드
computeStats: (): StatCard[] => [
{
label: '총 악성채권',
value: `${statsData.totalAmount.toLocaleString()}`,
icon: AlertTriangle,
iconColor: 'text-red-500',
},
{
label: '추심중',
value: `${statsData.collectingAmount.toLocaleString()}`,
icon: AlertTriangle,
iconColor: 'text-orange-500',
},
{
label: '법적조치',
value: `${statsData.legalActionAmount.toLocaleString()}`,
icon: AlertTriangle,
iconColor: 'text-red-600',
},
{
label: '회수완료',
value: `${statsData.recoveredAmount.toLocaleString()}`,
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">
{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}
>
{isPending ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
/>
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.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={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
),
// 모바일 카드 렌더링
renderMobileCard: (
item: BadDebtRecord,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<BadDebtRecord>
) => (
<MobileCard
key={item.id}
title={item.vendorName}
subtitle={`채권금액: ${item.debtAmount.toLocaleString()}`}
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 || '-' },
]}
actions={
handlers.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={() => handlers.onDelete?.(item)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
),
}),
[
data,
vendorOptions,
vendorFilter,
statusFilter,
sortOption,
statsData,
handleRowClick,
handleEdit,
handleSettingToggle,
isPending,
]
);
}
return <UniversalListPage config={config} initialData={data} />;
}