Files
sam-react-prod/src/components/pricing-distribution/PriceDistributionList.tsx
유병철 e14335b635 refactor(WEB): DataTable 개선 및 회계 상세 컴포넌트 리팩토링
- DataTable 컴포넌트 기능 확장 및 코드 개선
- 회계 상세 컴포넌트(Bill/Deposit/Purchase/Sales/Withdrawal) 리팩토링
- 엑셀 다운로드 유틸리티 개선
- 대시보드 및 각종 리스트 페이지 업데이트
- dashboard_type2 페이지 추가
- 프론트엔드 개선 로드맵 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:03:19 +09:00

328 lines
10 KiB
TypeScript

/**
* 단가배포 목록 클라이언트 컴포넌트
*
* UniversalListPage 공통 템플릿 활용
* - 탭 없음, 통계 카드 없음
* - 상태 필터: filterConfig (SELECT 드롭다운)
*/
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, FilePlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
UniversalListPage,
type UniversalListConfig,
type TableColumn,
type FilterFieldConfig,
type SelectionHandlers,
type RowClickHandlers,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import type { PriceDistributionListItem, DistributionStatus } from './types';
import {
DISTRIBUTION_STATUS_LABELS,
DISTRIBUTION_STATUS_STYLES,
} from './types';
import {
getPriceDistributionList,
createPriceDistribution,
deletePriceDistribution,
} from './actions';
export function PriceDistributionList() {
const router = useRouter();
const [data, setData] = useState<PriceDistributionListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showRegisterDialog, setShowRegisterDialog] = useState(false);
const [isRegistering, setIsRegistering] = useState(false);
const pageSize = 20;
// 날짜 범위 상태 (최근 30일)
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
const [startDate, setStartDate] = useState<string>(thirtyDaysAgo.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const listResult = await getPriceDistributionList();
if (listResult.success && listResult.data) {
setData(listResult.data.items);
}
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 검색 필터
const searchFilter = (item: PriceDistributionListItem, search: string) => {
const s = search.toLowerCase();
return (
item.distributionNo.toLowerCase().includes(s) ||
item.distributionName.toLowerCase().includes(s) ||
item.author.toLowerCase().includes(s)
);
};
// 상태 Badge 렌더링
const renderStatusBadge = (status: DistributionStatus) => {
const style = DISTRIBUTION_STATUS_STYLES[status];
const label = DISTRIBUTION_STATUS_LABELS[status];
return (
<Badge
variant="outline"
className={`${style.bg} ${style.text} ${style.border}`}
>
{label}
</Badge>
);
};
// 등록 핸들러
const handleRegister = async () => {
setIsRegistering(true);
try {
const result = await createPriceDistribution();
if (result.success && result.data) {
toast.success('단가배포가 등록되었습니다.');
setShowRegisterDialog(false);
router.push(`/master-data/price-distribution/${result.data.id}`);
} else {
toast.error(result.error || '등록에 실패했습니다.');
}
} catch {
toast.error('등록 중 오류가 발생했습니다.');
} finally {
setIsRegistering(false);
}
};
// 행 클릭 → 상세
const handleRowClick = (item: PriceDistributionListItem) => {
router.push(`/master-data/price-distribution/${item.id}`);
};
// 상태 필터 설정
const filterConfig: FilterFieldConfig[] = [
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'initial', label: '최초작성' },
{ value: 'revision', label: '보이수정' },
{ value: 'finalized', label: '최종확정' },
],
},
];
// 커스텀 필터 함수
const customFilterFn = (items: PriceDistributionListItem[], filterValues: Record<string, string | string[]>) => {
const status = filterValues.status as string;
if (!status || status === '') return items;
return items.filter((item) => item.status === status);
};
// 테이블 컬럼
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
{ key: 'distributionNo', label: '단가배포번호', className: 'min-w-[120px]' },
{ key: 'distributionName', label: '단가배포명', className: 'min-w-[150px]' },
{ key: 'status', label: '상태', className: 'min-w-[100px]' },
{ key: 'author', label: '작성자', className: 'min-w-[100px]' },
{ key: 'createdAt', label: '등록일', className: 'min-w-[120px]' },
], []);
// 테이블 행 렌더링
const renderTableRow = (
item: PriceDistributionListItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<PriceDistributionListItem>
) => {
const { isSelected, onToggle } = handlers;
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={onToggle} />
</TableCell>
<TableCell className="text-muted-foreground text-center">
{globalIndex}
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{item.distributionNo}
</code>
</TableCell>
<TableCell>
<span className="font-medium">{item.distributionName}</span>
</TableCell>
<TableCell>{renderStatusBadge(item.status)}</TableCell>
<TableCell className="text-muted-foreground">{item.author}</TableCell>
<TableCell className="text-muted-foreground">
{new Date(item.createdAt).toLocaleDateString('ko-KR')}
</TableCell>
</TableRow>
);
};
// 모바일 카드 렌더링
const renderMobileCard = (
item: PriceDistributionListItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<PriceDistributionListItem>
) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
key={item.id}
id={item.id}
title={item.distributionName}
headerBadges={
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{item.distributionNo}
</code>
}
statusBadge={renderStatusBadge(item.status)}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => handleRowClick(item)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="작성자" value={item.author} />
<InfoField
label="등록일"
value={new Date(item.createdAt).toLocaleDateString('ko-KR')}
/>
</div>
}
/>
);
};
// 헤더 액션
const headerActions = () => (
<Button
variant="default"
size="sm"
onClick={() => setShowRegisterDialog(true)}
className="ml-auto gap-2 bg-gray-900 text-white hover:bg-gray-800"
>
<FilePlus className="h-4 w-4" />
</Button>
);
// UniversalListPage 설정
const listConfig: UniversalListConfig<PriceDistributionListItem> = {
title: '단가배포 목록',
description: '단가표 기준 거래처별 단가 배포를 관리합니다',
icon: FileText,
basePath: '/master-data/price-distribution',
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: data,
totalCount: data.length,
}),
deleteBulk: async (ids: string[]) => {
const result = await deletePriceDistribution(ids);
return { success: result.success, error: result.error };
},
},
columns: tableColumns,
headerActions,
filterConfig,
// 날짜 범위 필터 + 프리셋 버튼
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
dateField: 'createdAt',
},
searchPlaceholder: '단가배포번호, 단가배포명, 작성자 검색...',
itemsPerPage: pageSize,
clientSideFiltering: true,
searchFilter,
customFilterFn,
renderTableRow,
renderMobileCard,
};
return (
<>
<UniversalListPage<PriceDistributionListItem>
config={listConfig}
initialData={data}
initialTotalCount={data.length}
/>
{/* 단가배포 등록 확인 다이얼로그 */}
<AlertDialog open={showRegisterDialog} onOpenChange={setShowRegisterDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<span className="block font-semibold text-foreground">
?
</span>
<span className="block text-muted-foreground">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRegistering}></AlertDialogCancel>
<AlertDialogAction
onClick={handleRegister}
disabled={isRegistering}
>
{isRegistering ? '등록 중...' : '등록'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export default PriceDistributionList;