feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
327
src/components/pricing-distribution/PriceDistributionList.tsx
Normal file
327
src/components/pricing-distribution/PriceDistributionList.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 단가배포 목록 클라이언트 컴포넌트
|
||||
*
|
||||
* UniversalListPage 공통 템플릿 활용
|
||||
* - 탭 없음, 통계 카드 없음
|
||||
* - 상태 필터: filterConfig (SELECT 드롭다운)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } 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[] = [
|
||||
{ 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;
|
||||
Reference in New Issue
Block a user