feat: 품목 관리 및 마스터 데이터 관리 시스템 구현

주요 기능:
- 품목 CRUD 기능 (생성, 조회, 수정)
- 품목 마스터 데이터 관리 시스템
- BOM(Bill of Materials) 관리 기능
- 도면 캔버스 기능
- 품목 속성 및 카테고리 관리
- 스크린 인쇄 생산 관리 페이지

기술 개선:
- localStorage SSR 호환성 수정 (9개 useState 초기화)
- Shadcn UI 컴포넌트 추가 (table, tabs, alert, drawer 등)
- DataContext 및 DeveloperModeContext 추가
- API 라우트 구현 (items, master-data)
- 타입 정의 및 유틸리티 함수 추가

빌드 테스트:  성공 (3.1초)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-18 14:17:52 +09:00
parent 21edc932d9
commit 63f5df7d7d
56 changed files with 23927 additions and 149 deletions

View File

@@ -0,0 +1,292 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Trash2, Package, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
export interface BOMItem {
id: string;
itemCode: string;
itemName: string;
quantity: number;
unit: string;
itemType?: string;
note?: string;
createdAt: string;
}
interface BOMManagementSectionProps {
title?: string;
description?: string;
bomItems: BOMItem[];
onAddItem: (item: Omit<BOMItem, 'id' | 'createdAt'>) => void;
onUpdateItem: (id: string, item: Partial<BOMItem>) => void;
onDeleteItem: (id: string) => void;
itemTypeOptions?: { value: string; label: string }[];
unitOptions?: { value: string; label: string }[];
}
export function BOMManagementSection({
title = '부품 구성 (BOM)',
description = '이 제품을 구성하는 하위 품목을 추가하세요',
bomItems,
onAddItem,
onUpdateItem,
onDeleteItem,
itemTypeOptions = [
{ value: 'product', label: '제품' },
{ value: 'part', label: '부품' },
{ value: 'material', label: '원자재' },
],
unitOptions = [
{ value: 'EA', label: 'EA' },
{ value: 'KG', label: 'KG' },
{ value: 'M', label: 'M' },
{ value: 'L', label: 'L' },
],
}: BOMManagementSectionProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [itemCode, setItemCode] = useState('');
const [itemName, setItemName] = useState('');
const [quantity, setQuantity] = useState('1');
const [unit, setUnit] = useState('EA');
const [itemType, setItemType] = useState('part');
const [note, setNote] = useState('');
const handleOpenDialog = (item?: BOMItem) => {
if (item) {
setEditingId(item.id);
setItemCode(item.itemCode);
setItemName(item.itemName);
setQuantity(item.quantity.toString());
setUnit(item.unit);
setItemType(item.itemType || 'part');
setNote(item.note || '');
} else {
setEditingId(null);
setItemCode('');
setItemName('');
setQuantity('1');
setUnit('EA');
setItemType('part');
setNote('');
}
setIsDialogOpen(true);
};
const handleSave = () => {
if (!itemCode.trim() || !itemName.trim()) {
return toast.error('품목코드와 품목명을 입력해주세요');
}
const qty = parseFloat(quantity);
if (isNaN(qty) || qty <= 0) {
return toast.error('올바른 수량을 입력해주세요');
}
const itemData = {
itemCode,
itemName,
quantity: qty,
unit,
itemType,
note: note.trim() || undefined,
};
if (editingId) {
onUpdateItem(editingId, itemData);
toast.success('BOM 품목이 수정되었습니다');
} else {
onAddItem(itemData);
toast.success('BOM 품목이 추가되었습니다');
}
setIsDialogOpen(false);
};
const handleDelete = (id: string) => {
if (confirm('이 BOM 품목을 삭제하시겠습니까?')) {
onDeleteItem(id);
toast.success('BOM 품목이 삭제되었습니다');
}
};
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription className="text-sm">{description}</CardDescription>
</div>
<Button size="sm" onClick={() => handleOpenDialog()}>
<Plus className="h-4 w-4 mr-2" />
BOM
</Button>
</div>
</CardHeader>
<CardContent>
{bomItems.length === 0 ? (
<div className="bg-gray-50 border-2 border-dashed border-gray-200 rounded-lg py-16">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-lg flex items-center justify-center">
<Package className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-600 mb-1">
BOM
</p>
<p className="text-sm text-gray-500">
, ,
</p>
</div>
</div>
) : (
<div className="space-y-2">
{bomItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-3 border rounded hover:bg-gray-50 transition-colors"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<span className="font-medium">{item.itemName}</span>
<Badge variant="outline" className="text-xs">
{item.itemCode}
</Badge>
{item.itemType && (
<Badge variant="secondary" className="text-xs">
{itemTypeOptions.find((t) => t.value === item.itemType)?.label || item.itemType}
</Badge>
)}
</div>
<div className="ml-6 text-sm text-gray-500 mt-1">
: {item.quantity} {item.unit}
{item.note && <span className="ml-2"> {item.note}</span>}
</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => handleOpenDialog(item)}>
<Edit className="h-4 w-4 text-blue-500" />
</Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(item.id)}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* BOM 품목 추가/수정 다이얼로그 */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingId(null);
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingId ? 'BOM 품목 수정' : 'BOM 품목 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={itemCode}
onChange={(e) => setItemCode(e.target.value)}
placeholder="예: PART-001"
/>
</div>
<div>
<Label> *</Label>
<Input
value={itemName}
onChange={(e) => setItemName(e.target.value)}
placeholder="예: 샤프트"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label> *</Label>
<Input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
placeholder="1"
min="0"
step="0.01"
/>
</div>
<div>
<Label> *</Label>
<Select value={unit} onValueChange={setUnit}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{unitOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Select value={itemType} onValueChange={setItemType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{itemTypeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label> ()</Label>
<Input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="추가 정보를 입력하세요"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,486 @@
/**
* BOM (자재명세서) 관리 컴포넌트
*
* 하위 품목 추가/수정/삭제, 수량 계산식 지원
*/
'use client';
import { useState } from 'react';
import type { BOMLine } from '@/types/item';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Plus, Edit, Trash2, Calculator, ImagePlus } from 'lucide-react';
import { DrawingCanvas } from './DrawingCanvas';
interface BOMManagerProps {
bomLines: BOMLine[];
onChange: (bomLines: BOMLine[]) => void;
disabled?: boolean;
}
interface BOMFormData {
childItemCode: string;
childItemName: string;
quantity: number;
unit: string;
unitPrice?: number;
quantityFormula?: string;
note?: string;
isBending?: boolean;
bendingDiagram?: string;
}
export default function BOMManager({ bomLines, onChange, disabled = false }: BOMManagerProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isDrawingOpen, setIsDrawingOpen] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [formData, setFormData] = useState<BOMFormData>({
childItemCode: '',
childItemName: '',
quantity: 1,
unit: 'EA',
});
// 폼 초기화
const resetForm = () => {
setFormData({
childItemCode: '',
childItemName: '',
quantity: 1,
unit: 'EA',
});
setEditingIndex(null);
};
// 새 BOM 라인 추가
const handleAdd = () => {
resetForm();
setIsDialogOpen(true);
};
// BOM 라인 수정
const handleEdit = (index: number) => {
const line = bomLines[index];
setFormData({
childItemCode: line.childItemCode,
childItemName: line.childItemName,
quantity: line.quantity,
unit: line.unit,
unitPrice: line.unitPrice,
quantityFormula: line.quantityFormula,
note: line.note,
isBending: line.isBending,
bendingDiagram: line.bendingDiagram,
});
setEditingIndex(index);
setIsDialogOpen(true);
};
// BOM 라인 삭제
const handleDelete = (index: number) => {
if (!confirm('이 BOM 라인을 삭제하시겠습니까?')) {
return;
}
const newLines = bomLines.filter((_, i) => i !== index);
onChange(newLines);
};
// 폼 제출
const handleSubmit = () => {
if (!formData.childItemCode || !formData.childItemName) {
alert('품목 코드와 품목명을 입력해주세요.');
return;
}
const newLine: BOMLine = {
id: editingIndex !== null ? bomLines[editingIndex].id : `bom-${Date.now()}`,
childItemCode: formData.childItemCode,
childItemName: formData.childItemName,
quantity: formData.quantity,
unit: formData.unit,
unitPrice: formData.unitPrice,
quantityFormula: formData.quantityFormula,
note: formData.note,
isBending: formData.isBending,
bendingDiagram: formData.bendingDiagram,
};
let newLines: BOMLine[];
if (editingIndex !== null) {
// 수정
newLines = bomLines.map((line, i) => (i === editingIndex ? newLine : line));
} else {
// 추가
newLines = [...bomLines, newLine];
}
onChange(newLines);
setIsDialogOpen(false);
resetForm();
};
// 총 금액 계산
const getTotalAmount = () => {
return bomLines.reduce((sum, line) => {
const lineTotal = (line.unitPrice || 0) * line.quantity;
return sum + lineTotal;
}, 0);
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>BOM ()</CardTitle>
<CardDescription>
({bomLines.length} )
</CardDescription>
</div>
<Button onClick={handleAdd} disabled={disabled}>
<Plus className="w-4 h-4 mr-2" />
BOM
</Button>
</div>
</CardHeader>
<CardContent>
{bomLines.length === 0 ? (
<div className="text-center py-8 text-gray-500">
. BOM을 .
</div>
) : (
<>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"> </TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomLines.map((line, index) => (
<TableRow key={line.id}>
<TableCell className="font-mono text-sm">
{line.childItemCode}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="flex-1">
{line.childItemName}
{line.isBending && (
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded ml-2">
</span>
)}
</div>
{line.bendingDiagram && (
<div className="relative group">
<img
src={line.bendingDiagram}
alt="전개도"
className="w-12 h-12 object-contain border rounded cursor-pointer hover:scale-110 transition-transform"
onClick={() => handleEdit(index)}
title="클릭하여 전개도 보기/편집"
/>
</div>
)}
</div>
</TableCell>
<TableCell>{line.quantity}</TableCell>
<TableCell>{line.unit}</TableCell>
<TableCell className="text-right">
{line.unitPrice ? `${line.unitPrice.toLocaleString()}` : '-'}
</TableCell>
<TableCell className="text-right font-medium">
{line.unitPrice
? `${(line.unitPrice * line.quantity).toLocaleString()}`
: '-'}
</TableCell>
<TableCell>
{line.quantityFormula ? (
<div className="flex items-center gap-1 text-sm text-blue-600">
<Calculator className="w-3 h-3" />
{line.quantityFormula}
</div>
) : (
'-'
)}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(index)}
disabled={disabled}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(index)}
disabled={disabled}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 총 금액 */}
<div className="mt-4 flex justify-end">
<div className="bg-gray-50 px-4 py-3 rounded-lg">
<p className="text-sm text-gray-600"> </p>
<p className="text-xl font-bold">
{getTotalAmount().toLocaleString()}
</p>
</div>
</div>
</>
)}
{/* BOM 추가/수정 다이얼로그 */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingIndex !== null ? 'BOM 수정' : 'BOM 추가'}
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
{/* 품목 코드 */}
<div className="space-y-2">
<Label htmlFor="childItemCode">
<span className="text-red-500 ml-1">*</span>
</Label>
<Input
id="childItemCode"
placeholder="예: KD-PT-001"
value={formData.childItemCode}
onChange={(e) =>
setFormData({ ...formData, childItemCode: e.target.value })
}
/>
</div>
{/* 품목명 */}
<div className="space-y-2">
<Label htmlFor="childItemName">
<span className="text-red-500 ml-1">*</span>
</Label>
<Input
id="childItemName"
placeholder="품목명"
value={formData.childItemName}
onChange={(e) =>
setFormData({ ...formData, childItemName: e.target.value })
}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{/* 수량 */}
<div className="space-y-2">
<Label htmlFor="quantity">
<span className="text-red-500 ml-1">*</span>
</Label>
<Input
id="quantity"
type="number"
min="0"
step="0.01"
value={formData.quantity}
onChange={(e) =>
setFormData({ ...formData, quantity: parseFloat(e.target.value) || 0 })
}
/>
</div>
{/* 단위 */}
<div className="space-y-2">
<Label htmlFor="unit">
<span className="text-red-500 ml-1">*</span>
</Label>
<Input
id="unit"
placeholder="EA"
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
/>
</div>
{/* 단가 */}
<div className="space-y-2">
<Label htmlFor="unitPrice"> ()</Label>
<Input
id="unitPrice"
type="number"
min="0"
placeholder="0"
value={formData.unitPrice || ''}
onChange={(e) =>
setFormData({
...formData,
unitPrice: parseFloat(e.target.value) || undefined,
})
}
/>
</div>
</div>
{/* 수량 계산식 */}
<div className="space-y-2">
<Label htmlFor="quantityFormula">
<span className="text-sm text-gray-500 ml-2">
() : W * 2, H + 100
</span>
</Label>
<Input
id="quantityFormula"
placeholder="예: W * 2, H + 100"
value={formData.quantityFormula || ''}
onChange={(e) =>
setFormData({ ...formData, quantityFormula: e.target.value || undefined })
}
/>
<p className="text-xs text-gray-500">
변수: W (), H (), L (), Q ()
</p>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Input
id="note"
placeholder="비고"
value={formData.note || ''}
onChange={(e) =>
setFormData({ ...formData, note: e.target.value || undefined })
}
/>
</div>
{/* 절곡품 여부 */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isBending"
checked={formData.isBending || false}
onChange={(e) =>
setFormData({ ...formData, isBending: e.target.checked })
}
className="w-4 h-4"
/>
<Label htmlFor="isBending" className="cursor-pointer">
( )
</Label>
</div>
{/* 전개도 그리기 버튼 (절곡품인 경우만 표시) */}
{formData.isBending && (
<div className="pl-6">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsDrawingOpen(true)}
className="w-full"
>
<ImagePlus className="w-4 h-4 mr-2" />
{formData.bendingDiagram ? '전개도 수정' : '전개도 그리기'}
</Button>
{formData.bendingDiagram && (
<div className="mt-2 p-2 border rounded bg-gray-50">
<p className="text-xs text-gray-600 mb-2"> :</p>
<img
src={formData.bendingDiagram}
alt="전개도"
className="w-full h-32 object-contain bg-white border rounded"
/>
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsDialogOpen(false);
resetForm();
}}
>
</Button>
<Button type="button" onClick={handleSubmit}>
{editingIndex !== null ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 전개도 그리기 캔버스 */}
<DrawingCanvas
open={isDrawingOpen}
onOpenChange={setIsDrawingOpen}
onSave={(imageData) => {
setFormData({ ...formData, bendingDiagram: imageData });
}}
initialImage={formData.bendingDiagram}
title="절곡품 전개도 그리기"
description="절곡 부품의 전개도를 그리거나 편집합니다."
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,401 @@
/**
* Canvas 기반 이미지 그리기 컴포넌트
*
* 절곡품 전개도 등 이미지를 직접 그리거나 편집
*/
'use client';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Pen,
Square,
Circle,
Type,
Minus,
Eraser,
Trash2,
Undo2,
Save,
} from 'lucide-react';
interface DrawingCanvasProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave?: (imageData: string) => void;
initialImage?: string;
title?: string;
description?: string;
}
type Tool = 'pen' | 'line' | 'rect' | 'circle' | 'text' | 'eraser';
export function DrawingCanvas({
open,
onOpenChange,
onSave,
initialImage,
title = '이미지 편집기',
description = '품목 이미지를 그리거나 편집합니다.',
}: DrawingCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [tool, setTool] = useState<Tool>('pen');
const [color, setColor] = useState('#000000');
const [lineWidth, setLineWidth] = useState(2);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [history, setHistory] = useState<ImageData[]>([]);
const [historyStep, setHistoryStep] = useState(-1);
const colors = [
'#000000',
'#FF0000',
'#00FF00',
'#0000FF',
'#FFFF00',
'#FF00FF',
'#00FFFF',
'#FFA500',
'#800080',
'#FFC0CB',
];
useEffect(() => {
if (open && canvasRef.current) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (ctx) {
// 캔버스 초기화
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 초기 이미지가 있으면 로드
if (initialImage) {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0);
saveToHistory();
};
img.src = initialImage;
} else {
saveToHistory();
}
}
}
}, [open, initialImage]);
const saveToHistory = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const newHistory = history.slice(0, historyStep + 1);
newHistory.push(imageData);
setHistory(newHistory);
setHistoryStep(newHistory.length - 1);
};
const undo = () => {
if (historyStep > 0) {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const newStep = historyStep - 1;
ctx.putImageData(history[newStep], 0, 0);
setHistoryStep(newStep);
}
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
saveToHistory();
};
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
const pos = getMousePos(e);
setStartPos(pos);
setIsDrawing(true);
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (tool === 'pen' || tool === 'eraser') {
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
if (tool === 'eraser') {
ctx.globalCompositeOperation = 'destination-out';
} else {
ctx.globalCompositeOperation = 'source-over';
}
}
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const pos = getMousePos(e);
if (tool === 'pen' || tool === 'eraser') {
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
} else {
// 도형 그리기: 임시로 표시하기 위해 이전 상태 복원
if (historyStep >= 0) {
ctx.putImageData(history[historyStep], 0, 0);
}
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = color;
ctx.fillStyle = color;
if (tool === 'line') {
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
} else if (tool === 'rect') {
const width = pos.x - startPos.x;
const height = pos.y - startPos.y;
ctx.strokeRect(startPos.x, startPos.y, width, height);
} else if (tool === 'circle') {
const radius = Math.sqrt(
Math.pow(pos.x - startPos.x, 2) + Math.pow(pos.y - startPos.y, 2)
);
ctx.beginPath();
ctx.arc(startPos.x, startPos.y, radius, 0, 2 * Math.PI);
ctx.stroke();
}
}
};
const stopDrawing = () => {
if (isDrawing) {
setIsDrawing(false);
saveToHistory();
}
};
const handleSave = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const imageData = canvas.toDataURL('image/png');
onSave?.(imageData);
onOpenChange(false);
};
const handleText = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const text = prompt('입력할 텍스트:');
if (text) {
ctx.font = `${lineWidth * 8}px sans-serif`;
ctx.fillStyle = color;
ctx.fillText(text, 50, 50);
saveToHistory();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 도구 모음 */}
<div className="space-y-3 p-3 border rounded-lg bg-muted/30">
{/* 첫 번째 줄: 그리기 도구 */}
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={tool === 'pen' ? 'default' : 'outline'}
size="sm"
onClick={() => setTool('pen')}
type="button"
>
<Pen className="h-4 w-4" />
</Button>
<Button
variant={tool === 'line' ? 'default' : 'outline'}
size="sm"
onClick={() => setTool('line')}
type="button"
>
<Minus className="h-4 w-4" />
</Button>
<Button
variant={tool === 'rect' ? 'default' : 'outline'}
size="sm"
onClick={() => setTool('rect')}
type="button"
>
<Square className="h-4 w-4" />
</Button>
<Button
variant={tool === 'circle' ? 'default' : 'outline'}
size="sm"
onClick={() => setTool('circle')}
type="button"
>
<Circle className="h-4 w-4" />
</Button>
<Button
variant={tool === 'text' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setTool('text');
handleText();
}}
type="button"
>
<Type className="h-4 w-4" />
</Button>
<Button
variant={tool === 'eraser' ? 'default' : 'outline'}
size="sm"
onClick={() => setTool('eraser')}
type="button"
>
<Eraser className="h-4 w-4" />
</Button>
<div className="h-6 w-px bg-border mx-1" />
<Button
variant="outline"
size="sm"
onClick={undo}
disabled={historyStep <= 0}
type="button"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={clearCanvas}
type="button"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* 두 번째 줄: 색상 팔레트 */}
<div className="flex items-center gap-2 flex-wrap">
<Label className="text-sm whitespace-nowrap">:</Label>
<div className="flex gap-1 flex-wrap">
{colors.map((c) => (
<button
key={c}
type="button"
className={`w-6 h-6 rounded border-2 ${
color === c ? 'border-primary' : 'border-transparent'
}`}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
/>
))}
</div>
</div>
{/* 세 번째 줄: 선 두께 조절 */}
<div className="flex items-center gap-4">
<Label className="text-sm whitespace-nowrap">
: {lineWidth}px
</Label>
<Slider
value={[lineWidth]}
onValueChange={(value) => setLineWidth(value[0])}
min={1}
max={20}
step={1}
className="flex-1"
/>
</div>
</div>
{/* 캔버스 */}
<div className="border rounded-lg overflow-hidden bg-white w-full">
<canvas
ref={canvasRef}
width={600}
height={400}
className="cursor-crosshair w-full block"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
</div>
{/* 하단 버튼 */}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
type="button"
>
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={handleSave}
type="button"
>
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,211 @@
/**
* 파일 업로드 컴포넌트
*
* 시방서, 인정서, 전개도 등 파일 업로드 UI
*/
'use client';
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { X, Upload, FileText, CheckCircle2 } from 'lucide-react';
interface FileUploadProps {
label: string;
accept?: string;
maxSize?: number; // MB
currentFile?: {
url: string;
filename: string;
};
onFileSelect: (file: File) => void;
onFileRemove?: () => void;
disabled?: boolean;
required?: boolean;
}
export default function FileUpload({
label,
accept = '*/*',
maxSize = 10, // 10MB default
currentFile,
onFileSelect,
onFileRemove,
disabled = false,
required = false,
}: FileUploadProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (file: File | null) => {
if (!file) {
setSelectedFile(null);
setError(null);
return;
}
// 파일 크기 검증
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxSize) {
setError(`파일 크기는 ${maxSize}MB 이하여야 합니다`);
return;
}
setError(null);
setSelectedFile(file);
onFileSelect(file);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
handleFileChange(file);
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = e.dataTransfer.files?.[0] || null;
handleFileChange(file);
};
const handleRemove = () => {
setSelectedFile(null);
setError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onFileRemove?.();
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<div className="space-y-2">
<Label htmlFor={`file-upload-${label}`}>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
{/* 파일 입력 (숨김) */}
<Input
ref={fileInputRef}
id={`file-upload-${label}`}
type="file"
accept={accept}
onChange={handleInputChange}
disabled={disabled}
className="hidden"
/>
{/* 드래그 앤 드롭 영역 */}
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleClick}
className={`
border-2 border-dashed rounded-lg p-6
flex flex-col items-center justify-center
cursor-pointer transition-colors
${isDragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary/50'}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
{selectedFile || currentFile ? (
<div className="w-full">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="w-10 h-10 text-primary" />
<div>
<p className="font-medium text-sm">
{selectedFile?.name || currentFile?.filename}
</p>
<p className="text-xs text-gray-500">
{selectedFile
? `${(selectedFile.size / 1024).toFixed(1)} KB`
: currentFile?.url && (
<a
href={currentFile.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
</a>
)}
</p>
</div>
{selectedFile && (
<CheckCircle2 className="w-5 h-5 text-green-500 ml-2" />
)}
</div>
{!disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRemove();
}}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
) : (
<>
<Upload className="w-12 h-12 text-gray-400 mb-3" />
<p className="text-sm text-gray-600 mb-1">
</p>
<p className="text-xs text-gray-500">
{maxSize}MB
</p>
</>
)}
</div>
{/* 에러 메시지 */}
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{/* 도움말 */}
{!error && accept !== '*/*' && (
<p className="text-xs text-gray-500">
: {accept}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,399 @@
/**
* 품목 상세 조회 Client Component
*
* 품목 정보를 읽기 전용으로 표시
*/
'use client';
import { useRouter } from 'next/navigation';
import type { ItemMaster } from '@/types/item';
import { ITEM_TYPE_LABELS, _PART_TYPE_LABELS, _PART_USAGE_LABELS, PRODUCT_CATEGORY_LABELS } from '@/types/item';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ArrowLeft, Edit, Package } from 'lucide-react';
interface ItemDetailClientProps {
item: ItemMaster;
}
/**
* 품목 유형별 Badge 반환
*/
function getItemTypeBadge(itemType: string) {
const badges: Record<string, { className: string }> = {
FG: { className: 'bg-purple-50 text-purple-700 border-purple-200' },
PT: { className: 'bg-orange-50 text-orange-700 border-orange-200' },
SM: { className: 'bg-green-50 text-green-700 border-green-200' },
RM: { className: 'bg-blue-50 text-blue-700 border-blue-200' },
CS: { className: 'bg-gray-50 text-gray-700 border-gray-200' },
};
const config = badges[itemType] || { className: '' };
return (
<Badge variant="outline" className={config.className}>
{ITEM_TYPE_LABELS[itemType as keyof typeof ITEM_TYPE_LABELS]}
</Badge>
);
}
/**
* 조립품 품목코드 포맷팅 (하이픈 이후 제거)
*/
function formatItemCodeForAssembly(item: ItemMaster): string {
return item.itemCode;
}
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
const router = useRouter();
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<h1 className="text-xl md:text-2xl"> </h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button
type="button"
onClick={() => router.push(`/items/${encodeURIComponent(item.itemCode)}/edit`)}
>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
{formatItemCodeForAssembly(item)}
</code>
</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 font-medium">{item.itemName}</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">{getItemTypeBadge(item.itemType)}</p>
</div>
{item.itemType === "PT" && item.partType && (
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1">
<Badge variant="outline" className={
item.partType === 'ASSEMBLY' ? 'bg-blue-50 text-blue-700' :
item.partType === 'BENDING' ? 'bg-purple-50 text-purple-700' :
item.partType === 'PURCHASED' ? 'bg-green-50 text-green-700' :
'bg-gray-50 text-gray-700'
}>
{item.partType === 'ASSEMBLY' ? '조립 부품' :
item.partType === 'BENDING' ? '절곡 부품' :
item.partType === 'PURCHASED' ? '구매 부품' :
item.partType}
</Badge>
</p>
</div>
)}
{item.itemType === "PT" && item.partType === "BENDING" && item.partUsage && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-indigo-50 text-indigo-700">
{item.partUsage === "GUIDE_RAIL" ? "가이드레일용" :
item.partUsage === "BOTTOM_FINISH" ? "하단마감재용" :
item.partUsage === "CASE" ? "케이스용" :
item.partUsage === "DOOR" ? "도어용" :
item.partUsage === "BRACKET" ? "브라켓용" :
item.partUsage === "GENERAL" ? "범용 (공통 부품)" :
item.partUsage}
</Badge>
</p>
</div>
)}
{item.itemType !== "FG" && item.partType !== 'ASSEMBLY' && item.specification && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">{item.specification}</p>
</div>
)}
{item.itemType !== "FG" && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="secondary">{item.unit}</Badge>
</p>
</div>
)}
{/* 버전 정보 */}
<div className="md:col-span-2 lg:col-span-3 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label className="text-muted-foreground"> </Label>
<div className="mt-1">
<Badge variant="secondary">V{item.currentRevision || 0}</Badge>
</div>
</div>
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1">{(item.revisions?.length || 0)}</p>
</div>
</div>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm">{new Date(item.createdAt).toLocaleDateString('ko-KR')}</p>
</div>
</div>
</CardContent>
</Card>
{/* 제품(FG) 전용 정보 */}
{item.itemType === 'FG' && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{item.productCategory && (
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1 font-medium">{PRODUCT_CATEGORY_LABELS[item.productCategory]}</p>
</div>
)}
{item.lotAbbreviation && (
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1 font-medium">{item.lotAbbreviation}</p>
</div>
)}
</div>
{item.note && (
<div className="mt-4">
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm whitespace-pre-wrap">{item.note}</p>
</div>
)}
</CardContent>
</Card>
)}
{/* 조립 부품 세부 정보 */}
{item.itemType === 'PT' && item.partType === 'ASSEMBLY' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{item.category1 && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-indigo-50 text-indigo-700">
{item.category1 === 'guide_rail' ? '가이드레일' :
item.category1 === 'case' ? '케이스' :
item.category1 === 'bottom_finish' ? '하단마감재' :
item.category1}
</Badge>
</p>
</div>
)}
{item.installationType && (
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1">
<Badge variant="outline" className="bg-green-50 text-green-700">
{item.installationType === 'wall' ? '벽면형 (R)' :
item.installationType === 'side' ? '측면형 (S)' :
item.installationType === 'steel' ? '스틸 (B)' :
item.installationType === 'iron' ? '철재 (T)' :
item.installationType}
</Badge>
</p>
</div>
)}
{item.assemblyType && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
{item.assemblyType}
</code>
</p>
</div>
)}
{item.material && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-indigo-50 text-indigo-700">
{item.material}
</Badge>
</p>
</div>
)}
{item.assemblyLength && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 font-medium">{item.assemblyLength}mm</p>
</div>
)}
{item.sideSpecWidth && item.sideSpecHeight && (
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1">
{item.sideSpecWidth} × {item.sideSpecHeight}mm
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* 가이드레일 세부 정보 */}
{item.category3 === "가이드레일" && item.guideRailModelType && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{item.guideRailModelType && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-orange-50 text-orange-700">
{item.guideRailModelType}
</Badge>
</p>
</div>
)}
{item.guideRailModel && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-orange-50 text-orange-700">
{item.guideRailModel}
</Badge>
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* BOM 정보 - 절곡 부품은 제외 */}
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
(BOM)
</CardTitle>
<Badge variant="outline" className="bg-blue-50 text-blue-700">
{item.bom.length}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{item.bom.map((line, index) => (
<TableRow key={line.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{line.childItemCode}
</code>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{line.childItemName}
{line.isBending && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700">
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">{line.quantity}</TableCell>
<TableCell>{line.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,585 @@
/**
* 품목 목록 Client Component
*
* Server Component에서 받은 데이터를 표시하고 상호작용 처리
*/
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { ItemMaster } from '@/types/item';
import { ITEM_TYPE_LABELS } from '@/types/item';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Search, Plus, Edit, Trash2, Package, GitBranch, ChevronLeft, ChevronRight } from 'lucide-react';
interface ItemListClientProps {
items: ItemMaster[];
}
/**
* 품목 유형별 Badge 색상 반환
*/
function getItemTypeBadge(itemType: string) {
const badges: Record<string, { variant: 'default' | 'secondary' | 'outline' | 'destructive'; className: string }> = {
FG: { variant: 'default', className: 'bg-purple-100 text-purple-700 border-purple-200' },
PT: { variant: 'default', className: 'bg-orange-100 text-orange-700 border-orange-200' },
SM: { variant: 'default', className: 'bg-green-100 text-green-700 border-green-200' },
RM: { variant: 'default', className: 'bg-blue-100 text-blue-700 border-blue-200' },
CS: { variant: 'default', className: 'bg-gray-100 text-gray-700 border-gray-200' },
};
const config = badges[itemType] || { variant: 'outline' as const, className: '' };
return (
<Badge variant="outline" className={config.className}>
{ITEM_TYPE_LABELS[itemType as keyof typeof ITEM_TYPE_LABELS]}
</Badge>
);
}
/**
* 조립품 품목코드 포맷팅 (하이픈 이후 제거)
*/
function formatItemCodeForAssembly(item: ItemMaster): string {
return item.itemCode;
}
export default function ItemListClient({ items: initialItems }: ItemListClientProps) {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [selectedType, setSelectedType] = useState<string>('all');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [selectAll, setSelectAll] = useState(false);
// 페이징 상태
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 필터링된 품목 목록
const filteredItems = initialItems.filter((item) => {
// 유형 필터
if (selectedType !== 'all' && item.itemType !== selectedType) {
return false;
}
// 검색어 필터
if (searchTerm) {
const search = searchTerm.toLowerCase();
return (
item.itemCode.toLowerCase().includes(search) ||
item.itemName.toLowerCase().includes(search) ||
item.specification?.toLowerCase().includes(search)
);
}
return true;
});
// 페이징 계산
const totalPages = Math.ceil(filteredItems.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedItems = filteredItems.slice(startIndex, endIndex);
// 검색이나 필터 변경 시 첫 페이지로 이동
const handleSearchChange = (value: string) => {
setSearchTerm(value);
setCurrentPage(1);
};
const handleTypeChange = (value: string) => {
setSelectedType(value);
setCurrentPage(1);
};
const handleView = (itemCode: string) => {
router.push(`/items/${encodeURIComponent(itemCode)}`);
};
const handleEdit = (itemCode: string) => {
router.push(`/items/${encodeURIComponent(itemCode)}/edit`);
};
const handleDelete = async (itemCode: string) => {
if (!confirm(`품목 "${itemCode}"을(를) 삭제하시겠습니까?`)) {
return;
}
try {
// TODO: API 연동 시 실제 삭제 로직 추가
// await deleteItem(itemCode);
alert('품목이 삭제되었습니다.');
router.refresh();
} catch {
alert('품목 삭제에 실패했습니다.');
}
};
// 체크박스 전체 선택/해제 (현재 페이지만)
const handleSelectAll = () => {
if (selectAll) {
setSelectedItems(new Set());
setSelectAll(false);
} else {
const allIds = new Set(paginatedItems.map((item) => item.id));
setSelectedItems(allIds);
setSelectAll(true);
}
};
// 개별 체크박스 선택/해제
const handleSelectItem = (itemId: string) => {
const newSelected = new Set(selectedItems);
if (newSelected.has(itemId)) {
newSelected.delete(itemId);
setSelectAll(false);
} else {
newSelected.add(itemId);
if (newSelected.size === paginatedItems.length) {
setSelectAll(true);
}
}
setSelectedItems(newSelected);
};
// 통계 데이터
const stats = [
{
label: '전체 품목',
value: initialItems.length,
icon: Package,
iconColor: 'text-blue-600',
},
{
label: '제품',
value: initialItems.filter((i) => i.itemType === 'FG').length,
icon: Package,
iconColor: 'text-purple-600',
},
{
label: '부품',
value: initialItems.filter((i) => i.itemType === 'PT').length,
icon: Package,
iconColor: 'text-orange-600',
},
{
label: '부자재',
value: initialItems.filter((i) => i.itemType === 'SM').length,
icon: Package,
iconColor: 'text-green-600',
},
];
return (
<div className="space-y-6">
{/* 페이지 헤더 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl md:text-2xl"> </h1>
<Badge variant="secondary" className="gap-1">
<GitBranch className="h-3 w-3" />
v1.0.0
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
, , , ,
</p>
</div>
</div>
<div className="flex gap-2 flex-wrap items-center">
<Button onClick={() => router.push('/items/create')}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<Card key={index}>
<CardContent className="p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
{stat.label}
</p>
<p className="text-3xl md:text-4xl font-bold mt-2">{stat.value}</p>
</div>
<stat.icon className={`w-10 h-10 md:w-12 md:h-12 opacity-15 ${stat.iconColor}`} />
</div>
</CardContent>
</Card>
))}
</div>
{/* 검색 및 필터 */}
<Card>
<CardContent className="p-4 md:p-6">
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="품목코드, 품목명, 규격 검색..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={selectedType} onValueChange={handleTypeChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="품목 유형" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
({initialItems.length})
</SelectItem>
<SelectItem value="FG">
({initialItems.filter((i) => i.itemType === 'FG').length})
</SelectItem>
<SelectItem value="PT">
({initialItems.filter((i) => i.itemType === 'PT').length})
</SelectItem>
<SelectItem value="SM">
({initialItems.filter((i) => i.itemType === 'SM').length})
</SelectItem>
<SelectItem value="RM">
({initialItems.filter((i) => i.itemType === 'RM').length})
</SelectItem>
<SelectItem value="CS">
({initialItems.filter((i) => i.itemType === 'CS').length})
</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 품목 목록 - 탭과 테이블 */}
<Card>
<CardHeader>
<CardTitle className="text-sm md:text-base">
{selectedType === 'all'
? `전체 목록 (${filteredItems.length}개)`
: `${ITEM_TYPE_LABELS[selectedType as keyof typeof ITEM_TYPE_LABELS]} 목록 (${filteredItems.length}개)`
}
</CardTitle>
</CardHeader>
<CardContent className="p-4 md:p-6">
<Tabs value={selectedType} onValueChange={handleTypeChange} className="w-full">
<div className="overflow-x-auto -mx-2 px-2 mb-6">
<TabsList className="inline-flex w-auto min-w-full md:grid md:w-full md:max-w-2xl md:grid-cols-6">
<TabsTrigger value="all" className="whitespace-nowrap">
({initialItems.length})
</TabsTrigger>
<TabsTrigger value="FG" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'FG').length})
</TabsTrigger>
<TabsTrigger value="PT" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'PT').length})
</TabsTrigger>
<TabsTrigger value="SM" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'SM').length})
</TabsTrigger>
<TabsTrigger value="RM" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'RM').length})
</TabsTrigger>
<TabsTrigger value="CS" className="whitespace-nowrap">
({initialItems.filter((i) => i.itemType === 'CS').length})
</TabsTrigger>
</TabsList>
</div>
<TabsContent value={selectedType} className="mt-0">
{/* 모바일 카드 뷰 */}
<div className="lg:hidden space-y-3">
{paginatedItems.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border rounded-lg">
{searchTerm || selectedType !== 'all'
? '검색 결과가 없습니다.'
: '등록된 품목이 없습니다.'}
</div>
) : (
paginatedItems.map((item, index) => (
<div
key={`${item.id}-mobile-${index}`}
className="border rounded-lg p-4 space-y-3 bg-card hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => handleSelectItem(item.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{formatItemCodeForAssembly(item)}
</code>
{getItemTypeBadge(item.itemType)}
{item.itemType === 'PT' && item.partType && (
<Badge variant="outline" className="ml-1 bg-purple-50 text-purple-700 text-xs">
{item.partType === 'ASSEMBLY' ? '조립' :
item.partType === 'BENDING' ? '절곡' :
item.partType === 'PURCHASED' ? '구매' : ''}
</Badge>
)}
</div>
<div
className="font-medium cursor-pointer"
onClick={() => handleView(item.itemCode)}
>
{item.itemName}
</div>
{(item.specification || (item.itemCode?.includes('-') && item.itemCode.split('-').slice(1).join('-'))) && (
<div className="text-sm text-muted-foreground">
: {item.itemCode?.includes('-')
? item.itemCode.split('-').slice(1).join('-')
: item.specification}
</div>
)}
{item.unit && (
<div>
<Badge variant="secondary" className="text-xs">{item.unit}</Badge>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-1 pt-2 border-t">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(item.itemCode)}
className="h-8 px-3"
>
<Search className="h-4 w-4 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.itemCode)}
className="h-8 px-3"
>
<Edit className="h-4 w-4 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.itemCode)}
className="h-8 px-2"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
))
)}
</div>
{/* 데스크톱 테이블 */}
<div className="hidden lg:block rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={selectAll}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="hidden md:table-cell"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="hidden md:table-cell"></TableHead>
<TableHead className="hidden md:table-cell"></TableHead>
<TableHead className="min-w-[100px] whitespace-nowrap"> </TableHead>
<TableHead className="text-right min-w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedItems.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-gray-500">
{searchTerm || selectedType !== 'all'
? '검색 결과가 없습니다.'
: '등록된 품목이 없습니다.'}
</TableCell>
</TableRow>
) : (
paginatedItems.map((item, index) => (
<TableRow key={item.id} className="hover:bg-gray-50">
<TableCell>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => handleSelectItem(item.id)}
/>
</TableCell>
<TableCell className="text-muted-foreground cursor-pointer hidden md:table-cell">
{filteredItems.length - (startIndex + index)}
</TableCell>
<TableCell className="cursor-pointer">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{formatItemCodeForAssembly(item) || '-'}
</code>
</TableCell>
<TableCell className="cursor-pointer">
{getItemTypeBadge(item.itemType)}
{item.itemType === 'PT' && item.partType && (
<Badge variant="outline" className="ml-1 bg-purple-50 text-purple-700 text-xs hidden lg:inline-flex">
{item.partType === 'ASSEMBLY' ? '조립' :
item.partType === 'BENDING' ? '절곡' :
item.partType === 'PURCHASED' ? '구매' : ''}
</Badge>
)}
</TableCell>
<TableCell className="cursor-pointer">
<div className="flex items-center gap-2">
<span className="truncate max-w-[150px] md:max-w-none">
{item.itemName}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground cursor-pointer hidden md:table-cell">
{item.itemCode?.includes('-')
? item.itemCode.split('-').slice(1).join('-')
: item.specification || '-'}
</TableCell>
<TableCell className="cursor-pointer hidden md:table-cell">
<Badge variant="secondary">{item.unit || '-'}</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant={item.isActive ? 'default' : 'secondary'}>
{item.isActive ? '활성' : '비활성'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(item.itemCode)}
title="상세 보기"
>
<Search className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.itemCode)}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.itemCode)}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{filteredItems.length > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
<div className="text-sm text-muted-foreground">
{filteredItems.length} {startIndex + 1}-{Math.min(endIndex, filteredItems.length)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((page) => {
// 현재 페이지 주변 5개만 표시
return (
page === 1 ||
page === totalPages ||
(page >= currentPage - 2 && page <= currentPage + 2)
);
})
.map((page, index, array) => {
// 페이지 번호 사이에 ... 표시
const showEllipsisBefore = index > 0 && array[index - 1] !== page - 1;
return (
<div key={page} className="flex items-center gap-1">
{showEllipsisBefore && (
<span className="px-2 text-muted-foreground">...</span>
)}
<Button
variant={currentPage === page ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentPage(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
/**
* 품목 유형 선택 컴포넌트
*
* FG/PT/SM/RM/CS 선택 UI
*/
'use client';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { type ItemType } from '@/types/item';
interface ItemTypeSelectProps {
value?: ItemType;
onChange: (value: ItemType) => void;
disabled?: boolean;
required?: boolean;
label?: string;
showLabel?: boolean;
description?: string;
}
// 품목 유형 라벨 (영문 포함)
const ITEM_TYPE_LABELS_WITH_ENGLISH: Record<ItemType, string> = {
FG: '제품 (Finished Goods)',
PT: '부품 (Part)',
SM: '부자재 (Sub Material)',
RM: '원자재 (Raw Material)',
CS: '소모품 (Consumables)',
};
export default function ItemTypeSelect({
value,
onChange,
disabled = false,
required = false,
label = '품목 유형',
showLabel = true,
description = '(먼저 선택하세요)',
}: ItemTypeSelectProps) {
return (
<div className="space-y-2">
{showLabel && (
<Label htmlFor="item-type-select">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
{description}
</Label>
)}
<Select
value={value}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger id="item-type-select">
<SelectValue placeholder="품목 유형을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="FG">{ITEM_TYPE_LABELS_WITH_ENGLISH.FG}</SelectItem>
<SelectItem value="PT">{ITEM_TYPE_LABELS_WITH_ENGLISH.PT}</SelectItem>
<SelectItem value="SM">{ITEM_TYPE_LABELS_WITH_ENGLISH.SM}</SelectItem>
<SelectItem value="RM">{ITEM_TYPE_LABELS_WITH_ENGLISH.RM}</SelectItem>
<SelectItem value="CS">{ITEM_TYPE_LABELS_WITH_ENGLISH.CS}</SelectItem>
</SelectContent>
</Select>
</div>
);
}