Files
sam-react-prod/src/components/accounting/VendorManagement/VendorManagementClient.tsx
권혁성 31157122ca fix: [vendor] 거래처 관리 날짜 필터 기본값 변경
- 기본값을 당월 → 빈 값(전체 조회)으로 변경
- date-fns import 제거
- 날짜 필터 범위 조건 개선
2026-03-14 08:29:09 +09:00

584 lines
20 KiB
TypeScript

'use client';
/**
* 거래처관리 - UniversalListPage 마이그레이션
*
* IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 클라이언트 사이드 필터링/페이지네이션
* - computeStats: 통계 카드 (전체/매출/매입 거래처)
* - tableHeaderActions: 5개 필터 (구분, 신용등급, 거래등급, 악성채권, 정렬)
*/
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
import {
Building2,
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 { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
UniversalListPage,
type UniversalListConfig,
type TableColumn,
type StatCard,
type SelectionHandlers,
type RowClickHandlers,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { deleteClient } from './actions';
import type {
Vendor,
SortOption,
} from './types';
import {
VENDOR_CATEGORY_OPTIONS,
VENDOR_CATEGORY_LABELS,
VENDOR_CATEGORY_COLORS,
CREDIT_RATING_OPTIONS,
CREDIT_RATING_COLORS,
TRANSACTION_GRADE_OPTIONS,
TRANSACTION_GRADE_LABELS,
TRANSACTION_GRADE_COLORS,
BAD_DEBT_STATUS_OPTIONS,
BAD_DEBT_STATUS_LABELS,
BAD_DEBT_STATUS_COLORS,
VENDOR_STATUS_LABELS,
VENDOR_STATUS_COLORS,
SORT_OPTIONS,
} from './types';
interface VendorManagementClientProps {
initialData: Vendor[];
initialTotal: number;
}
export function VendorManagementClient({ initialData, initialTotal: _initialTotal }: VendorManagementClientProps) {
const router = useRouter();
// ===== 상태 관리 =====
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [creditRatingFilter, setCreditRatingFilter] = useState<string>('all');
const [transactionGradeFilter, setTransactionGradeFilter] = useState<string>('all');
const [badDebtFilter, setBadDebtFilter] = useState<string>('all');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 삭제 다이얼로그
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const result = await deleteClient(id);
if (result.success) {
setData(prev => prev.filter(item => item.id !== id));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return result;
},
entityName: '거래처',
});
// API 데이터 상태
const [data, setData] = useState<Vendor[]>(initialData);
// ===== 체크박스 핸들러 =====
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(() => {
const result = applyFilters(data, [
textFilter(searchQuery, ['vendorName', 'vendorCode', 'businessNumber']),
enumFilter('category', categoryFilter),
enumFilter('creditRating', creditRatingFilter),
enumFilter('transactionGrade', transactionGradeFilter),
enumFilter('badDebtStatus', badDebtFilter),
(items: Vendor[]) => items.filter((item) => {
if (!startDate && !endDate) return true;
if (!item.createdAt) return true;
const created = item.createdAt.slice(0, 10);
if (startDate && created < startDate) return false;
if (endDate && created > endDate) return false;
return true;
}),
]);
// 정렬
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;
case 'nameAsc':
result.sort((a, b) => a.vendorName.localeCompare(b.vendorName));
break;
case 'nameDesc':
result.sort((a, b) => b.vendorName.localeCompare(a.vendorName));
break;
case 'outstandingHigh':
result.sort((a, b) => b.outstandingAmount - a.outstandingAmount);
break;
case 'outstandingLow':
result.sort((a, b) => a.outstandingAmount - b.outstandingAmount);
break;
}
return result;
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption, startDate, endDate]);
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: Vendor) => {
router.push(`/ko/accounting/vendors/${item.id}?mode=view`);
}, [router]);
const handleEdit = useCallback((item: Vendor) => {
router.push(`/ko/accounting/vendors/${item.id}?mode=edit`);
}, [router]);
// ===== 통계 카드 =====
const statCards: StatCard[] = useMemo(() => {
const totalCount = data.length;
const salesCount = data.filter(d => d.category === 'sales' || d.category === 'both').length;
const purchaseCount = data.filter(d => d.category === 'purchase' || d.category === 'both').length;
return [
{ label: '전체 거래처', value: `${totalCount}`, icon: Building2, iconColor: 'text-blue-500' },
{ label: '매출 거래처', value: `${salesCount}`, icon: Building2, iconColor: 'text-green-500' },
{ label: '매입 거래처', value: `${purchaseCount}`, icon: Building2, iconColor: 'text-orange-500' },
];
}, [data]);
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처명', sortable: true, copyable: true },
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'creditRating', label: '신용등급', className: 'text-center w-[90px]', sortable: true },
{ key: 'transactionGrade', label: '거래등급', className: 'text-center w-[100px]', sortable: true },
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true, copyable: true },
{ key: 'badDebtStatus', label: '악성채권', className: 'text-center w-[90px]', sortable: true },
{ key: 'status', label: '상태', className: 'text-center w-[80px]', sortable: true },
{ key: 'actions', label: '작업', className: 'text-center w-[150px]' },
], []);
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((
item: Vendor,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Vendor>
) => {
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={handlers.isSelected} onCheckedChange={handlers.onToggle} />
</TableCell>
{/* 번호 */}
<TableCell className="text-center text-sm text-gray-500">{globalIndex}</TableCell>
{/* 구분 */}
<TableCell className="text-center">
<Badge className={VENDOR_CATEGORY_COLORS[item.category]}>
{VENDOR_CATEGORY_LABELS[item.category]}
</Badge>
</TableCell>
{/* 거래처명 */}
<TableCell className="font-medium">{item.vendorName}</TableCell>
{/* 매입 결제일 */}
<TableCell className="text-center">{item.purchasePaymentDay}</TableCell>
{/* 매출 결제일 */}
<TableCell className="text-center">{item.salesPaymentDay}</TableCell>
{/* 신용등급 */}
<TableCell className="text-center">
<Badge className={CREDIT_RATING_COLORS[item.creditRating]}>
{item.creditRating}
</Badge>
</TableCell>
{/* 거래등급 */}
<TableCell className="text-center">
<Badge className={TRANSACTION_GRADE_COLORS[item.transactionGrade]}>
{TRANSACTION_GRADE_LABELS[item.transactionGrade]}
</Badge>
</TableCell>
{/* 미수금 */}
<TableCell className="text-right">
{item.outstandingAmount > 0 ? (
<span className="text-red-600 font-medium">{formatNumber(item.outstandingAmount)}</span>
) : (
<span className="text-gray-500">-</span>
)}
</TableCell>
{/* 악성채권 */}
<TableCell className="text-center">
{item.badDebtStatus === 'none' ? (
<span className="text-gray-400">-</span>
) : (
<Badge className={BAD_DEBT_STATUS_COLORS[item.badDebtStatus]}>
{BAD_DEBT_STATUS_LABELS[item.badDebtStatus]}
</Badge>
)}
</TableCell>
{/* 상태 */}
<TableCell className="text-center">
<Badge className={VENDOR_STATUS_COLORS[item.isActive ? 'active' : 'inactive']}>
{VENDOR_STATUS_LABELS[item.isActive ? 'active' : 'inactive']}
</Badge>
</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={() => deleteDialog.single.open(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
}, [handleRowClick, handleEdit, deleteDialog.single.open]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: Vendor,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Vendor>
) => {
return (
<ListMobileCard
id={item.id}
title={item.vendorName}
headerBadges={
<>
<Badge className={VENDOR_CATEGORY_COLORS[item.category]}>
{VENDOR_CATEGORY_LABELS[item.category]}
</Badge>
<Badge className={CREDIT_RATING_COLORS[item.creditRating]}>
{item.creditRating}
</Badge>
</>
}
isSelected={handlers.isSelected}
onToggleSelection={handlers.onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="거래처코드" value={item.vendorCode} />
<InfoField label="거래등급" value={TRANSACTION_GRADE_LABELS[item.transactionGrade]} />
<InfoField
label="미수금"
value={item.outstandingAmount > 0 ? `${formatNumber(item.outstandingAmount)}` : '-'}
className={item.outstandingAmount > 0 ? 'text-red-600' : ''}
/>
<InfoField label="결제일" value={`매입 ${item.purchasePaymentDay}일 / 매출 ${item.salesPaymentDay}`} />
<InfoField label="상태" value={VENDOR_STATUS_LABELS[item.isActive ? 'active' : 'inactive']} />
</div>
}
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={() => deleteDialog.single.open(item.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
onClick={() => handleRowClick(item)}
/>
);
}, [handleRowClick, handleEdit, deleteDialog.single.open]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Vendor> = useMemo(
() => ({
// 페이지 기본 정보
title: '거래처관리',
description: '거래처 정보를 등록하고 관리합니다',
icon: Building2,
basePath: '/accounting/vendors',
// ID 추출
idField: 'id',
// API 액션
actions: {
getList: async () => {
return {
success: true,
data: paginatedData,
totalCount: filteredData.length,
};
},
},
// 테이블 컬럼
columns: tableColumns,
// 클라이언트 사이드 필터링 (이미 외부에서 처리)
clientSideFiltering: false,
itemsPerPage,
// 날짜 범위 선택기
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 검색
searchPlaceholder: '거래처명, 거래처코드, 사업자번호 검색...',
onSearchChange: setSearchQuery,
// 모바일 필터 설정
filterConfig: [
{
key: 'category',
label: '구분',
type: 'single',
options: VENDOR_CATEGORY_OPTIONS.filter(o => o.value !== 'all'),
},
{
key: 'creditRating',
label: '신용등급',
type: 'single',
options: CREDIT_RATING_OPTIONS.filter(o => o.value !== 'all'),
},
{
key: 'transactionGrade',
label: '거래등급',
type: 'single',
options: TRANSACTION_GRADE_OPTIONS.filter(o => o.value !== 'all'),
},
{
key: 'badDebt',
label: '악성채권',
type: 'single',
options: BAD_DEBT_STATUS_OPTIONS.filter(o => o.value !== 'all'),
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: SORT_OPTIONS,
},
],
initialFilters: {
category: categoryFilter,
creditRating: creditRatingFilter,
transactionGrade: transactionGradeFilter,
badDebt: badDebtFilter,
sortBy: sortOption,
},
filterTitle: '거래처 필터',
// 통계 카드
computeStats: (): StatCard[] => statCards,
// 테이블 헤더 액션 (5개 필터)
tableHeaderActions: (
<div className="flex items-center gap-2 flex-wrap">
{/* 구분 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="min-w-[120px] w-auto">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{VENDOR_CATEGORY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 신용등급 필터 */}
<Select value={creditRatingFilter} onValueChange={setCreditRatingFilter}>
<SelectTrigger className="min-w-[110px] w-auto">
<SelectValue placeholder="신용등급" />
</SelectTrigger>
<SelectContent>
{CREDIT_RATING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 거래등급 필터 */}
<Select value={transactionGradeFilter} onValueChange={setTransactionGradeFilter}>
<SelectTrigger className="min-w-[120px] w-auto">
<SelectValue placeholder="거래등급" />
</SelectTrigger>
<SelectContent>
{TRANSACTION_GRADE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 악성채권 필터 */}
<Select value={badDebtFilter} onValueChange={setBadDebtFilter}>
<SelectTrigger className="min-w-[110px] w-auto">
<SelectValue placeholder="악성채권" />
</SelectTrigger>
<SelectContent>
{BAD_DEBT_STATUS_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-[150px] w-auto">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
),
// 렌더링 함수
renderTableRow,
renderMobileCard,
}),
[
paginatedData,
filteredData.length,
tableColumns,
statCards,
startDate,
endDate,
categoryFilter,
creditRatingFilter,
transactionGradeFilter,
badDebtFilter,
sortOption,
itemsPerPage,
renderTableRow,
renderMobileCard,
]
);
return (
<>
<UniversalListPage
config={config}
initialData={paginatedData}
externalPagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
externalSelection={{
selectedItems,
onToggleSelection: toggleSelection,
onToggleSelectAll: toggleSelectAll,
getItemId: (item: Vendor) => item.id,
}}
/>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="거래처 삭제"
description="이 거래처를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={deleteDialog.isPending}
/>
</>
);
}