- DataTable 컴포넌트 기능 확장 및 코드 개선 - 회계 상세 컴포넌트(Bill/Deposit/Purchase/Sales/Withdrawal) 리팩토링 - 엑셀 다운로드 유틸리티 개선 - 대시보드 및 각종 리스트 페이지 업데이트 - dashboard_type2 페이지 추가 - 프론트엔드 개선 로드맵 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
328 lines
10 KiB
TypeScript
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;
|