495 lines
19 KiB
TypeScript
495 lines
19 KiB
TypeScript
'use client';
|
||
|
||
/**
|
||
* 모델 목록 — 가이드레일 / 케이스 / 하단마감재 공용
|
||
*
|
||
* category prop으로 분기:
|
||
* - GUIDERAIL_MODEL: 대분류/인정/형태/모델 필터
|
||
* - SHUTTERBOX_MODEL: 점검구 필터만
|
||
* - BOTTOMBAR_MODEL: 대분류/인정/모델 필터
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useListSearchState } from '@/hooks/useListSearchState';
|
||
import { Layers, Plus } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import {
|
||
IntegratedListTemplateV2,
|
||
type TableColumn,
|
||
type FilterFieldConfig,
|
||
type FilterValues,
|
||
} from '@/components/templates/IntegratedListTemplateV2';
|
||
import { useColumnSettings } from '@/hooks/useColumnSettings';
|
||
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||
import { toast } from 'sonner';
|
||
import { TableRow, TableCell } from '@/components/ui/table';
|
||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||
import { getGuiderailModels, getGuiderailModelFilters } from './actions';
|
||
import type { GuiderailModel, GuiderailModelFilters, ModelCategory } from './types';
|
||
|
||
// --- 카테고리별 설정 ---
|
||
|
||
interface CategoryConfig {
|
||
title: string;
|
||
description: string;
|
||
basePath: string;
|
||
pageId: string;
|
||
hasItemSep: boolean;
|
||
hasModelUA: boolean;
|
||
hasCheckType: boolean;
|
||
checkTypeLabel: string;
|
||
hasModelName: boolean;
|
||
hasFinishingType: boolean;
|
||
dimensionLabel: [string, string];
|
||
hasFrontBottom: boolean;
|
||
hasRailWidth: boolean;
|
||
dimensionPrefix?: string; // 컬럼명 접두어 (예: '박스')
|
||
dimensionBeforeCheckType?: boolean; // true면 치수 컬럼이 점검구 앞에 옴
|
||
}
|
||
|
||
const CATEGORY_CONFIG: Record<ModelCategory, CategoryConfig> = {
|
||
GUIDERAIL_MODEL: {
|
||
title: '절곡품 (가이드레일)',
|
||
description: '가이드레일 모델 관리',
|
||
basePath: '/production/bending/guiderail',
|
||
pageId: 'bending-guiderail',
|
||
hasItemSep: true,
|
||
hasModelUA: true,
|
||
hasCheckType: true,
|
||
checkTypeLabel: '형상',
|
||
hasModelName: true,
|
||
hasFinishingType: true,
|
||
dimensionLabel: ['레일폭', '높이'],
|
||
hasFrontBottom: false,
|
||
hasRailWidth: false,
|
||
},
|
||
SHUTTERBOX_MODEL: {
|
||
title: '케이스 관리',
|
||
description: '케이스(셔터박스) 모델 관리',
|
||
basePath: '/production/bending/shutterbox',
|
||
pageId: 'bending-shutterbox',
|
||
hasItemSep: false,
|
||
hasModelUA: false,
|
||
hasCheckType: true,
|
||
checkTypeLabel: '점검구',
|
||
hasModelName: false,
|
||
hasFinishingType: false,
|
||
dimensionLabel: ['가로', '세로'],
|
||
dimensionPrefix: '박스',
|
||
dimensionBeforeCheckType: true,
|
||
hasFrontBottom: true,
|
||
hasRailWidth: true,
|
||
},
|
||
BOTTOMBAR_MODEL: {
|
||
title: '하단마감재 관리',
|
||
description: '하단마감재 모델 관리',
|
||
basePath: '/production/bending/bottombar',
|
||
pageId: 'bending-bottombar',
|
||
hasItemSep: true,
|
||
hasModelUA: true,
|
||
hasCheckType: false,
|
||
checkTypeLabel: '',
|
||
hasModelName: true,
|
||
hasFinishingType: true,
|
||
dimensionLabel: ['가로', '세로'],
|
||
hasFrontBottom: false,
|
||
hasRailWidth: false,
|
||
},
|
||
};
|
||
|
||
// --- 컬럼 빌더 ---
|
||
|
||
function buildColumns(cfg: CategoryConfig): TableColumn[] {
|
||
const cols: TableColumn[] = [
|
||
{ key: 'no', label: 'NO', className: 'text-center w-[60px]' },
|
||
];
|
||
|
||
if (cfg.hasModelName) cols.push({ key: 'model_name', label: '모델명', className: 'w-[100px]', copyable: true });
|
||
if (cfg.hasItemSep) cols.push({ key: 'item_sep', label: '대분류', className: 'text-center w-[70px]', copyable: true });
|
||
if (cfg.hasModelUA) cols.push({ key: 'model_UA', label: '인정', className: 'text-center w-[60px]', copyable: true });
|
||
const dimLabel = cfg.dimensionPrefix
|
||
? `${cfg.dimensionPrefix}(${cfg.dimensionLabel[0]}×${cfg.dimensionLabel[1]})`
|
||
: `${cfg.dimensionLabel[0]}×${cfg.dimensionLabel[1]}`;
|
||
const dimCol: TableColumn = { key: 'dimensions', label: dimLabel, className: 'w-[100px]', copyable: true };
|
||
const checkCol: TableColumn | null = cfg.hasCheckType ? { key: 'check_type', label: cfg.checkTypeLabel, className: 'w-[90px]', copyable: true } : null;
|
||
|
||
if (cfg.dimensionBeforeCheckType) {
|
||
cols.push(dimCol);
|
||
if (checkCol) cols.push(checkCol);
|
||
} else {
|
||
if (checkCol) cols.push(checkCol);
|
||
cols.push(dimCol);
|
||
}
|
||
|
||
if (cfg.hasFrontBottom) cols.push({ key: 'front_bottom', label: '전면밑', className: 'text-center w-[60px]', copyable: true });
|
||
if (cfg.hasRailWidth) cols.push({ key: 'rail_width_col', label: '레일폭', className: 'text-center w-[60px]', copyable: true });
|
||
if (cfg.hasFinishingType) cols.push({ key: 'finishing_type', label: '마감', className: 'w-[80px]', copyable: true });
|
||
|
||
cols.push(
|
||
{ key: 'image', label: '이미지', className: 'text-center w-[60px]' },
|
||
{ key: 'component_count', label: '부품수', className: 'text-center w-[60px]', copyable: true },
|
||
{ key: 'material_summary', label: '소요자재량', className: 'w-[200px]', copyable: true },
|
||
{ key: 'search_keyword', label: '검색어', className: 'w-[80px]', copyable: true },
|
||
{ key: 'modified_by', label: '수정자', className: 'w-[80px]', copyable: true },
|
||
{ key: 'work_order', label: '작업지시서', className: 'text-center w-[80px]' },
|
||
);
|
||
|
||
return cols;
|
||
}
|
||
|
||
// --- 컴포넌트 ---
|
||
|
||
interface BendingModelListProps {
|
||
category: ModelCategory;
|
||
}
|
||
|
||
const PAGE_SIZE = 30;
|
||
|
||
export function BendingModelList({ category }: BendingModelListProps) {
|
||
const router = useRouter();
|
||
const cfg = CATEGORY_CONFIG[category];
|
||
|
||
const [items, setItems] = useState<GuiderailModel[]>([]);
|
||
const [filterOptions, setFilterOptions] = useState<GuiderailModelFilters | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
// --- 검색 상태 보존 ---
|
||
const searchState = useListSearchState({
|
||
fields: [
|
||
{ key: 'search', defaultValue: '' },
|
||
{ key: 'item_sep', defaultValue: 'all' },
|
||
{ key: 'model_UA', defaultValue: 'all' },
|
||
{ key: 'check_type', defaultValue: 'all' },
|
||
{ key: 'model_name', defaultValue: 'all' },
|
||
],
|
||
});
|
||
|
||
const searchTerm = searchState.getValue('search');
|
||
const setSearchTerm = useCallback((v: string) => searchState.setValue('search', v), [searchState]);
|
||
const currentPage = searchState.currentPage;
|
||
const setCurrentPage = searchState.setPage;
|
||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||
|
||
const filterSep = searchState.getValue('item_sep');
|
||
const filterUA = searchState.getValue('model_UA');
|
||
const filterCheckType = searchState.getValue('check_type');
|
||
const filterModelName = searchState.getValue('model_name');
|
||
const setFilterSep = useCallback((v: string) => searchState.setValue('item_sep', v), [searchState]);
|
||
const setFilterUA = useCallback((v: string) => searchState.setValue('model_UA', v), [searchState]);
|
||
const setFilterCheckType = useCallback((v: string) => searchState.setValue('check_type', v), [searchState]);
|
||
const setFilterModelName = useCallback((v: string) => searchState.setValue('model_name', v), [searchState]);
|
||
|
||
const tableColumns = useMemo(() => buildColumns(cfg), [cfg]);
|
||
|
||
const {
|
||
visibleColumns, allColumnsWithVisibility, columnWidths,
|
||
setColumnWidth, toggleColumnVisibility, resetSettings, hasHiddenColumns,
|
||
} = useColumnSettings({
|
||
pageId: cfg.pageId,
|
||
columns: tableColumns,
|
||
alwaysVisibleKeys: ['no', 'dimensions'],
|
||
});
|
||
|
||
// 데이터 로드
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const [listResult, filtersResult] = await Promise.all([
|
||
getGuiderailModels({ item_category: category, perPage: 200 }),
|
||
getGuiderailModelFilters(),
|
||
]);
|
||
|
||
if (listResult.success && listResult.data) {
|
||
setItems(listResult.data as unknown as GuiderailModel[]);
|
||
} else {
|
||
toast.error(listResult.error || '목록을 불러오는데 실패했습니다.');
|
||
}
|
||
|
||
if (filtersResult.success && filtersResult.data) {
|
||
setFilterOptions(filtersResult.data as GuiderailModelFilters);
|
||
}
|
||
} catch {
|
||
toast.error('데이터를 불러오는 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [category]);
|
||
|
||
useEffect(() => { loadData(); }, [loadData]);
|
||
|
||
// 클라이언트 필터링
|
||
const filteredItems = useMemo(() => {
|
||
let result = items;
|
||
if (cfg.hasItemSep && filterSep !== 'all') result = result.filter((i) => i.item_sep === filterSep);
|
||
if (cfg.hasModelUA && filterUA !== 'all') result = result.filter((i) => i.model_UA === filterUA);
|
||
if (cfg.hasCheckType && filterCheckType !== 'all') {
|
||
result = result.filter((i) => (i.exit_direction || i.check_type) === filterCheckType);
|
||
}
|
||
if (cfg.hasModelName && filterModelName !== 'all') result = result.filter((i) => i.model_name === filterModelName);
|
||
|
||
if (searchTerm) {
|
||
const q = searchTerm.toLowerCase();
|
||
result = result.filter(
|
||
(i) =>
|
||
(i.model_name || '').toLowerCase().includes(q) ||
|
||
(i.code || '').toLowerCase().includes(q) ||
|
||
(i.name || '').toLowerCase().includes(q) ||
|
||
(i.search_keyword || '').toLowerCase().includes(q)
|
||
);
|
||
}
|
||
return result;
|
||
}, [items, filterSep, filterUA, filterCheckType, filterModelName, searchTerm, cfg]);
|
||
|
||
const totalPages = Math.ceil(filteredItems.length / PAGE_SIZE);
|
||
const paginatedItems = filteredItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||
|
||
// 필터 설정
|
||
const filterConfig: FilterFieldConfig[] = useMemo(() => {
|
||
if (!filterOptions) return [];
|
||
const configs: FilterFieldConfig[] = [];
|
||
|
||
if (cfg.hasItemSep) {
|
||
configs.push({
|
||
key: 'item_sep', label: '대분류', type: 'single' as const,
|
||
options: (filterOptions.item_sep || []).map((v) => ({ value: v, label: v })),
|
||
allOptionLabel: '전체',
|
||
});
|
||
}
|
||
if (cfg.hasModelUA) {
|
||
configs.push({
|
||
key: 'model_UA', label: '인정', type: 'single' as const,
|
||
options: (filterOptions.model_UA || []).map((v) => ({ value: v, label: v })),
|
||
allOptionLabel: '전체',
|
||
});
|
||
}
|
||
if (cfg.hasCheckType) {
|
||
// 케이스: exit_direction 기반 하드코딩 / 가이드레일: API check_type 사용
|
||
const checkTypeOptions = category === 'SHUTTERBOX_MODEL'
|
||
? [{ value: '양면 점검구', label: '양면' }, { value: '밑면 점검구', label: '밑면' }, { value: '후면 점검구', label: '후면' }]
|
||
: (filterOptions.check_type || []).map((v) => ({ value: v, label: v }));
|
||
configs.push({
|
||
key: 'check_type', label: cfg.checkTypeLabel, type: 'single' as const,
|
||
options: checkTypeOptions,
|
||
allOptionLabel: '전체',
|
||
});
|
||
}
|
||
if (cfg.hasModelName) {
|
||
configs.push({
|
||
key: 'model_name', label: '모델', type: 'single' as const,
|
||
options: (filterOptions.model_name || []).map((v) => ({ value: v, label: v })),
|
||
allOptionLabel: '전체(모델)',
|
||
});
|
||
}
|
||
return configs;
|
||
}, [filterOptions, cfg]);
|
||
|
||
const filterValues: FilterValues = useMemo(() => ({
|
||
item_sep: filterSep,
|
||
model_UA: filterUA,
|
||
check_type: filterCheckType,
|
||
model_name: filterModelName,
|
||
}), [filterSep, filterUA, filterCheckType, filterModelName]);
|
||
|
||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||
const v = value as string;
|
||
if (key === 'item_sep') setFilterSep(v);
|
||
if (key === 'model_UA') setFilterUA(v);
|
||
if (key === 'check_type') setFilterCheckType(v);
|
||
if (key === 'model_name') setFilterModelName(v);
|
||
}, [setFilterSep, setFilterUA, setFilterCheckType, setFilterModelName]);
|
||
|
||
const handleFilterReset = useCallback(() => {
|
||
searchState.resetAll();
|
||
}, [searchState]);
|
||
|
||
// 선택
|
||
const toggleSelection = useCallback((id: string) => {
|
||
setSelectedItems((prev) => {
|
||
const next = new Set(prev);
|
||
next.has(id) ? next.delete(id) : next.add(id);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const toggleSelectAll = useCallback(() => {
|
||
setSelectedItems((prev) =>
|
||
prev.size === paginatedItems.length
|
||
? new Set()
|
||
: new Set(paginatedItems.map((i) => String(i.id)))
|
||
);
|
||
}, [paginatedItems]);
|
||
|
||
// 소요자재량 포맷
|
||
const formatMaterialSummary = (summary: Record<string, number> | null | undefined) => {
|
||
if (!summary || Object.keys(summary).length === 0) return '-';
|
||
return Object.entries(summary)
|
||
.map(([mat, val]) => `${mat}: ${val}`)
|
||
.join(' | ');
|
||
};
|
||
|
||
// 테이블 행
|
||
const renderTableRow = useCallback(
|
||
(item: GuiderailModel, _index: number, globalIndex: number) => (
|
||
<TableRow
|
||
key={item.id}
|
||
className="cursor-pointer hover:bg-muted/50"
|
||
onClick={() => router.push(`${cfg.basePath}/${item.id}?mode=edit`)}
|
||
>
|
||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||
{cfg.hasModelName && (
|
||
<TableCell className="font-semibold text-blue-600">{item.model_name || '-'}</TableCell>
|
||
)}
|
||
{cfg.hasItemSep && (
|
||
<TableCell className="text-center">
|
||
<Badge variant="outline" className={`text-xs ${item.item_sep === '스크린' ? 'bg-blue-50 text-blue-700 border-blue-200' : 'bg-orange-50 text-orange-700 border-orange-200'}`}>
|
||
{item.item_sep}
|
||
</Badge>
|
||
</TableCell>
|
||
)}
|
||
{cfg.hasModelUA && (
|
||
<TableCell className="text-center">
|
||
<Badge variant="outline" className={`text-xs ${item.model_UA === '인정' ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
|
||
{item.model_UA}
|
||
</Badge>
|
||
</TableCell>
|
||
)}
|
||
{cfg.dimensionBeforeCheckType && (
|
||
<TableCell>
|
||
{category === 'SHUTTERBOX_MODEL'
|
||
? (item.box_width && item.box_height ? `${item.box_width}×${item.box_height}` : '-')
|
||
: category === 'BOTTOMBAR_MODEL'
|
||
? (item.bar_width && item.bar_height ? `${item.bar_width}×${item.bar_height}` : '-')
|
||
: (item.rail_width && item.rail_length ? `${item.rail_width}×${item.rail_length}` : '-')
|
||
}
|
||
</TableCell>
|
||
)}
|
||
{cfg.hasCheckType && (
|
||
<TableCell>{item.exit_direction || item.check_type || '-'}</TableCell>
|
||
)}
|
||
{!cfg.dimensionBeforeCheckType && (
|
||
<TableCell>
|
||
{category === 'SHUTTERBOX_MODEL'
|
||
? (item.box_width && item.box_height ? `${item.box_width}×${item.box_height}` : '-')
|
||
: category === 'BOTTOMBAR_MODEL'
|
||
? (item.bar_width && item.bar_height ? `${item.bar_width}×${item.bar_height}` : '-')
|
||
: (item.rail_width && item.rail_length ? `${item.rail_width}×${item.rail_length}` : '-')
|
||
}
|
||
</TableCell>
|
||
)}
|
||
{cfg.hasFrontBottom && <TableCell className="text-center">{item.front_bottom_width ?? '-'}</TableCell>}
|
||
{cfg.hasRailWidth && <TableCell className="text-center">{item.rail_width || '-'}</TableCell>}
|
||
{cfg.hasFinishingType && <TableCell>{item.finishing_type || '-'}</TableCell>}
|
||
<TableCell className="text-center">
|
||
{item.image_url ? (
|
||
<TooltipProvider delayDuration={200}>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<img src={item.image_url} alt="" className="w-8 h-8 object-contain mx-auto rounded cursor-pointer" />
|
||
</TooltipTrigger>
|
||
<TooltipContent side="left" className="p-1 max-w-none">
|
||
<img src={item.image_url} alt="" className="w-52 h-52 object-contain" />
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
) : '-'}
|
||
</TableCell>
|
||
<TableCell className="text-center">{item.component_count || '-'}</TableCell>
|
||
<TableCell className="text-xs">{formatMaterialSummary(item.material_summary)}</TableCell>
|
||
<TableCell className="text-xs">{item.search_keyword || '-'}</TableCell>
|
||
<TableCell>{item.modified_by || '-'}</TableCell>
|
||
<TableCell className="text-center">
|
||
<Button variant="outline" size="sm" className="h-6 text-xs">보기</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
),
|
||
[router, cfg]
|
||
);
|
||
|
||
// 모바일 카드
|
||
const renderMobileCard = useCallback(
|
||
(item: GuiderailModel, _index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => (
|
||
<ListMobileCard
|
||
key={item.id}
|
||
id={String(item.id)}
|
||
isSelected={isSelected}
|
||
onToggleSelection={onToggle}
|
||
onCardClick={() => router.push(`${cfg.basePath}/${item.id}?mode=edit`)}
|
||
headerBadges={
|
||
<Badge variant="outline" className="bg-gray-100 text-gray-700 font-mono text-xs">{globalIndex}</Badge>
|
||
}
|
||
title={item.model_name || item.name}
|
||
statusBadge={cfg.hasItemSep ? (
|
||
<Badge variant="outline" className={`text-xs ${item.item_sep === '스크린' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'}`}>
|
||
{item.item_sep}
|
||
</Badge>
|
||
) : undefined}
|
||
infoGrid={
|
||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||
{cfg.hasCheckType && <InfoField label={cfg.checkTypeLabel} value={item.check_type || '-'} />}
|
||
<InfoField label="치수" value={
|
||
category === 'SHUTTERBOX_MODEL' ? (item.box_width && item.box_height ? `${item.box_width}×${item.box_height}` : '-')
|
||
: category === 'BOTTOMBAR_MODEL' ? (item.bar_width && item.bar_height ? `${item.bar_width}×${item.bar_height}` : '-')
|
||
: (item.rail_width && item.rail_length ? `${item.rail_width}×${item.rail_length}` : '-')
|
||
} />
|
||
{cfg.hasFinishingType && <InfoField label="마감" value={item.finishing_type || '-'} />}
|
||
<InfoField label="부품수" value={String(item.component_count || '-')} />
|
||
</div>
|
||
}
|
||
/>
|
||
),
|
||
[router, cfg]
|
||
);
|
||
|
||
return (
|
||
<IntegratedListTemplateV2<GuiderailModel>
|
||
title={cfg.title}
|
||
description={cfg.description}
|
||
icon={Layers}
|
||
createButton={{
|
||
label: '신규 등록',
|
||
onClick: () => router.push(`${cfg.basePath}?mode=new`),
|
||
}}
|
||
searchValue={searchTerm}
|
||
onSearchChange={setSearchTerm}
|
||
searchPlaceholder="검색..."
|
||
filterConfig={filterConfig}
|
||
filterValues={filterValues}
|
||
onFilterChange={handleFilterChange}
|
||
onFilterReset={handleFilterReset}
|
||
filterTitle={`${cfg.title} 필터`}
|
||
tableColumns={visibleColumns}
|
||
columnSettings={{
|
||
columnWidths,
|
||
onColumnResize: setColumnWidth,
|
||
settingsPopover: (
|
||
<ColumnSettingsPopover
|
||
columns={allColumnsWithVisibility}
|
||
onToggle={toggleColumnVisibility}
|
||
onReset={resetSettings}
|
||
hasHiddenColumns={hasHiddenColumns}
|
||
/>
|
||
),
|
||
}}
|
||
data={paginatedItems}
|
||
selectedItems={selectedItems}
|
||
onToggleSelection={toggleSelection}
|
||
onToggleSelectAll={toggleSelectAll}
|
||
showCheckbox={false}
|
||
getItemId={(item) => String(item.id)}
|
||
renderTableRow={renderTableRow}
|
||
renderMobileCard={renderMobileCard}
|
||
pagination={{
|
||
currentPage,
|
||
totalPages,
|
||
totalItems: filteredItems.length,
|
||
itemsPerPage: PAGE_SIZE,
|
||
onPageChange: setCurrentPage,
|
||
}}
|
||
isLoading={isLoading}
|
||
/>
|
||
);
|
||
}
|