feat: [production] 절곡 생산관리 페이지 신규 추가 (셔터박스, 가이드레일, 하단마감)
This commit is contained in:
625
src/components/production/bending/BendingBaseForm.tsx
Normal file
625
src/components/production/bending/BendingBaseForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
410
src/components/production/bending/BendingBaseList.tsx
Normal file
410
src/components/production/bending/BendingBaseList.tsx
Normal 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="해당 기초자료를 삭제하시겠습니까?"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
748
src/components/production/bending/BendingModelForm.tsx
Normal file
748
src/components/production/bending/BendingModelForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
494
src/components/production/bending/BendingModelList.tsx
Normal file
494
src/components/production/bending/BendingModelList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
185
src/components/production/bending/BendingSearchModal.tsx
Normal file
185
src/components/production/bending/BendingSearchModal.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
260
src/components/production/bending/BendingTable.tsx
Normal file
260
src/components/production/bending/BendingTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/production/bending/MaterialSummary.tsx
Normal file
45
src/components/production/bending/MaterialSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
src/components/production/bending/PartListEditor.tsx
Normal file
333
src/components/production/bending/PartListEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/components/production/bending/actions.ts
Normal file
171
src/components/production/bending/actions.ts
Normal 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 불가) */
|
||||
261
src/components/production/bending/types.ts
Normal file
261
src/components/production/bending/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user