Files
sam-react-prod/src/components/production/bending/BendingModelList.tsx

495 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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}
/>
);
}