feat: [production] 절곡 생산관리 페이지 신규 추가 (셔터박스, 가이드레일, 하단마감)

This commit is contained in:
유병철
2026-03-21 14:08:08 +09:00
parent effe5a7196
commit 728c9c7a29
18 changed files with 3629 additions and 0 deletions

View File

@@ -0,0 +1,625 @@
'use client';
/**
* 기초관리 등록/수정 폼 — 별도 페이지 (2열 레이아웃)
*
* 좌측: 기본 정보 (4열 그리드) + 절곡 입력 테이블
* 우측: 형상 이미지 + 비고 + 저장 버튼
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { X, Save, Trash2, Loader2, Pencil, History } from 'lucide-react';
import { DrawingCanvas } from '@/components/items/DrawingCanvas';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useMenuStore } from '@/stores/menuStore';
import { BendingTable } from './BendingTable';
import {
getBendingItem,
getBendingItemFilters,
createBendingItem,
updateBendingItem,
deleteBendingItem,
} from './actions';
import {
type BendingItem,
type BendingItemFilters,
type BendingData,
type BendingItemFormData,
recalculateSums,
createEmptyBendingData,
getWidthSum,
getBendCount,
} from './types';
interface BendingBaseFormProps {
id?: string;
mode: 'new' | 'edit' | 'view';
}
const INITIAL_FORM: BendingItemFormData = {
code: '',
name: '',
item_name: '',
item_sep: '',
item_bending: '',
material: '',
item_spec: '120*70',
model_name: '',
model_UA: '',
registration_date: new Date().toISOString().split('T')[0],
search_keyword: '',
memo: '',
exit_direction: '',
front_bottom_width: '',
rail_width: '',
box_width: '',
box_height: '',
bendingData: Array.from({ length: 7 }, (_, i) => createEmptyBendingData(i + 1)),
};
export function BendingBaseForm({ id, mode }: BendingBaseFormProps) {
const router = useRouter();
const isNew = mode === 'new';
const isView = mode === 'view';
const isEdit = mode === 'edit';
const [formData, setFormData] = useState<BendingItemFormData>(INITIAL_FORM);
const [bendingData, setBendingData] = useState<BendingData[]>(INITIAL_FORM.bendingData);
const [filters, setFilters] = useState<BendingItemFilters | null>(null);
const [isLoading, setIsLoading] = useState(!isNew);
const [isSaving, setIsSaving] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDrawingOpen, setIsDrawingOpen] = useState(false);
const [drawingImage, setDrawingImage] = useState<string | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [originalItem, setOriginalItem] = useState<BendingItem | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 파일 선택 → data URL로 미리보기
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
setDrawingImage(ev.target?.result as string);
};
reader.readAsDataURL(file);
e.target.value = '';
}, []);
// 로그인 사용자 이름 로드
const [authorName, setAuthorName] = useState('');
useEffect(() => {
try {
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
if (user?.name) setAuthorName(user.name);
}
} catch { /* ignore */ }
}, []);
// 데이터 로드
useEffect(() => {
getBendingItemFilters().then((r) => {
if (r.success && r.data) setFilters(r.data as BendingItemFilters);
});
// 다른이름저장: sessionStorage에서 데이터 복사 → 새 등록 폼
if (isNew) {
try {
const saved = sessionStorage.getItem('bending-save-as');
if (saved) {
const data = JSON.parse(saved);
sessionStorage.removeItem('bending-save-as');
setFormData((prev) => ({
...prev,
name: data.name || '',
item_name: data.name || '',
material: data.material || '',
}));
if (data.bendingData?.length) {
setBendingData(recalculateSums(data.bendingData));
}
}
} catch { /* ignore */ }
}
if (!isNew && id) {
getBendingItem(id).then((r) => {
if (r.success && r.data) {
const item = r.data as BendingItem;
setOriginalItem(item);
setFormData({
code: item.code || '',
name: item.name || '',
item_name: item.item_name || '',
item_sep: item.item_sep || '',
item_bending: item.item_bending || '',
material: item.material || '',
item_spec: item.item_spec || '',
model_name: item.model_name || '',
model_UA: item.model_UA || '',
registration_date: item.registration_date || '',
search_keyword: item.search_keyword || '',
memo: item.memo || '',
exit_direction: item.exit_direction || '',
front_bottom_width: item.front_bottom_width ? String(item.front_bottom_width) : '',
rail_width: item.rail_width ? String(item.rail_width) : '',
box_width: item.box_width ? String(item.box_width) : '',
box_height: item.box_height ? String(item.box_height) : '',
bendingData: item.bendingData || [],
});
if (item.author) {
setAuthorName(item.author);
}
if (item.image_url) {
setDrawingImage(item.image_url);
}
setBendingData(
item.bendingData?.length
? recalculateSums(item.bendingData)
: Array.from({ length: 7 }, (_, i) => createEmptyBendingData(i + 1))
);
} else {
toast.error('해당 기초자료가 존재하지 않습니다. (삭제되었거나 연결이 끊어졌을 수 있습니다)');
router.back();
}
setIsLoading(false);
});
}
}, [id, isNew, router]);
const handleChange = useCallback((field: keyof BendingItemFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
const handleBendingDataChange = useCallback((data: BendingData[]) => {
setBendingData(data);
}, []);
// 저장
const handleSubmit = useCallback(async () => {
if (!formData.code || !formData.name || !formData.item_name || !formData.item_sep) {
toast.error('필수 항목을 입력해주세요. (코드, 이름, 품명, 대분류)');
return;
}
if (!formData.item_bending || !formData.material) {
toast.error('분류와 재질을 입력해주세요.');
return;
}
setIsSaving(true);
try {
const payload = {
...formData,
author: authorName,
bendingData: bendingData.filter((d) => d.input > 0),
width_sum: getWidthSum(bendingData),
bend_count: getBendCount(bendingData),
};
const result = isNew
? await createBendingItem(payload)
: await updateBendingItem(id!, payload);
if (result.success) {
toast.success(isNew ? '등록되었습니다.' : '저장되었습니다.');
router.push('/production/bending');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} finally {
setIsSaving(false);
}
}, [formData, bendingData, isNew, id, router]);
// 삭제
const handleDelete = useCallback(async () => {
if (!id) return;
setIsDeleting(true);
try {
const result = await deleteBendingItem(id);
if (result.success) {
toast.success('삭제되었습니다.');
router.push('/production/bending');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} finally {
setIsDeleting(false);
setIsDeleteOpen(false);
}
}, [id, router]);
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
if (isLoading) {
return (
<div className="flex items-center justify-center h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
const disabled = isView;
const isCase = formData.item_bending === '케이스';
return (
<div className="space-y-4 pb-24">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">
{isNew ? '등록' : '수정'}
{!isNew && formData.code && (
<span className="ml-2 text-muted-foreground font-normal text-base">{formData.code}</span>
)}
</h1>
<Button variant="link" className="text-muted-foreground" onClick={() => router.push('/production/bending')}>
</Button>
</div>
{/* 2열 레이아웃 */}
<div className="flex gap-4">
{/* 좌측: 기본 정보 + 절곡 테이블 */}
<div className="flex-1 space-y-4">
<Card>
<CardContent className="pt-6">
<h3 className="text-sm font-semibold mb-4"> </h3>
{/* 1행 */}
<div className="grid grid-cols-4 gap-3 mb-3">
<div className="space-y-1">
<Label className="text-xs"> <span className="text-red-500">*</span></Label>
<Input value={formData.code} onChange={(e) => handleChange('code', e.target.value)} disabled={disabled || isEdit} />
</div>
<div className="space-y-1">
<Label className="text-xs"> <span className="text-red-500">*</span></Label>
<Input value={formData.name} onChange={(e) => handleChange('name', e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"> <span className="text-red-500">*</span></Label>
<Input value={formData.item_name} onChange={(e) => handleChange('item_name', e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"> <span className="text-red-500">*</span></Label>
<Select
key={`sep-${formData.item_sep}`}
value={formData.item_sep}
onValueChange={(v) => handleChange('item_sep', v)}
disabled={disabled}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="스크린"></SelectItem>
<SelectItem value="철재"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 2행 */}
<div className="grid grid-cols-4 gap-3 mb-3">
<div className="space-y-1">
<Label className="text-xs"> <span className="text-red-500">*</span></Label>
<Input
value={formData.item_bending}
onChange={(e) => handleChange('item_bending', e.target.value)}
disabled={disabled}
list="bending-list"
/>
{filters && (
<datalist id="bending-list">
{filters.item_bending.map((v) => <option key={v} value={v} />)}
</datalist>
)}
</div>
<div className="space-y-1">
<Label className="text-xs"> <span className="text-red-500">*</span></Label>
<Input
value={formData.material}
onChange={(e) => handleChange('material', e.target.value)}
disabled={disabled}
list="material-list"
/>
{filters && (
<datalist id="material-list">
{filters.material.map((v) => <option key={v} value={v} />)}
</datalist>
)}
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input value={formData.item_spec} onChange={(e) => handleChange('item_spec', e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={formData.model_name}
onChange={(e) => handleChange('model_name', e.target.value)}
disabled={disabled}
list="model-list"
/>
{filters && (
<datalist id="model-list">
{filters.model_name.map((v) => <option key={v} value={v} />)}
</datalist>
)}
</div>
</div>
{/* 3행 */}
<div className="grid grid-cols-4 gap-3 mb-3">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
key={`ua-${formData.model_UA}`}
value={formData.model_UA}
onValueChange={(v) => handleChange('model_UA', v)}
disabled={disabled}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="인정"></SelectItem>
<SelectItem value="비인정"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<DatePicker
value={formData.registration_date}
onChange={(v) => handleChange('registration_date', v)}
disabled={disabled}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input value={authorName} onChange={(e) => setAuthorName(e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input value={formData.search_keyword} onChange={(e) => handleChange('search_keyword', e.target.value)} disabled={disabled} />
</div>
</div>
</CardContent>
</Card>
{/* 케이스 전용 */}
{isCase && (
<Card>
<CardContent className="pt-6">
<h3 className="text-sm font-semibold mb-4"> </h3>
<div className="grid grid-cols-5 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
key={`exit-${formData.exit_direction}`}
value={formData.exit_direction}
onValueChange={(v) => handleChange('exit_direction', v)}
disabled={disabled}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="양면"></SelectItem>
<SelectItem value="후면"></SelectItem>
<SelectItem value="밑면"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> (mm)</Label>
<Input type="number" value={formData.front_bottom_width} onChange={(e) => handleChange('front_bottom_width', e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"> (mm)</Label>
<Input type="number" value={formData.rail_width} onChange={(e) => handleChange('rail_width', e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"> (mm)</Label>
<Input type="number" value={formData.box_width} onChange={(e) => handleChange('box_width', e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"> (mm)</Label>
<Input type="number" value={formData.box_height} onChange={(e) => handleChange('box_height', e.target.value)} disabled={disabled} />
</div>
</div>
</CardContent>
</Card>
)}
{/* 절곡 입력 테이블 */}
<Card>
<CardContent className="pt-6">
<BendingTable
data={bendingData}
onChange={handleBendingDataChange}
readOnly={disabled}
showActions={!disabled}
showSummary
/>
</CardContent>
</Card>
</div>
{/* 우측: 이미지 + 비고 + 저장 */}
<div className="w-[280px] space-y-4">
{/* 형상 이미지 */}
<Card>
<CardContent className="pt-6">
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="w-full aspect-square bg-muted/30 border-2 border-dashed rounded-lg flex items-center justify-center mb-3 overflow-hidden">
{drawingImage ? (
<img src={drawingImage} alt="형상 이미지" className="w-full h-full object-contain" />
) : (
<span className="text-sm text-muted-foreground"> </span>
)}
</div>
{!disabled && (
<div className="space-y-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => fileInputRef.current?.click()}>
</Button>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => setIsDrawingOpen(true)}
>
<Pencil className="h-3 w-3 mr-1" />
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<p className="text-xs text-muted-foreground">Ctrl+V로 </p>
</div>
)}
</CardContent>
</Card>
{/* 비고 */}
<Card>
<CardContent className="pt-6">
<h3 className="text-sm font-semibold mb-3"></h3>
<Textarea
value={formData.memo}
onChange={(e) => handleChange('memo', e.target.value)}
disabled={disabled}
rows={5}
placeholder="비고를 입력하세요"
/>
</CardContent>
</Card>
</div>
</div>
{/* 하단 Sticky 액션 바 */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`}>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={() => router.push('/production/bending')} size="sm" className="md:size-default">
<X className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex items-center gap-1 md:gap-2">
{isEdit && (
<>
<Button variant="outline" size="sm" className="md:size-default" onClick={() => setIsHistoryOpen(true)}>
<History className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
onClick={() => setIsDeleteOpen(true)}
>
<Trash2 className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
</>
)}
{!disabled && (
<Button onClick={handleSubmit} disabled={isSaving} size="sm" className="md:size-default">
{isSaving ? <Loader2 className="w-4 h-4 md:mr-2 animate-spin" /> : <Save className="w-4 h-4 md:mr-2" />}
<span className="hidden md:inline"></span>
</Button>
)}
</div>
</div>
</div>
{/* 수정 이력 */}
<Dialog open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{originalItem?.code || formData.code}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span>{originalItem?.registration_date || formData.registration_date || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span>{originalItem?.author || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span>{originalItem?.modified_by || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"> </span>
<span className="text-blue-600 font-medium">{authorName || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span>{originalItem?.created_at || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>{originalItem?.updated_at || '-'}</span>
</div>
</div>
</DialogContent>
</Dialog>
{/* 그리기 도구 */}
<DrawingCanvas
open={isDrawingOpen}
onOpenChange={setIsDrawingOpen}
onSave={(imageData) => {
setDrawingImage(imageData);
setIsDrawingOpen(false);
}}
initialImage={drawingImage || undefined}
title="절곡 형상 그리기"
description="절곡 부품의 형상을 그리거나 편집합니다."
/>
{/* 삭제 확인 */}
<DeleteConfirmDialog
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
onConfirm={handleDelete}
loading={isDeleting}
title="기초자료 삭제"
description="해당 기초자료를 삭제하시겠습니까?"
/>
</div>
);
}

View File

@@ -0,0 +1,410 @@
'use client';
/**
* 기초관리 (절곡 바라시 기초자료) — 목록 페이지
*
* API: GET /api/v1/bending-items (266건)
* 패턴: IntegratedListTemplateV2 + 클라이언트 필터링
*/
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 { Checkbox } from '@/components/ui/checkbox';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { getBendingItems, getBendingItemFilters, deleteBendingItem } from './actions';
import type { BendingItem, BendingItemFilters } from './types';
// --- 뱃지 ---
function SepBadge({ value }: { value: string }) {
if (value === '스크린')
return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 text-xs">{value}</Badge>;
if (value === '철재')
return <Badge variant="outline" className="bg-orange-50 text-orange-700 border-orange-200 text-xs">{value}</Badge>;
return <span className="text-xs">{value || '-'}</span>;
}
function UABadge({ value }: { value: string }) {
if (value === '인정')
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs">{value}</Badge>;
if (value === '비인정')
return <Badge variant="outline" className="bg-gray-100 text-gray-600 border-gray-300 text-xs">{value}</Badge>;
return <span className="text-xs">{value || '-'}</span>;
}
// --- 컬럼 ---
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: 'NO', className: 'text-center w-[60px]' },
{ key: 'code', label: '코드', className: 'w-[130px]', copyable: true },
{ key: 'item_sep', label: '대분류', className: 'text-center w-[70px]', copyable: true },
{ key: 'model_UA', label: '인정', className: 'text-center w-[60px]', copyable: true },
{ key: 'item_bending', label: '분류', className: 'w-[100px]', copyable: true },
{ key: 'item_name', label: '품명', className: 'w-[100px]', copyable: true },
{ key: 'item_spec', label: '규격', className: 'w-[80px]', copyable: true },
{ key: 'material', label: '재질', className: 'w-[100px]', copyable: true },
{ key: 'image_url', label: '이미지', className: 'text-center w-[80px]' },
{ key: 'model_name', label: '모델', className: 'w-[80px]', copyable: true },
{ key: 'width_sum', label: '폭합', className: 'text-right w-[60px]', copyable: true },
{ key: 'bend_count', label: '절곡수', className: 'text-center w-[60px]', copyable: true },
{ key: 'created_at', label: '등록일', className: 'w-[100px]', copyable: true },
{ key: 'modified_by', label: '수정자', className: 'w-[80px]', copyable: true },
];
const PAGE_SIZE = 30;
export function BendingBaseList() {
const router = useRouter();
// --- 데이터 ---
const [items, setItems] = useState<BendingItem[]>([]);
const [filters, setFilters] = useState<BendingItemFilters | 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: 'item_bending', defaultValue: 'all' },
{ key: 'material', 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 filterBending = searchState.getValue('item_bending');
const filterMaterial = searchState.getValue('material');
const setFilterSep = useCallback((v: string) => searchState.setValue('item_sep', v), [searchState]);
const setFilterUA = useCallback((v: string) => searchState.setValue('model_UA', v), [searchState]);
const setFilterBending = useCallback((v: string) => searchState.setValue('item_bending', v), [searchState]);
const setFilterMaterial = useCallback((v: string) => searchState.setValue('material', v), [searchState]);
// --- 삭제 ---
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// --- 컬럼 설정 ---
const {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: 'bending-base',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'code', 'item_name'],
});
// --- 데이터 로드 ---
const loadData = useCallback(async () => {
try {
setIsLoading(true);
const [itemsResult, filtersResult] = await Promise.all([
getBendingItems({ perPage: 500 }),
getBendingItemFilters(),
]);
if (itemsResult.success && itemsResult.data) {
setItems(itemsResult.data as unknown as BendingItem[]);
} else {
toast.error(itemsResult.error || '기초관리 목록을 불러오는데 실패했습니다.');
}
if (filtersResult.success && filtersResult.data) {
setFilters(filtersResult.data as BendingItemFilters);
}
} catch {
toast.error('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
// --- 클라이언트 필터링 ---
const filteredItems = useMemo(() => {
let result = items;
// 필터
if (filterSep !== 'all') result = result.filter((i) => i.item_sep === filterSep);
if (filterUA !== 'all') result = result.filter((i) => i.model_UA === filterUA);
if (filterBending !== 'all') result = result.filter((i) => i.item_bending === filterBending);
if (filterMaterial !== 'all') result = result.filter((i) => i.material === filterMaterial);
// 검색
if (searchTerm) {
const q = searchTerm.toLowerCase();
result = result.filter(
(i) =>
(i.code || '').toLowerCase().includes(q) ||
(i.name || '').toLowerCase().includes(q) ||
(i.item_name || '').toLowerCase().includes(q) ||
(i.item_spec || '').toLowerCase().includes(q) ||
(i.search_keyword || '').toLowerCase().includes(q) ||
(i.item_bending || '').toLowerCase().includes(q)
);
}
return result;
}, [items, filterSep, filterUA, filterBending, filterMaterial, searchTerm]);
// --- 페이지네이션 ---
const totalPages = Math.ceil(filteredItems.length / PAGE_SIZE);
const paginatedItems = filteredItems.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
);
// --- 필터 설정 ---
const filterConfig: FilterFieldConfig[] = useMemo(() => {
if (!filters) return [];
return [
{
key: 'item_sep', label: '대분류', type: 'single' as const,
options: (filters.item_sep || []).map((v) => ({ value: v, label: v })),
allOptionLabel: '전체',
},
{
key: 'model_UA', label: '인정', type: 'single' as const,
options: (filters.model_UA || []).map((v) => ({ value: v, label: v })),
allOptionLabel: '전체',
},
{
key: 'item_bending', label: '분류', type: 'single' as const,
options: (filters.item_bending || []).map((v) => ({ value: v, label: v })),
allOptionLabel: '전체(분류)',
},
{
key: 'material', label: '재질', type: 'single' as const,
options: (filters.material || []).map((v) => ({ value: v, label: v })),
allOptionLabel: '전체(재질)',
},
];
}, [filters]);
const filterValues: FilterValues = useMemo(() => ({
item_sep: filterSep,
model_UA: filterUA,
item_bending: filterBending,
material: filterMaterial,
}), [filterSep, filterUA, filterBending, filterMaterial]);
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 === 'item_bending') setFilterBending(v);
if (key === 'material') setFilterMaterial(v);
}, [setFilterSep, setFilterUA, setFilterBending, setFilterMaterial]);
const handleFilterReset = useCallback(() => {
searchState.resetAll();
}, [searchState]);
// --- 삭제 ---
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
const result = await deleteBendingItem(deleteTargetId);
if (result.success) {
toast.success('삭제되었습니다.');
loadData();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} finally {
setIsDeleting(false);
setIsDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId, loadData]);
// --- 선택 ---
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 renderTableRow = useCallback(
(item: BendingItem, _index: number, globalIndex: number) => {
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/production/bending/${item.id}?mode=edit`)}
>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell className="font-mono text-xs">{item.code}</TableCell>
<TableCell className="text-center"><SepBadge value={item.item_sep} /></TableCell>
<TableCell className="text-center"><UABadge value={item.model_UA} /></TableCell>
<TableCell>{item.item_bending || '-'}</TableCell>
<TableCell>{item.item_name || '-'}</TableCell>
<TableCell>{item.item_spec || '-'}</TableCell>
<TableCell>{item.material || '-'}</TableCell>
<TableCell className="text-center">
{item.image_url ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<img src={item.image_url} alt="" className="w-10 h-10 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>{item.model_name || '-'}</TableCell>
<TableCell className="text-right font-mono">{item.width_sum || '-'}</TableCell>
<TableCell className="text-center">
{item.bend_count ? (
<span className="text-blue-600 font-semibold">{item.bend_count}</span>
) : '-'}
</TableCell>
<TableCell>{item.created_at?.split(' ')[0] || '-'}</TableCell>
<TableCell>{item.modified_by || '-'}</TableCell>
</TableRow>
);
},
[router]
);
// --- 모바일 카드 ---
const renderMobileCard = useCallback(
(item: BendingItem, _index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<ListMobileCard
key={item.id}
id={String(item.id)}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => router.push(`/production/bending/${item.id}?mode=edit`)}
headerBadges={
<>
<Badge variant="outline" className="bg-gray-100 text-gray-700 font-mono text-xs">
{globalIndex}
</Badge>
<code className="inline-block text-xs bg-gray-100 text-gray-700 px-2.5 py-0.5 rounded-md font-mono">
{item.code}
</code>
</>
}
title={item.item_name || item.name}
statusBadge={<SepBadge value={item.item_sep} />}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="분류" value={item.item_bending || '-'} />
<InfoField label="재질" value={item.material || '-'} />
<InfoField label="규격" value={item.item_spec || '-'} />
<InfoField label="폭합" value={String(item.width_sum || '-')} />
</div>
}
/>
);
},
[router]
);
return (
<>
<IntegratedListTemplateV2<BendingItem>
title="절곡 바라시 기초자료"
description="절곡 부품 기초 데이터 관리"
icon={Layers}
createButton={{
label: '신규 등록',
onClick: () => router.push('/production/bending?mode=new'),
}}
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="코드/이름/품명/규격 검색..."
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="기초관리 필터"
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}
/>
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
loading={isDeleting}
title="기초자료 삭제"
description="해당 기초자료를 삭제하시겠습니까?"
/>
</>
);
}

View File

@@ -0,0 +1,748 @@
'use client';
/**
* 모델 등록/수정 폼 — 가이드레일 / 케이스 / 하단마감재 공용
*
* 2열 레이아웃 (좌: 기본정보 + PartListEditor, 우: 이미지 + 저장)
* 타입별 헤더 필드 분기
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { X, Save, Trash2, Loader2, FileText, Pencil, History } from 'lucide-react';
import { DrawingCanvas } from '@/components/items/DrawingCanvas';
import { DocumentViewer } from '@/components/document-system/viewer/DocumentViewer';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useMenuStore } from '@/stores/menuStore';
import { PartListEditor } from './PartListEditor';
import { BendingSearchModal } from './BendingSearchModal';
import {
getGuiderailModel,
createGuiderailModel,
updateGuiderailModel,
deleteGuiderailModel,
} from './actions';
import type {
ModelCategory,
GuiderailModelDetail,
ComponentData,
BendingItem,
} from './types';
import { recalculateSums, getWidthSum, parseApiComponent } from './types';
// --- 카테고리별 설정 ---
interface CategoryFormConfig {
title: string;
basePath: string;
hasItemSep: boolean;
hasCheckType: boolean;
checkTypeOptions: string[];
hasModelSelect: boolean;
hasFinishingType: boolean;
hasModelUA: boolean;
hasFrontBottom: boolean;
hasRailWidthField: boolean;
widthLabel: string;
heightLabel: string;
}
const FORM_CONFIG: Record<ModelCategory, CategoryFormConfig> = {
GUIDERAIL_MODEL: {
title: '가이드레일',
basePath: '/production/bending/guiderail',
hasItemSep: true,
hasCheckType: true,
checkTypeOptions: ['벽면형', '측면형'],
hasModelSelect: true,
hasFinishingType: true,
hasModelUA: true,
hasFrontBottom: false,
hasRailWidthField: false,
widthLabel: '가로(너비)',
heightLabel: '세로(폭)',
},
SHUTTERBOX_MODEL: {
title: '케이스',
basePath: '/production/bending/shutterbox',
hasItemSep: false,
hasCheckType: true,
checkTypeOptions: ['양면 점검구', '밑면 점검구', '후면 점검구'],
hasModelSelect: false,
hasFinishingType: false,
hasModelUA: false,
hasFrontBottom: true,
hasRailWidthField: true,
widthLabel: '가로(폭)',
heightLabel: '세로(높이)',
},
BOTTOMBAR_MODEL: {
title: '하단마감재',
basePath: '/production/bending/bottombar',
hasItemSep: true,
hasCheckType: false,
checkTypeOptions: [],
hasModelSelect: true,
hasFinishingType: true,
hasModelUA: true,
hasFrontBottom: false,
hasRailWidthField: false,
widthLabel: '가로(폭)',
heightLabel: '세로(높이)',
},
};
// --- 컴포넌트 ---
interface BendingModelFormProps {
id?: string;
mode: 'new' | 'edit' | 'view';
category: ModelCategory;
}
interface FormState {
registration_date: string;
author: string;
search_keyword: string;
memo: string;
rail_width: string;
rail_length: string;
box_width: string;
box_height: string;
bar_width: string;
bar_height: string;
item_sep: string;
model_UA: string;
check_type: string;
model_name: string;
finishing_type: string;
front_bottom_width: string;
}
const INITIAL_STATE: FormState = {
registration_date: new Date().toISOString().split('T')[0],
author: '',
search_keyword: '',
memo: '',
rail_width: '',
rail_length: '',
box_width: '',
box_height: '',
bar_width: '',
bar_height: '',
item_sep: '스크린',
model_UA: '인정',
check_type: '',
model_name: '',
finishing_type: '',
front_bottom_width: '',
};
export function BendingModelForm({ id, mode, category }: BendingModelFormProps) {
const router = useRouter();
const cfg = FORM_CONFIG[category];
const isNew = mode === 'new';
const isView = mode === 'view';
const isEdit = mode === 'edit';
const disabled = isView;
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const [form, setForm] = useState<FormState>(INITIAL_STATE);
const [components, setComponents] = useState<ComponentData[]>([]);
const [isLoading, setIsLoading] = useState(!isNew);
const [isSaving, setIsSaving] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [isWorkOrderOpen, setIsWorkOrderOpen] = useState(false);
const [originalModel, setOriginalModel] = useState<GuiderailModelDetail | null>(null);
const [isDrawingOpen, setIsDrawingOpen] = useState(false);
const [drawingImage, setDrawingImage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setDrawingImage(ev.target?.result as string);
reader.readAsDataURL(file);
e.target.value = '';
}, []);
// 로그인 사용자 이름 로드
const [authorName, setAuthorName] = useState('');
useEffect(() => {
try {
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
if (user?.name) setAuthorName(user.name);
}
} catch { /* ignore */ }
}, []);
// 데이터 로드
useEffect(() => {
if (!isNew && id) {
getGuiderailModel(id).then((r) => {
if (r.success && r.data) {
const model = r.data as GuiderailModelDetail;
setOriginalModel(model);
// 결합형태 이미지 로드
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modelAny = model as any;
if (modelAny.image_url) setDrawingImage(modelAny.image_url);
setForm({
registration_date: model.registration_date || '',
author: model.author || '',
search_keyword: model.search_keyword || '',
memo: model.memo || '',
rail_width: model.rail_width ? String(model.rail_width) : '',
rail_length: model.rail_length ? String(model.rail_length) : '',
box_width: model.box_width ? String(model.box_width) : '',
box_height: model.box_height ? String(model.box_height) : '',
bar_width: (model as any).bar_width ? String((model as any).bar_width) : '',
bar_height: (model as any).bar_height ? String((model as any).bar_height) : '',
item_sep: model.item_sep || '스크린',
model_UA: model.model_UA || '인정',
check_type: model.check_type || model.exit_direction || '',
model_name: model.model_name || '',
finishing_type: model.finishing_type || '',
front_bottom_width: model.front_bottom_width ? String(model.front_bottom_width) : '',
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setComponents(
(model.components || []).map((c: any, i: number) => {
if (c.inputList) return parseApiComponent(c, i);
return {
...c,
orderNumber: c.orderNumber || i + 1,
quantity: c.quantity || 1,
bendingData: c.bendingData ? recalculateSums(c.bendingData) : [],
width_sum: c.width_sum || getWidthSum(c.bendingData || []),
sourceItemId: c.sourceItemId || c.sam_item_id || undefined,
};
})
);
} else {
toast.error('데이터를 불러오는데 실패했습니다.');
router.back();
}
setIsLoading(false);
});
}
}, [id, isNew, router]);
const handleChange = useCallback((field: keyof FormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
}, []);
// 부품 추가 (검색 모달에서)
const handleAddParts = useCallback((items: BendingItem[]) => {
const newComponents: ComponentData[] = items.map((item, i) => ({
orderNumber: components.length + i + 1,
itemName: item.item_name || item.name,
material: item.material || '',
quantity: 1,
width_sum: item.width_sum || 0,
bendingData: item.bendingData ? recalculateSums(item.bendingData) : [],
sourceItemId: item.id,
}));
setComponents((prev) => [...prev, ...newComponents]);
setIsSearchOpen(false);
}, [components.length]);
// 저장
const handleSubmit = useCallback(async () => {
setIsSaving(true);
try {
const payload = {
item_category: category,
...form,
rail_width: form.rail_width ? Number(form.rail_width) : null,
rail_length: form.rail_length ? Number(form.rail_length) : null,
box_width: form.box_width ? Number(form.box_width) : null,
box_height: form.box_height ? Number(form.box_height) : null,
bar_width: form.bar_width ? Number(form.bar_width) : null,
bar_height: form.bar_height ? Number(form.bar_height) : null,
front_bottom_width: form.front_bottom_width ? Number(form.front_bottom_width) : null,
components: components.map((c) => ({
orderNumber: c.orderNumber,
itemName: c.itemName,
material: c.material,
quantity: c.quantity,
width_sum: c.width_sum,
bendingData: c.bendingData,
sourceItemId: c.sourceItemId,
})),
};
const result = isNew
? await createGuiderailModel(payload)
: await updateGuiderailModel(id!, payload);
if (result.success) {
toast.success(isNew ? '등록되었습니다.' : '저장되었습니다.');
router.push(cfg.basePath);
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} finally {
setIsSaving(false);
}
}, [form, components, isNew, id, category, cfg.basePath, router]);
// 삭제
const handleDelete = useCallback(async () => {
if (!id) return;
setIsDeleting(true);
try {
const result = await deleteGuiderailModel(id);
if (result.success) {
toast.success('삭제되었습니다.');
router.push(cfg.basePath);
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} finally {
setIsDeleting(false);
setIsDeleteOpen(false);
}
}, [id, cfg.basePath, router]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-4 pb-24">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">
{cfg.title} {isNew ? '등록' : '수정'}
{!isNew && form.model_name && (
<span className="ml-2 text-muted-foreground font-normal text-base">{form.model_name}</span>
)}
</h1>
<Button variant="link" className="text-muted-foreground" onClick={() => router.push(cfg.basePath)}>
</Button>
</div>
{/* 2열 레이아웃 */}
<div className="flex gap-4">
{/* 좌측 */}
<div className="flex-1 space-y-4">
{/* 기본 정보 */}
<Card>
<CardContent className="pt-6">
<h3 className="text-sm font-semibold mb-4"> </h3>
{/* 1행: 등록일, 작성자, 검색어, 비고 */}
<div className="grid grid-cols-4 gap-3 mb-3">
<div className="space-y-1">
<Label className="text-xs"></Label>
<DatePicker value={form.registration_date} onChange={(v) => handleChange('registration_date', v)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input value={authorName || form.author} disabled className="bg-muted" placeholder="자동" />
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input value={form.search_keyword} onChange={(e) => handleChange('search_keyword', e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input value={form.memo} onChange={(e) => handleChange('memo', e.target.value)} disabled={disabled} />
</div>
</div>
{/* 2행: 외형 치수 + 케이스 전용 */}
<div className="grid grid-cols-4 gap-3 mb-3">
<div className="space-y-1">
<Label className="text-xs text-green-600 font-semibold">{cfg.widthLabel}</Label>
<Input type="number" value={category === 'SHUTTERBOX_MODEL' ? form.box_width : category === 'BOTTOMBAR_MODEL' ? form.bar_width : form.rail_width} onChange={(e) => handleChange(category === 'SHUTTERBOX_MODEL' ? 'box_width' : category === 'BOTTOMBAR_MODEL' ? 'bar_width' : 'rail_width', e.target.value)} disabled={disabled} />
</div>
<div className="space-y-1">
<Label className="text-xs text-red-600 font-semibold">{cfg.heightLabel} {cfg.hasCheckType && category !== 'GUIDERAIL_MODEL' ? '*' : ''}</Label>
<Input type="number" value={category === 'SHUTTERBOX_MODEL' ? form.box_height : category === 'BOTTOMBAR_MODEL' ? form.bar_height : form.rail_length} onChange={(e) => handleChange(category === 'SHUTTERBOX_MODEL' ? 'box_height' : category === 'BOTTOMBAR_MODEL' ? 'bar_height' : 'rail_length', e.target.value)} disabled={disabled} />
</div>
{cfg.hasModelSelect && (
<div className="space-y-1">
<Label className="text-xs"> <span className="text-red-500">*</span></Label>
<Input value={form.model_name} onChange={(e) => handleChange('model_name', e.target.value)} disabled={disabled} />
</div>
)}
{cfg.hasFinishingType && (
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select key={`finish-${form.finishing_type}`} value={form.finishing_type} onValueChange={(v) => handleChange('finishing_type', v)} disabled={disabled}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="SUS마감">SUS마감</SelectItem>
<SelectItem value="EGI마감">EGI마감</SelectItem>
</SelectContent>
</Select>
</div>
)}
{cfg.hasFrontBottom && (
<>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input type="number" value={form.front_bottom_width} onChange={(e) => handleChange('front_bottom_width', e.target.value)} disabled={disabled} />
</div>
{cfg.hasRailWidthField && (
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input type="number" value={form.rail_width} onChange={(e) => handleChange('rail_width', e.target.value)} disabled={disabled} />
</div>
)}
</>
)}
</div>
{/* 3행: 라디오 그룹 */}
<div className="flex flex-wrap gap-6 mt-2">
{cfg.hasItemSep && (
<div className="space-y-1">
<Label className="text-xs"> <span className="text-red-500">*</span></Label>
<RadioGroup value={form.item_sep} onValueChange={(v) => handleChange('item_sep', v)} className="flex gap-4" disabled={disabled}>
<div className="flex items-center gap-1"><RadioGroupItem value="스크린" id="sep-screen" /><Label htmlFor="sep-screen" className="text-sm"></Label></div>
<div className="flex items-center gap-1"><RadioGroupItem value="철재" id="sep-steel" /><Label htmlFor="sep-steel" className="text-sm"></Label></div>
</RadioGroup>
</div>
)}
{cfg.hasCheckType && (
<div className="space-y-1">
<Label className="text-xs">{category === 'GUIDERAIL_MODEL' ? '형태' : '점검구'} {category !== 'GUIDERAIL_MODEL' ? '*' : ''}</Label>
<RadioGroup value={form.check_type} onValueChange={(v) => handleChange('check_type', v)} className="flex gap-4" disabled={disabled}>
{cfg.checkTypeOptions.map((opt) => (
<div key={opt} className="flex items-center gap-1">
<RadioGroupItem value={opt} id={`ct-${opt}`} />
<Label htmlFor={`ct-${opt}`} className="text-sm">{opt}</Label>
</div>
))}
</RadioGroup>
</div>
)}
{cfg.hasModelUA && (
<div className="space-y-1">
<Label className="text-xs"></Label>
<RadioGroup value={form.model_UA} onValueChange={(v) => handleChange('model_UA', v)} className="flex gap-4" disabled={disabled}>
<div className="flex items-center gap-1"><RadioGroupItem value="인정" id="ua-yes" /><Label htmlFor="ua-yes" className="text-sm"></Label></div>
<div className="flex items-center gap-1"><RadioGroupItem value="비인정" id="ua-no" /><Label htmlFor="ua-no" className="text-sm"></Label></div>
</RadioGroup>
</div>
)}
</div>
</CardContent>
</Card>
{/* 부품 조립 */}
<Card>
<CardContent className="pt-6">
<PartListEditor
components={components}
onChange={setComponents}
readOnly={disabled}
onAddParts={() => setIsSearchOpen(true)}
/>
</CardContent>
</Card>
</div>
{/* 우측: 이미지 + 저장 */}
<div className="w-[280px] space-y-4">
<Card>
<CardContent className="pt-6">
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="w-full aspect-square bg-muted/30 border-2 border-dashed rounded-lg flex items-center justify-center mb-3 overflow-hidden">
{drawingImage ? (
<img src={drawingImage} alt="결합형태 이미지" className="w-full h-full object-contain" />
) : (
<span className="text-sm text-muted-foreground"> </span>
)}
</div>
{!disabled && (
<div className="space-y-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => fileInputRef.current?.click()}>
</Button>
<Button variant="outline" size="sm" className="text-xs" onClick={() => setIsDrawingOpen(true)}>
<Pencil className="h-3 w-3 mr-1" />
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<p className="text-xs text-muted-foreground">Ctrl+V로 </p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* 하단 Sticky 액션 바 */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`}>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={() => router.push(cfg.basePath)} size="sm" className="md:size-default">
<X className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex items-center gap-1 md:gap-2">
{isEdit && (
<>
<Button variant="outline" size="sm" className="md:size-default" onClick={() => setIsHistoryOpen(true)}>
<History className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button variant="outline" size="sm" className="md:size-default" onClick={() => setIsWorkOrderOpen(true)}>
<FileText className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
onClick={() => setIsDeleteOpen(true)}
>
<Trash2 className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
</>
)}
{!disabled && (
<Button onClick={handleSubmit} disabled={isSaving} size="sm" className="md:size-default">
{isSaving ? <Loader2 className="w-4 h-4 md:mr-2 animate-spin" /> : <Save className="w-4 h-4 md:mr-2" />}
<span className="hidden md:inline"></span>
</Button>
)}
</div>
</div>
</div>
{/* 부품 검색 모달 */}
<BendingSearchModal
open={isSearchOpen}
onOpenChange={setIsSearchOpen}
onSelect={handleAddParts}
/>
{/* 수정 이력 */}
<Dialog open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{originalModel?.model_name || form.model_name || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span>{originalModel?.registration_date || form.registration_date || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span>{originalModel?.author || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span>{originalModel?.modified_by || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"> </span>
<span className="text-blue-600 font-medium">{authorName || '-'}</span>
</div>
<div className="flex justify-between border-b pb-2">
<span className="text-muted-foreground"></span>
<span>{originalModel?.created_at || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>{originalModel?.updated_at || '-'}</span>
</div>
</div>
</DialogContent>
</Dialog>
{/* 작업지시서 — DocumentViewer 쉘 (축소/확대/맞춤/PDF/인쇄) */}
<DocumentViewer
title={`절곡 작업일지 — ${form.model_name || cfg.title}`}
features={{ zoom: true, print: true }}
actions={['pdf', 'print']}
open={isWorkOrderOpen}
onOpenChange={setIsWorkOrderOpen}
>
<div className="p-6 space-y-4 bg-white min-h-[600px]">
{/* 헤더 정보 */}
<h2 className="text-lg font-bold text-center">{cfg.title} </h2>
<div className="flex items-center justify-center gap-6 text-sm">
<div>
<span className="text-muted-foreground">: </span>
<span className="font-bold text-red-600">
{category === 'SHUTTERBOX_MODEL'
? `${form.box_width || '-'}*${form.box_height || '-'}`
: category === 'BOTTOMBAR_MODEL'
? `${form.bar_width || '-'}×${form.bar_height || '-'}`
: `${form.rail_width || '-'}×${form.rail_length || '-'}`}
</span>
</div>
{cfg.hasCheckType && (
<div>
<span className="text-muted-foreground">: </span>
<span className="font-bold">{form.check_type || '-'}</span>
</div>
)}
{cfg.hasFinishingType && (
<div>
<span className="text-muted-foreground">: </span>
<span className="font-medium">{form.finishing_type || '-'}</span>
</div>
)}
</div>
{/* 부품별 절곡 데이터 */}
{components.map((comp) => (
<div key={comp.orderNumber} className="border rounded p-3 space-y-2">
<div className="flex items-center gap-3 text-sm font-medium">
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs">:{comp.orderNumber}</span>
<span>{comp.itemName}</span>
<span className="text-muted-foreground">: {comp.material}</span>
<span className="text-muted-foreground">: {comp.quantity}</span>
<span className="text-blue-600 font-semibold">: {(comp.width_sum || 0).toLocaleString()}</span>
</div>
{/* 절곡 치수 테이블 */}
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-muted/50">
<th className="border px-2 py-1 w-[60px]"></th>
{comp.bendingData.map((d) => (
<th key={d.no} className="border px-2 py-1 text-center">{d.no}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="border px-2 py-1 font-medium"></td>
{comp.bendingData.map((_, i) => (
<td key={i} className="border px-2 py-1 text-center">{comp.material}</td>
))}
</tr>
<tr>
<td className="border px-2 py-1 font-medium"></td>
{comp.bendingData.map((d) => (
<td key={d.no} className={`border px-2 py-1 text-center ${d.color ? 'bg-gray-200 font-bold' : ''}`}>
{d.sum}{d.aAngle && <span className="text-red-600 font-bold"> / A°</span>}
</td>
))}
</tr>
<tr>
<td className="border px-2 py-1 font-medium"></td>
<td className="border px-2 py-1 text-center font-semibold" colSpan={comp.bendingData.length}>
{(comp.width_sum || 0).toLocaleString()}
</td>
</tr>
<tr>
<td className="border px-2 py-1 font-medium"></td>
<td className="border px-2 py-1 text-center" colSpan={comp.bendingData.length}>
{comp.quantity}
</td>
</tr>
</tbody>
</table>
</div>
))}
{/* 재질별 폭합 */}
{components.length > 0 && (
<div className="border rounded p-3">
<h4 className="text-sm font-semibold mb-2"> </h4>
<table className="text-sm border-collapse">
<thead>
<tr className="bg-muted/50">
<th className="border px-3 py-1 text-left"></th>
<th className="border px-3 py-1 text-right"> (mm)</th>
</tr>
</thead>
<tbody>
{Object.entries(
components.reduce((acc, c) => {
if (c.material) acc[c.material] = (acc[c.material] || 0) + c.width_sum * c.quantity;
return acc;
}, {} as Record<string, number>)
).map(([mat, total]) => (
<tr key={mat}>
<td className="border px-3 py-1">{mat}</td>
<td className="border px-3 py-1 text-right font-semibold text-blue-600">{total.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</DocumentViewer>
{/* 그리기 도구 */}
<DrawingCanvas
open={isDrawingOpen}
onOpenChange={setIsDrawingOpen}
onSave={(imageData) => {
setDrawingImage(imageData);
setIsDrawingOpen(false);
}}
initialImage={drawingImage || undefined}
title="결합형태 이미지 그리기"
description="조립도 형상을 그리거나 편집합니다."
/>
{/* 삭제 확인 */}
<DeleteConfirmDialog
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
onConfirm={handleDelete}
loading={isDeleting}
title={`${cfg.title} 삭제`}
description={`해당 ${cfg.title}을(를) 삭제하시겠습니까?`}
/>
</div>
);
}

View File

@@ -0,0 +1,494 @@
'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}
/>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
/**
* 기초관리 부품 검색 모달
*
* SearchableSelectionModal 활용 — 다중선택 + 필터 드롭다운 + 테이블
* 모델 폼에서 [+ 부품 추가] 시 사용
*/
import { useCallback, useState, useEffect, useRef } from 'react';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getBendingItems, getBendingItemFilters } from './actions';
import type { BendingItem, BendingItemFilters } from './types';
interface BendingSearchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (items: BendingItem[]) => void;
}
export function BendingSearchModal({ open, onOpenChange, onSelect }: BendingSearchModalProps) {
const [filters, setFilters] = useState<BendingItemFilters | null>(null);
const [filterSep, setFilterSep] = useState('all');
const [filterBending, setFilterBending] = useState('all');
const [filterMaterial, setFilterMaterial] = useState('all');
// 필터값을 ref로 유지 (fetchData 클로저에서 최신값 접근)
const filterRef = useRef({ sep: filterSep, bending: filterBending, material: filterMaterial });
filterRef.current = { sep: filterSep, bending: filterBending, material: filterMaterial };
// 필터 옵션 로드
useEffect(() => {
if (open && !filters) {
getBendingItemFilters().then((r) => {
if (r.success && r.data) setFilters(r.data as BendingItemFilters);
});
}
}, [open, filters]);
const fetchData = useCallback(async (query: string) => {
const f = filterRef.current;
const result = await getBendingItems({
search: query,
perPage: 500,
item_sep: f.sep !== 'all' ? f.sep : undefined,
item_bending: f.bending !== 'all' ? f.bending : undefined,
material: f.material !== 'all' ? f.material : undefined,
});
if (result.success && result.data) {
return result.data as unknown as BendingItem[];
}
return [];
}, []);
// 필터 변경 시 key를 바꿔서 재검색
const filterKey = `${filterSep}-${filterBending}-${filterMaterial}`;
return (
<SearchableSelectionModal<BendingItem>
key={filterKey}
open={open}
onOpenChange={onOpenChange}
title="절곡 부품 검색"
searchPlaceholder="품명/코드 검색..."
fetchData={fetchData}
keyExtractor={(item) => String(item.id)}
loadOnOpen
dialogClassName="sm:max-w-4xl"
listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md"
mode="multiple"
onSelect={onSelect}
confirmLabel="선택 적용"
allowSelectAll
renderHeader={() => (
<div className="flex gap-2 mb-3">
<Select value={filterSep} onValueChange={setFilterSep}>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue placeholder="전체(대분류)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">()</SelectItem>
{filters?.item_sep?.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterBending} onValueChange={setFilterBending}>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue placeholder="전체(분류)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">()</SelectItem>
{filters?.item_bending?.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterMaterial} onValueChange={setFilterMaterial}>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue placeholder="전체(재질)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">()</SelectItem>
{filters?.material?.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
listWrapper={(children, selectState) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
{selectState && (
<Checkbox
checked={selectState.isAllSelected}
onCheckedChange={selectState.onToggleAll}
/>
)}
</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[70px] text-center"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[60px] text-right"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>{children}</TableBody>
</Table>
)}
renderItem={(item, isSelected) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50">
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} />
</TableCell>
<TableCell className="font-mono text-xs">{item.code}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className={`text-xs ${item.item_sep === '철재' ? 'bg-orange-50 text-orange-700 border-orange-200' : 'bg-blue-50 text-blue-700 border-blue-200'}`}>
{item.item_sep}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">{item.item_bending}</Badge>
</TableCell>
<TableCell>{item.item_name || item.name}</TableCell>
<TableCell className="text-xs">{item.material}</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>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right font-mono">{item.width_sum || '-'}</TableCell>
<TableCell className="text-center">{item.bend_count || '-'}</TableCell>
</TableRow>
)}
/>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, Trash2, Eraser, ChevronUp, ChevronDown } from 'lucide-react';
import {
type BendingData,
createEmptyBendingData,
calculateRateResult,
recalculateSums,
getWidthSum,
getBendCount,
} from './types';
interface BendingTableProps {
data: BendingData[];
onChange: (data: BendingData[]) => void;
readOnly?: boolean;
showActions?: boolean;
showSummary?: boolean;
}
export function BendingTable({
data,
onChange,
readOnly = false,
showActions = true,
showSummary = true,
}: BendingTableProps) {
const handleInputChange = useCallback(
(colIndex: number, value: number) => {
const updated = [...data];
updated[colIndex] = { ...updated[colIndex], input: isNaN(value) ? 0 : value };
onChange(recalculateSums(updated));
},
[data, onChange]
);
const handleRateChange = useCallback(
(colIndex: number, value: string) => {
const cleaned = value.trim();
// 빈값 또는 숫자(음수 포함) 허용, 입력 중간 '-' 허용
if (cleaned !== '' && cleaned !== '-' && isNaN(Number(cleaned))) return;
const updated = [...data];
updated[colIndex] = { ...updated[colIndex], rate: cleaned };
if (cleaned === '' || cleaned === '-') {
onChange(updated);
} else {
onChange(recalculateSums(updated));
}
},
[data, onChange]
);
const handleCheckChange = useCallback(
(colIndex: number, field: 'color' | 'aAngle', checked: boolean) => {
const updated = [...data];
updated[colIndex] = { ...updated[colIndex], [field]: checked };
onChange(updated);
},
[data, onChange]
);
const addColumn = useCallback(() => {
const newData = [...data, createEmptyBendingData(data.length + 1)];
onChange(recalculateSums(newData));
}, [data, onChange]);
const removeLastColumn = useCallback(() => {
if (data.length === 0) return;
const newData = data.slice(0, -1);
onChange(recalculateSums(newData));
}, [data, onChange]);
const clearAll = useCallback(() => {
const cleared = data.map((d, i) => createEmptyBendingData(i + 1));
onChange(recalculateSums(cleared));
}, [data, onChange]);
const widthSum = getWidthSum(data);
const bendCount = getBendCount(data);
return (
<div className="space-y-2">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
{showActions && !readOnly && (
<div className="flex gap-1">
<Button type="button" size="sm" variant="outline" onClick={addColumn}>
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
<Button type="button" size="sm" variant="destructive" onClick={removeLastColumn}>
<Trash2 className="h-3.5 w-3.5 mr-1" />
</Button>
<Button type="button" size="sm" variant="secondary" onClick={clearAll}>
<Eraser className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
)}
</div>
{/* 가로형 테이블 */}
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-muted/50">
<th className="border px-2 py-1.5 text-center w-[70px] font-medium"></th>
{data.map((d) => (
<th key={d.no} className="border px-2 py-1.5 text-center min-w-[70px] font-medium">
{d.no}
</th>
))}
{!readOnly && data.length > 0 && (
<th className="border px-1 py-1.5 w-[36px]">
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={addColumn}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</th>
)}
</tr>
</thead>
<tbody>
{/* 입력 */}
<tr>
<td className="border px-2 py-1 text-center font-medium bg-muted/30"></td>
{data.map((d, i) => (
<td key={i} className="border px-1 py-1 bg-yellow-50">
{readOnly ? (
<span className="block text-center">{d.input || ''}</span>
) : (
<Input
type="number"
value={d.input || ''}
onChange={(e) => handleInputChange(i, Number(e.target.value))}
className="h-7 text-center px-1 border-0 bg-transparent"
/>
)}
</td>
))}
{!readOnly && data.length > 0 && <td className="border" />}
</tr>
{/* 연신율 */}
<tr>
<td className="border px-2 py-1 text-center font-medium bg-muted/30"></td>
{data.map((d, i) => (
<td key={i} className="border px-1 py-1">
{readOnly ? (
<span className="block text-center">{d.rate ?? ''}</span>
) : (
<div className="flex items-center justify-center gap-0.5">
<button
type="button"
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={() => handleRateChange(i, String((Number(d.rate) || 0) + 1))}
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<span className="w-5 text-center text-sm font-medium">{d.rate || ''}</span>
<button
type="button"
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={() => handleRateChange(i, String((Number(d.rate) || 0) - 1))}
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
)}
</td>
))}
{!readOnly && data.length > 0 && <td className="border" />}
</tr>
{/* 연신율 후 (자동 계산) */}
<tr>
<td className="border px-2 py-1 text-center font-medium bg-muted/30"> </td>
{data.map((d, i) => (
<td key={i} className="border px-1 py-1 text-center text-muted-foreground">
{d.input ? (calculateRateResult(d.input, d.rate) || '-') : '-'}
</td>
))}
{!readOnly && data.length > 0 && <td className="border" />}
</tr>
{/* 합계 (누적) */}
<tr className="bg-amber-50">
<td className="border px-2 py-1 text-center font-semibold bg-amber-100"></td>
{data.map((d, i) => {
const isLast = i === data.length - 1;
return (
<td
key={i}
className={`border px-1 py-1 text-center font-semibold ${
d.color ? 'text-red-600' : ''
} ${isLast ? 'text-blue-600' : ''}`}
>
{d.sum != null && !isNaN(d.sum) ? d.sum : '-'}
</td>
);
})}
{!readOnly && data.length > 0 && <td className="border" />}
</tr>
{/* 음영 */}
<tr>
<td className="border px-2 py-1 text-center font-medium bg-muted/30"></td>
{data.map((d, i) => (
<td key={i} className="border px-1 py-1 text-center">
<Checkbox
checked={d.color}
onCheckedChange={(checked) =>
handleCheckChange(i, 'color', checked === true)
}
disabled={readOnly}
/>
</td>
))}
{!readOnly && data.length > 0 && <td className="border" />}
</tr>
{/* A각 */}
<tr>
<td className="border px-2 py-1 text-center font-medium bg-muted/30">A각</td>
{data.map((d, i) => (
<td key={i} className="border px-1 py-1 text-center">
<Checkbox
checked={d.aAngle}
onCheckedChange={(checked) =>
handleCheckChange(i, 'aAngle', checked === true)
}
disabled={readOnly}
/>
</td>
))}
{!readOnly && data.length > 0 && <td className="border" />}
</tr>
</tbody>
</table>
</div>
{/* 합계 표시 */}
{showSummary && (
<div className="text-sm">
: <span className="font-bold text-blue-600">{widthSum}</span>
<span className="mx-2">|</span>
: <span className="font-bold text-blue-600">{bendCount}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
/**
* 재질별 폭합 테이블
*
* 부품 조립 하단에 표시 — components에서 material별 width_sum * quantity 합산
*/
import type { ComponentData } from './types';
import { calculateMaterialSummary } from './types';
interface MaterialSummaryProps {
components: ComponentData[];
}
export function MaterialSummary({ components }: MaterialSummaryProps) {
const summary = calculateMaterialSummary(components);
const entries = Object.entries(summary);
if (entries.length === 0) return null;
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold"> </h3>
<table className="text-sm border-collapse">
<thead>
<tr className="bg-muted/50">
<th className="border px-3 py-1.5 text-left font-medium"></th>
<th className="border px-3 py-1.5 text-right font-medium"> (mm)</th>
</tr>
</thead>
<tbody>
{entries.map(([material, total]) => (
<tr key={material}>
<td className="border px-3 py-1.5">{material}</td>
<td className="border px-3 py-1.5 text-right font-semibold text-blue-600">
{total}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,333 @@
'use client';
/**
* 부품 조립 편집기
*
* 모델 폼 내부에서 사용 — 부품 목록 + 순서 변경 + 절곡 테이블
*/
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Plus,
Trash2,
ArrowUp,
ArrowDown,
RotateCcw,
Edit,
Copy,
Loader2,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { BendingTable } from './BendingTable';
import { MaterialSummary } from './MaterialSummary';
import type { ComponentData, BendingData } from './types';
import { recalculateSums, getWidthSum } from './types';
interface PartListEditorProps {
components: ComponentData[];
onChange: (components: ComponentData[]) => void;
readOnly?: boolean;
onAddParts: () => void;
}
export function PartListEditor({
components,
onChange,
readOnly = false,
onAddParts,
}: PartListEditorProps) {
const router = useRouter();
// 다른이름저장: 기초관리 등록 페이지로 이동 (원본 데이터 + 새 이름)
const [saveAsOpen, setSaveAsOpen] = useState(false);
const [saveAsName, setSaveAsName] = useState('');
const [saveAsIndex, setSaveAsIndex] = useState<number | null>(null);
const handleOpenSaveAs = useCallback((index: number) => {
setSaveAsIndex(index);
setSaveAsName(components[index].itemName || '');
setSaveAsOpen(true);
}, [components]);
const handleSaveAs = useCallback(() => {
if (saveAsIndex === null || !saveAsName.trim()) return;
const comp = components[saveAsIndex];
sessionStorage.setItem('bending-save-as', JSON.stringify({
name: saveAsName.trim(),
material: comp.material || '',
bendingData: comp.bendingData || [],
width_sum: comp.width_sum || 0,
}));
setSaveAsOpen(false);
router.push('/production/bending?mode=new&from=saveAs');
}, [saveAsIndex, saveAsName, components, router]);
// 부품 삭제
const handleRemove = useCallback(
(index: number) => {
const updated = components
.filter((_, i) => i !== index)
.map((c, i) => ({ ...c, orderNumber: i + 1 }));
onChange(updated);
},
[components, onChange]
);
// 전체 삭제
const handleRemoveAll = useCallback(() => {
onChange([]);
}, [onChange]);
// 순서 위로
const handleMoveUp = useCallback(
(index: number) => {
if (index === 0) return;
const updated = [...components];
[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]];
onChange(updated.map((c, i) => ({ ...c, orderNumber: i + 1 })));
},
[components, onChange]
);
// 순서 아래로
const handleMoveDown = useCallback(
(index: number) => {
if (index === components.length - 1) return;
const updated = [...components];
[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]];
onChange(updated.map((c, i) => ({ ...c, orderNumber: i + 1 })));
},
[components, onChange]
);
// 순서 초기화
const handleResetOrder = useCallback(() => {
onChange(components.map((c, i) => ({ ...c, orderNumber: i + 1 })));
}, [components, onChange]);
// 필드 인라인 수정
const handleFieldChange = useCallback(
(index: number, field: keyof ComponentData, value: string | number) => {
const updated = [...components];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
},
[components, onChange]
);
// 부품별 절곡 데이터 수정
const handleBendingDataChange = useCallback(
(index: number, data: BendingData[]) => {
const updated = [...components];
updated[index] = {
...updated[index],
bendingData: data,
width_sum: getWidthSum(data),
};
onChange(updated);
},
[components, onChange]
);
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">
({components.length})
</h3>
{!readOnly && (
<div className="flex gap-1">
<Button type="button" size="sm" variant="default" onClick={onAddParts}>
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={handleRemoveAll}
disabled={components.length === 0}
>
</Button>
</div>
)}
</div>
{/* 순서 변경 버튼 */}
{!readOnly && components.length > 1 && (
<div className="flex gap-1 items-center text-xs text-muted-foreground">
<Button type="button" size="sm" variant="outline" onClick={handleResetOrder}>
<RotateCcw className="h-3 w-3 mr-1" />
</Button>
<span className="ml-2"> / </span>
</div>
)}
{/* 부품 카드 리스트 */}
{components.map((comp, index) => (
<div key={`part-${index}`} className="border rounded-lg p-4 space-y-3 bg-white">
{/* 부품 헤더 */}
<div className="flex items-center gap-3 flex-wrap">
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs font-semibold">
:{comp.orderNumber}
</span>
{readOnly ? (
<span className="font-medium">{comp.itemName}</span>
) : (
<Input
value={comp.itemName}
onChange={(e) => handleFieldChange(index, 'itemName', e.target.value)}
className="h-7 w-[160px] text-sm"
placeholder="부품명"
/>
)}
<span className="text-xs text-muted-foreground">:</span>
{readOnly ? (
<span className="text-sm">{comp.material}</span>
) : (
<Input
value={comp.material}
onChange={(e) => handleFieldChange(index, 'material', e.target.value)}
className="h-7 w-[120px] text-sm"
/>
)}
<span className="text-xs text-muted-foreground">:</span>
{readOnly ? (
<span className="text-sm">{comp.quantity}</span>
) : (
<Input
type="number"
value={comp.quantity || ''}
onChange={(e) => handleFieldChange(index, 'quantity', Number(e.target.value) || 0)}
className="h-7 w-[60px] text-sm text-center"
/>
)}
<span className="text-xs text-muted-foreground">
: <span className="font-semibold text-blue-600">{comp.width_sum || 0}</span>
</span>
{/* 액션 버튼 */}
{!readOnly && (
<div className="ml-auto flex gap-1">
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleMoveUp(index)} disabled={index === 0}>
<ArrowUp className="h-3 w-3" />
</Button>
<Button type="button" size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleMoveDown(index)} disabled={index === components.length - 1}>
<ArrowDown className="h-3 w-3" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs text-green-700"
onClick={() => {
if (!comp.sourceItemId) {
toast.error('연결된 기초자료가 없습니다.');
return;
}
router.push(`/production/bending/${comp.sourceItemId}?mode=edit`);
}}
>
<Edit className="h-3 w-3 mr-0.5" />
</Button>
<Button type="button" size="sm" variant="outline" className="h-7 text-xs text-blue-700" onClick={() => handleOpenSaveAs(index)}>
<Copy className="h-3 w-3 mr-0.5" />
</Button>
<Button type="button" size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleRemove(index)}>
<Trash2 className="h-3 w-3 mr-0.5" />
</Button>
</div>
)}
</div>
{/* 부품 이미지 썸네일 */}
{comp.imageUrl && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-[80px] h-[60px] border rounded overflow-hidden cursor-pointer">
<img src={comp.imageUrl} alt={comp.itemName} className="w-full h-full object-contain" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="p-1 max-w-none">
<img src={comp.imageUrl} alt={comp.itemName} className="w-52 h-52 object-contain" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* 부품별 절곡 테이블 */}
<BendingTable
data={comp.bendingData}
onChange={(data) => handleBendingDataChange(index, data)}
readOnly={readOnly}
showActions={false}
showSummary={false}
/>
</div>
))}
{/* 빈 상태 */}
{components.length === 0 && (
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
<p className="mb-2"> .</p>
{!readOnly && (
<Button variant="outline" onClick={onAddParts}>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</div>
)}
{/* 재질별 폭합 */}
{components.length > 0 && <MaterialSummary components={components} />}
{/* 다른이름저장 다이얼로그 */}
<Dialog open={saveAsOpen} onOpenChange={setSaveAsOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1">
<Label className="text-sm"> ( )</Label>
<Input
value={saveAsName}
onChange={(e) => setSaveAsName(e.target.value)}
placeholder="새 품명을 입력하세요"
autoFocus
/>
</div>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveAsOpen(false)}></Button>
<Button onClick={handleSaveAs} disabled={!saveAsName.trim()}>
<Copy className="w-4 h-4 mr-1" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,171 @@
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
// -------------------------------------------------------------------
// 기초관리 (bending-items)
// -------------------------------------------------------------------
/** 기초관리 목록 조회 */
export async function getBendingItems(params?: {
page?: number;
perPage?: number;
search?: string;
item_sep?: string;
item_bending?: string;
material?: string;
model_UA?: string;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/bending-items', {
page: params?.page,
per_page: params?.perPage,
search: params?.search,
item_sep: params?.item_sep !== 'all' ? params?.item_sep : undefined,
item_bending: params?.item_bending !== 'all' ? params?.item_bending : undefined,
material: params?.material !== 'all' ? params?.material : undefined,
model_UA: params?.model_UA !== 'all' ? params?.model_UA : undefined,
}),
transform: (item: Record<string, unknown>) => item,
errorMessage: '절곡 기초관리 목록 조회에 실패했습니다.',
});
}
/** 기초관리 필터 옵션 조회 */
export async function getBendingItemFilters() {
return executeServerAction({
url: buildApiUrl('/api/v1/bending-items/filters'),
errorMessage: '절곡 기초관리 필터 옵션 조회에 실패했습니다.',
});
}
/** 기초관리 상세 조회 */
export async function getBendingItem(id: number | string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/bending-items/${id}`),
errorMessage: '절곡 기초관리 상세 조회에 실패했습니다.',
});
}
/** 기초관리 등록 */
export async function createBendingItem(data: Record<string, unknown>) {
return executeServerAction({
url: buildApiUrl('/api/v1/bending-items'),
method: 'POST',
body: data,
errorMessage: '절곡 기초관리 등록에 실패했습니다.',
});
}
/** 기초관리 수정 */
export async function updateBendingItem(id: number | string, data: Record<string, unknown>) {
return executeServerAction({
url: buildApiUrl(`/api/v1/bending-items/${id}`),
method: 'PUT',
body: data,
errorMessage: '절곡 기초관리 수정에 실패했습니다.',
});
}
/** 기초관리 삭제 */
export async function deleteBendingItem(id: number | string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/bending-items/${id}`),
method: 'DELETE',
errorMessage: '절곡 기초관리 삭제에 실패했습니다.',
});
}
// -------------------------------------------------------------------
// 모델관리 (guiderail-models)
// -------------------------------------------------------------------
/** 모델 목록 조회 (item_category 필수!) */
export async function getGuiderailModels(params: {
item_category: string;
page?: number;
perPage?: number;
search?: string;
item_sep?: string;
model_UA?: string;
check_type?: string;
model_name?: string;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/guiderail-models', {
item_category: params.item_category,
page: params.page,
per_page: params.perPage,
search: params.search,
item_sep: params.item_sep !== 'all' ? params.item_sep : undefined,
model_UA: params.model_UA !== 'all' ? params.model_UA : undefined,
check_type: params.check_type !== 'all' ? params.check_type : undefined,
model_name: params.model_name !== 'all' ? params.model_name : undefined,
}),
transform: (item: Record<string, unknown>) => item,
errorMessage: '절곡품 모델 목록 조회에 실패했습니다.',
});
}
/** 모델 필터 옵션 조회 */
export async function getGuiderailModelFilters() {
return executeServerAction({
url: buildApiUrl('/api/v1/guiderail-models/filters'),
errorMessage: '절곡품 모델 필터 옵션 조회에 실패했습니다.',
});
}
/** 모델 상세 조회 (부품 조합 + 재질별 폭합 포함) */
export async function getGuiderailModel(id: number | string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/guiderail-models/${id}`),
errorMessage: '절곡품 모델 상세 조회에 실패했습니다.',
});
}
/** 모델 등록 */
export async function createGuiderailModel(data: Record<string, unknown>) {
return executeServerAction({
url: buildApiUrl('/api/v1/guiderail-models'),
method: 'POST',
body: data,
errorMessage: '절곡품 모델 등록에 실패했습니다.',
});
}
/** 모델 수정 */
export async function updateGuiderailModel(id: number | string, data: Record<string, unknown>) {
return executeServerAction({
url: buildApiUrl(`/api/v1/guiderail-models/${id}`),
method: 'PUT',
body: data,
errorMessage: '절곡품 모델 수정에 실패했습니다.',
});
}
/** 모델 삭제 */
export async function deleteGuiderailModel(id: number | string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/guiderail-models/${id}`),
method: 'DELETE',
errorMessage: '절곡품 모델 삭제에 실패했습니다.',
});
}
// -------------------------------------------------------------------
// 이미지 (기존 API 재사용)
// -------------------------------------------------------------------
/** 이미지 업로드 */
export async function uploadBendingImage(itemId: number | string, file: FormData) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${itemId}/files`),
method: 'POST',
body: file,
errorMessage: '이미지 업로드에 실패했습니다.',
});
}
/** 이미지 URL 생성 — types.ts에서 import하여 사용 (use server 파일에서 sync 함수 export 불가) */

View File

@@ -0,0 +1,261 @@
/**
* 절곡품 관리 타입 정의
*
* API: bending-items (기초관리) + guiderail-models (모델관리)
*/
// --- 공용 ---
/** 절곡 입력 데이터 (bendingData JSON 배열 원소) */
export interface BendingData {
no: number;
input: number;
rate: string; // '' | '-1' | '1'
sum: number; // 누적합 (자동 계산)
color: boolean; // 음영
aAngle: boolean; // A각
}
/** 모델 카테고리 */
export type ModelCategory =
| 'GUIDERAIL_MODEL'
| 'SHUTTERBOX_MODEL'
| 'BOTTOMBAR_MODEL';
// --- 기초관리 (bending-items) ---
/** 기초관리 목록 아이템 (GET /api/v1/bending-items) */
export interface BendingItem {
id: number;
code: string;
name: string;
item_type: string;
item_category: string;
item_name: string;
item_sep: string; // 대분류: '스크린' | '철재'
item_bending: string; // 분류: '가이드레일' | '케이스' | '하단마감재' 등
item_spec: string; // 규격
material: string; // 재질
model_name: string | null;
model_UA: string; // '인정' | '비인정'
search_keyword: string | null;
rail_width: number | null;
registration_date: string | null;
author: string | null;
memo: string | null;
exit_direction: string | null; // 점검구 방향 (케이스 전용)
front_bottom_width: number | null;
box_width: number | null;
box_height: number | null;
bendingData: BendingData[];
prefix: string;
length_code: string;
length_mm: number;
legacy_bending_num: number | null;
image_url: string | null;
width_sum: number;
bend_count: number;
created_at: string;
updated_at?: string;
modified_by?: string | null;
}
/** 기초관리 필터 옵션 (GET /api/v1/bending-items/filters) */
export interface BendingItemFilters {
item_sep: string[];
item_bending: string[];
material: string[];
model_UA: string[];
model_name: string[];
}
/** 기초관리 등록/수정 폼 데이터 */
export interface BendingItemFormData {
code: string;
name: string;
item_name: string;
item_sep: string;
item_bending: string;
material: string;
item_spec: string;
model_name: string;
model_UA: string;
registration_date: string;
search_keyword: string;
memo: string;
// 케이스 전용
exit_direction: string;
front_bottom_width: string;
rail_width: string;
box_width: string;
box_height: string;
// 절곡 데이터
bendingData: BendingData[];
}
// --- 모델관리 (guiderail-models) ---
/** 부품 조립 데이터 */
export interface ComponentData {
orderNumber: number;
itemName: string;
material: string;
quantity: number;
width_sum: number;
bendingData: BendingData[];
imageUrl?: string;
sourceItemId?: number;
legacy_bending_num?: string;
}
/** 모델 목록 아이템 (GET /api/v1/guiderail-models) */
export interface GuiderailModel {
id: number;
code: string;
name: string;
item_category: ModelCategory;
model_name: string;
check_type: string; // 형상: '벽면형' | '측면형' (가이드레일) / 점검구 방향 (케이스)
rail_width: number | null;
rail_length: number | null;
finishing_type: string; // 마감: 'SUS마감' | 'EGI마감'
item_sep: string; // 대분류
model_UA: string; // 인정/비인정
component_count: number;
material_summary: Record<string, number>; // { "SUS 1.2T": 599, "EGI 1.55T": 894 }
// 케이스 전용
exit_direction?: string | null; // 점검구 방향 (케이스: '양면 점검구' | '밑면 점검구' | '후면 점검구')
front_bottom_width?: number | null;
box_width?: number | null;
box_height?: number | null;
bar_width?: number | null; // 하단마감재 가로
bar_height?: number | null; // 하단마감재 세로
search_keyword?: string | null;
author?: string | null;
memo?: string | null;
registration_date?: string | null;
image_url?: string | null;
created_at: string;
updated_at?: string;
modified_by?: string | null;
}
/** 모델 상세 (GET /api/v1/guiderail-models/{id}) */
export interface GuiderailModelDetail extends GuiderailModel {
components: ComponentData[];
}
/** 모델 필터 옵션 (GET /api/v1/guiderail-models/filters) */
export interface GuiderailModelFilters {
item_sep: string[];
model_UA: string[];
check_type: string[];
model_name: string[];
finishing_type: string[];
}
/** 모델 등록/수정 폼 데이터 */
export interface GuiderailModelFormData {
item_category: ModelCategory;
model_name: string;
check_type: string;
rail_width: string;
rail_length: string;
finishing_type: string;
item_sep: string;
model_UA: string;
registration_date: string;
author: string;
search_keyword: string;
memo: string;
// 케이스 전용
front_bottom_width: string;
// 부품 조립
components: ComponentData[];
}
// --- 유틸리티 ---
/** bendingData에서 연신율 후 값 계산 */
export function calculateRateResult(input: number, rate: string): number {
const rateNum = Number(rate);
if (!rate || rate === '-' || isNaN(rateNum)) return input;
return input + rateNum;
}
/** bendingData 배열에서 합계 재계산 */
export function recalculateSums(data: BendingData[]): BendingData[] {
let cumulative = 0;
return data.map((d, i) => {
const rateResult = calculateRateResult(d.input, d.rate);
cumulative += rateResult;
return { ...d, no: i + 1, sum: cumulative };
});
}
/** 폭합계 (마지막 행의 sum) */
export function getWidthSum(data: BendingData[]): number {
if (data.length === 0) return 0;
return data[data.length - 1].sum;
}
/** 절곡횟수 (rate가 빈 문자열이 아닌 행의 수) */
export function getBendCount(data: BendingData[]): number {
return data.filter(d => d.rate !== '').length;
}
/** 빈 BendingData 생성 */
export function createEmptyBendingData(no: number): BendingData {
return { no, input: 0, rate: '', sum: 0, color: false, aAngle: false };
}
/** API 부품 응답을 ComponentData로 변환 */
export function parseApiComponent(raw: Record<string, unknown>, index: number): ComponentData {
const inputList = (raw.inputList as string[]) || [];
const rateList = (raw.bendingrateList as string[]) || [];
const colorList = (raw.colorList as boolean[]) || [];
const aList = (raw.AList as boolean[]) || [];
const sumList = (raw.sumList as string[]) || [];
const bendingData: BendingData[] = inputList.map((input, i) => ({
no: i + 1,
input: Number(input) || 0,
rate: rateList[i] ?? '',
sum: Number(sumList[i]) || 0,
color: colorList[i] ?? false,
aAngle: aList[i] ?? false,
}));
const lastSum = sumList.length > 0 ? Number(sumList[sumList.length - 1]) || 0 : 0;
return {
orderNumber: (raw.orderNumber as number) || index + 1,
itemName: (raw.itemName as string) || '',
material: (raw.material as string) || '',
quantity: (raw.quantity as number) || 1,
width_sum: lastSum,
bendingData,
imageUrl: (raw.image_url as string) || undefined,
sourceItemId: (raw.sam_item_id as number) || undefined,
legacy_bending_num: (raw.num as string) || undefined,
};
}
/** 이미지 URL 생성 */
export function getBendingImageUrl(fileId: number | string): string {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${apiUrl}/api/v1/files/${fileId}/view`;
}
/** 재질별 폭합 계산 */
export function calculateMaterialSummary(
components: ComponentData[]
): Record<string, number> {
const summary: Record<string, number> = {};
for (const comp of components) {
if (comp.material) {
summary[comp.material] = (summary[comp.material] || 0) + comp.width_sum * comp.quantity;
}
}
return summary;
}