- transformFrontendToApi에서 data.itemType 사용 (FG, PT, SM, RM, CS) - 잘못된 'PRODUCT'/'MATERIAL' 코드 대신 실제 품목 유형 코드 사용 - create/page.tsx에서 itemTypeCode 파라미터 제거 - PricingListClient.tsx URL에서 itemTypeCode 파라미터 제거 - types.ts에서 itemTypeCode 속성 제거
451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
/**
|
|
* 단가 목록 클라이언트 컴포넌트
|
|
*
|
|
* IntegratedListTemplateV2 공통 템플릿 활용
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
DollarSign,
|
|
Package,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Plus,
|
|
Edit,
|
|
History,
|
|
RefreshCw,
|
|
} 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 {
|
|
IntegratedListTemplateV2,
|
|
type TabOption,
|
|
type TableColumn,
|
|
type StatCard,
|
|
} from '@/components/templates/IntegratedListTemplateV2';
|
|
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
|
import type { PricingListItem, ItemType } from './types';
|
|
import { ITEM_TYPE_LABELS, ITEM_TYPE_COLORS } from './types';
|
|
|
|
interface PricingListClientProps {
|
|
initialData: PricingListItem[];
|
|
}
|
|
|
|
export function PricingListClient({
|
|
initialData,
|
|
}: PricingListClientProps) {
|
|
const router = useRouter();
|
|
const [data] = useState<PricingListItem[]>(initialData);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [activeTab, setActiveTab] = useState('all');
|
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const pageSize = 20;
|
|
|
|
// 필터링된 데이터
|
|
const filteredData = useMemo(() => {
|
|
let result = [...data];
|
|
|
|
// 탭 필터
|
|
if (activeTab !== 'all') {
|
|
result = result.filter(item => item.itemType === activeTab);
|
|
}
|
|
|
|
// 검색 필터
|
|
if (searchTerm) {
|
|
const search = searchTerm.toLowerCase();
|
|
result = result.filter(item =>
|
|
item.itemCode.toLowerCase().includes(search) ||
|
|
item.itemName.toLowerCase().includes(search) ||
|
|
(item.specification?.toLowerCase().includes(search) ?? false)
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}, [data, activeTab, searchTerm]);
|
|
|
|
// 페이지네이션된 데이터
|
|
const paginatedData = useMemo(() => {
|
|
const start = (currentPage - 1) * pageSize;
|
|
return filteredData.slice(start, start + pageSize);
|
|
}, [filteredData, currentPage, pageSize]);
|
|
|
|
// 통계 계산
|
|
const totalStats = useMemo(() => {
|
|
const totalAll = data.length;
|
|
const totalFG = data.filter(d => d.itemType === 'FG').length;
|
|
const totalPT = data.filter(d => d.itemType === 'PT').length;
|
|
const totalSM = data.filter(d => d.itemType === 'SM').length;
|
|
const totalRM = data.filter(d => d.itemType === 'RM').length;
|
|
const totalCS = data.filter(d => d.itemType === 'CS').length;
|
|
const registered = data.filter(d => d.status !== 'not_registered').length;
|
|
const notRegistered = totalAll - registered;
|
|
const finalized = data.filter(d => d.isFinal).length;
|
|
|
|
return { totalAll, totalFG, totalPT, totalSM, totalRM, totalCS, registered, notRegistered, finalized };
|
|
}, [data]);
|
|
|
|
// 금액 포맷팅
|
|
const formatPrice = (price?: number) => {
|
|
if (price === undefined || price === null) return '-';
|
|
return `${price.toLocaleString()}원`;
|
|
};
|
|
|
|
// 품목 유형 Badge 렌더링
|
|
const renderItemTypeBadge = (type: string) => {
|
|
const colors = ITEM_TYPE_COLORS[type as ItemType];
|
|
const label = ITEM_TYPE_LABELS[type as ItemType] || type;
|
|
|
|
if (!colors) {
|
|
return <Badge variant="outline">{label}</Badge>;
|
|
}
|
|
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={`${colors.bg} ${colors.text} ${colors.border}`}
|
|
>
|
|
{label}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
// 상태 Badge 렌더링
|
|
const renderStatusBadge = (item: PricingListItem) => {
|
|
if (item.status === 'not_registered') {
|
|
return <Badge variant="outline" className="bg-gray-50 text-gray-700">미등록</Badge>;
|
|
}
|
|
if (item.isFinal) {
|
|
return <Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200">확정</Badge>;
|
|
}
|
|
if (item.status === 'active') {
|
|
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">활성</Badge>;
|
|
}
|
|
if (item.status === 'inactive') {
|
|
return <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">비활성</Badge>;
|
|
}
|
|
return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">초안</Badge>;
|
|
};
|
|
|
|
// 마진율 Badge 렌더링
|
|
const renderMarginBadge = (marginRate?: number) => {
|
|
if (marginRate === undefined || marginRate === null || marginRate === 0) {
|
|
return <span className="text-muted-foreground">-</span>;
|
|
}
|
|
|
|
const colorClass =
|
|
marginRate >= 30 ? 'bg-green-50 text-green-700 border-green-200' :
|
|
marginRate >= 20 ? 'bg-blue-50 text-blue-700 border-blue-200' :
|
|
marginRate >= 10 ? 'bg-orange-50 text-orange-700 border-orange-200' :
|
|
'bg-red-50 text-red-700 border-red-200';
|
|
|
|
return (
|
|
<Badge variant="outline" className={colorClass}>
|
|
{marginRate.toFixed(1)}%
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
// 네비게이션 핸들러
|
|
const handleRegister = (item: PricingListItem) => {
|
|
// item_type_code는 품목 정보에서 자동으로 가져오므로 URL에 포함하지 않음
|
|
router.push(`/sales/pricing-management/create?itemId=${item.itemId}&itemCode=${item.itemCode}`);
|
|
};
|
|
|
|
const handleEdit = (item: PricingListItem) => {
|
|
router.push(`/sales/pricing-management/${item.id}/edit`);
|
|
};
|
|
|
|
const handleHistory = (item: PricingListItem) => {
|
|
// TODO: 이력 다이얼로그 열기
|
|
console.log('이력 조회:', item.id);
|
|
};
|
|
|
|
// 체크박스 전체 선택/해제
|
|
const toggleSelectAll = () => {
|
|
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
|
|
setSelectedItems(new Set());
|
|
} else {
|
|
const allIds = new Set(paginatedData.map((item) => item.id));
|
|
setSelectedItems(allIds);
|
|
}
|
|
};
|
|
|
|
// 개별 체크박스 선택/해제
|
|
const toggleSelection = (itemId: string) => {
|
|
const newSelected = new Set(selectedItems);
|
|
if (newSelected.has(itemId)) {
|
|
newSelected.delete(itemId);
|
|
} else {
|
|
newSelected.add(itemId);
|
|
}
|
|
setSelectedItems(newSelected);
|
|
};
|
|
|
|
// 탭 옵션
|
|
const tabs: TabOption[] = [
|
|
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
|
|
{ value: 'FG', label: '제품', count: totalStats.totalFG, color: 'purple' },
|
|
{ value: 'PT', label: '부품', count: totalStats.totalPT, color: 'orange' },
|
|
{ value: 'SM', label: '부자재', count: totalStats.totalSM, color: 'green' },
|
|
{ value: 'RM', label: '원자재', count: totalStats.totalRM, color: 'blue' },
|
|
{ value: 'CS', label: '소모품', count: totalStats.totalCS, color: 'gray' },
|
|
];
|
|
|
|
// 통계 카드
|
|
const stats: StatCard[] = [
|
|
{ label: '전체 품목', value: totalStats.totalAll, icon: Package, iconColor: 'text-blue-600' },
|
|
{ label: '단가 등록', value: totalStats.registered, icon: DollarSign, iconColor: 'text-green-600' },
|
|
{ label: '미등록', value: totalStats.notRegistered, icon: AlertCircle, iconColor: 'text-orange-600' },
|
|
{ label: '확정', value: totalStats.finalized, icon: CheckCircle2, iconColor: 'text-purple-600' },
|
|
];
|
|
|
|
// 테이블 컬럼 정의
|
|
const tableColumns: TableColumn[] = [
|
|
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
|
{ key: 'itemType', label: '품목유형', className: 'min-w-[100px]' },
|
|
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
|
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
|
{ key: 'specification', label: '규격', className: 'min-w-[100px]', hideOnMobile: true },
|
|
{ key: 'unit', label: '단위', className: 'min-w-[60px]', hideOnMobile: true },
|
|
{ key: 'purchasePrice', label: '매입단가', className: 'min-w-[100px] text-right', hideOnTablet: true },
|
|
{ key: 'processingCost', label: '가공비', className: 'min-w-[80px] text-right', hideOnTablet: true },
|
|
{ key: 'salesPrice', label: '판매단가', className: 'min-w-[100px] text-right' },
|
|
{ key: 'marginRate', label: '마진율', className: 'min-w-[80px] text-right', hideOnMobile: true },
|
|
{ key: 'effectiveDate', label: '적용일', className: 'min-w-[100px]', hideOnMobile: true },
|
|
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
|
{ key: 'actions', label: '작업', className: 'w-[120px] text-right' },
|
|
];
|
|
|
|
// 테이블 행 렌더링
|
|
const renderTableRow = (item: PricingListItem, index: number, globalIndex: number) => {
|
|
const isSelected = selectedItems.has(item.id);
|
|
|
|
return (
|
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
|
<TableCell className="text-center">
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={() => toggleSelection(item.id)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-center">
|
|
{globalIndex}
|
|
</TableCell>
|
|
<TableCell>{renderItemTypeBadge(item.itemType)}</TableCell>
|
|
<TableCell>
|
|
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
|
{item.itemCode}
|
|
</code>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="font-medium truncate max-w-[200px] block">
|
|
{item.itemName}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground hidden md:table-cell">
|
|
{item.specification || '-'}
|
|
</TableCell>
|
|
<TableCell className="hidden md:table-cell">
|
|
<Badge variant="secondary">{item.unit || '-'}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono hidden lg:table-cell">
|
|
{formatPrice(item.purchasePrice)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono hidden lg:table-cell">
|
|
{formatPrice(item.processingCost)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono font-semibold">
|
|
{formatPrice(item.salesPrice)}
|
|
</TableCell>
|
|
<TableCell className="text-right hidden md:table-cell">
|
|
{renderMarginBadge(item.marginRate)}
|
|
</TableCell>
|
|
<TableCell className="hidden md:table-cell">
|
|
{item.effectiveDate
|
|
? new Date(item.effectiveDate).toLocaleDateString('ko-KR')
|
|
: '-'}
|
|
</TableCell>
|
|
<TableCell>{renderStatusBadge(item)}</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{item.status === 'not_registered' ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => { e.stopPropagation(); handleRegister(item); }}
|
|
title="단가 등록"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
|
title="수정"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
{item.currentRevision > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
|
|
title="이력"
|
|
>
|
|
<History className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
};
|
|
|
|
// 모바일 카드 렌더링
|
|
const renderMobileCard = (
|
|
item: PricingListItem,
|
|
index: number,
|
|
globalIndex: number,
|
|
isSelected: boolean,
|
|
onToggle: () => void
|
|
) => {
|
|
return (
|
|
<ListMobileCard
|
|
key={item.id}
|
|
id={item.id}
|
|
title={item.itemName}
|
|
headerBadges={
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
|
{item.itemCode}
|
|
</code>
|
|
{renderItemTypeBadge(item.itemType)}
|
|
</div>
|
|
}
|
|
statusBadge={renderStatusBadge(item)}
|
|
isSelected={isSelected}
|
|
onToggleSelection={onToggle}
|
|
onCardClick={() => item.status !== 'not_registered' ? handleEdit(item) : handleRegister(item)}
|
|
infoGrid={
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
|
{item.specification && (
|
|
<InfoField label="규격" value={item.specification} />
|
|
)}
|
|
{item.unit && (
|
|
<InfoField label="단위" value={item.unit} />
|
|
)}
|
|
<InfoField label="판매단가" value={formatPrice(item.salesPrice)} />
|
|
<InfoField
|
|
label="마진율"
|
|
value={item.marginRate ? `${item.marginRate.toFixed(1)}%` : '-'}
|
|
/>
|
|
</div>
|
|
}
|
|
actions={
|
|
isSelected ? (
|
|
<div className="flex gap-2 flex-wrap">
|
|
{item.status === 'not_registered' ? (
|
|
<Button
|
|
variant="default"
|
|
size="default"
|
|
className="flex-1 min-w-[100px] h-11"
|
|
onClick={(e) => { e.stopPropagation(); handleRegister(item); }}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
등록
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="default"
|
|
size="default"
|
|
className="flex-1 min-w-[100px] h-11"
|
|
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
|
>
|
|
<Edit className="h-4 w-4 mr-2" />
|
|
수정
|
|
</Button>
|
|
{item.currentRevision > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="default"
|
|
className="flex-1 min-w-[100px] h-11"
|
|
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
|
|
>
|
|
<History className="h-4 w-4 mr-2" />
|
|
이력
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : undefined
|
|
}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// 헤더 액션
|
|
const headerActions = (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
// TODO: API 연동 시 품목 마스터 동기화 로직 구현
|
|
console.log('품목 마스터 동기화');
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
품목 마스터 동기화
|
|
</Button>
|
|
);
|
|
|
|
// 페이지네이션
|
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
|
|
|
return (
|
|
<IntegratedListTemplateV2<PricingListItem>
|
|
title="단가 목록"
|
|
description="품목별 매입단가, 판매단가 및 마진을 관리합니다"
|
|
icon={DollarSign}
|
|
headerActions={headerActions}
|
|
stats={stats}
|
|
searchValue={searchTerm}
|
|
onSearchChange={setSearchTerm}
|
|
searchPlaceholder="품목코드, 품목명, 규격 검색..."
|
|
tabs={tabs}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
tableColumns={tableColumns}
|
|
data={paginatedData}
|
|
totalCount={filteredData.length}
|
|
allData={filteredData}
|
|
selectedItems={selectedItems}
|
|
onToggleSelection={toggleSelection}
|
|
onToggleSelectAll={toggleSelectAll}
|
|
getItemId={(item) => item.id}
|
|
renderTableRow={renderTableRow}
|
|
renderMobileCard={renderMobileCard}
|
|
pagination={{
|
|
currentPage,
|
|
totalPages,
|
|
totalItems: filteredData.length,
|
|
itemsPerPage: pageSize,
|
|
onPageChange: setCurrentPage,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default PricingListClient;
|