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:
292
src/components/items/BOMManagementSection.tsx
Normal file
292
src/components/items/BOMManagementSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
486
src/components/items/BOMManager.tsx
Normal file
486
src/components/items/BOMManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
401
src/components/items/DrawingCanvas.tsx
Normal file
401
src/components/items/DrawingCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src/components/items/FileUpload.tsx
Normal file
211
src/components/items/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
399
src/components/items/ItemDetailClient.tsx
Normal file
399
src/components/items/ItemDetailClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2600
src/components/items/ItemForm.tsx
Normal file
2600
src/components/items/ItemForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
585
src/components/items/ItemListClient.tsx
Normal file
585
src/components/items/ItemListClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5934
src/components/items/ItemMasterDataManagement.tsx
Normal file
5934
src/components/items/ItemMasterDataManagement.tsx
Normal file
File diff suppressed because it is too large
Load Diff
76
src/components/items/ItemTypeSelect.tsx
Normal file
76
src/components/items/ItemTypeSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -56,44 +56,15 @@ export default function Sidebar({
|
||||
<div className={`h-full flex flex-col clean-glass rounded-2xl overflow-hidden transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'sidebar-collapsed' : ''
|
||||
}`}>
|
||||
{/* 로고 */}
|
||||
<div
|
||||
className={`text-white relative transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'p-5' : 'p-6 md:p-8'
|
||||
}`}
|
||||
style={{ backgroundColor: '#3B82F6' }}
|
||||
>
|
||||
<div className={`flex items-center relative z-10 transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'justify-center' : 'space-x-4'
|
||||
}`}>
|
||||
<div className={`rounded-xl flex items-center justify-center clean-shadow backdrop-blur-sm transition-all duration-300 sidebar-logo relative overflow-hidden ${
|
||||
sidebarCollapsed ? 'w-11 h-11' : 'w-12 h-12 md:w-14 md:h-14'
|
||||
}`} style={{ backgroundColor: '#3B82F6' }}>
|
||||
<div className={`text-white font-bold transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'text-lg' : 'text-xl md:text-2xl'
|
||||
}`}>
|
||||
S
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent opacity-30"></div>
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="transition-all duration-300 opacity-100">
|
||||
<h1 className="text-xl md:text-2xl font-bold tracking-wide">SAM</h1>
|
||||
<p className="text-sm text-white/90 font-medium">Smart Automation Management</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 */}
|
||||
<div
|
||||
ref={menuContainerRef}
|
||||
className={`sidebar-scroll flex-1 overflow-y-auto transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'px-3 py-4' : 'p-4 md:p-6'
|
||||
sidebarCollapsed ? 'px-3 py-4' : 'px-4 py-3 md:px-6 md:py-4'
|
||||
}`}
|
||||
>
|
||||
<div className={`transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'space-y-2' : 'space-y-3'
|
||||
sidebarCollapsed ? 'space-y-2 mt-4' : 'space-y-3 mt-3'
|
||||
}`}>
|
||||
{menuItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
@@ -111,7 +82,7 @@ export default function Sidebar({
|
||||
<button
|
||||
onClick={() => handleMenuClick(item.id, item.path, !!hasChildren)}
|
||||
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
|
||||
sidebarCollapsed ? 'p-3 justify-center' : 'space-x-3 p-3 md:p-4'
|
||||
sidebarCollapsed ? 'p-4 justify-center' : 'space-x-3 p-4 md:p-5'
|
||||
} ${
|
||||
isActive
|
||||
? "text-white clean-shadow scale-[0.98]"
|
||||
@@ -163,14 +134,14 @@ export default function Sidebar({
|
||||
>
|
||||
<button
|
||||
onClick={() => handleMenuClick(subItem.id, subItem.path, false)}
|
||||
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2 space-x-2 group ${
|
||||
className={`w-full flex items-center rounded-lg transition-all duration-200 p-3 space-x-3 group ${
|
||||
isSubActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<SubIcon className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">{subItem.label}</span>
|
||||
<span className="text-sm font-medium">{subItem.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
40
src/components/organisms/PageHeader.tsx
Normal file
40
src/components/organisms/PageHeader.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
versionBadge?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions, icon: Icon, versionBadge }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
|
||||
<Icon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl md:text-2xl">{title}</h1>
|
||||
{versionBadge}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/organisms/PageLayout.tsx
Normal file
50
src/components/organisms/PageLayout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { useDeveloperMode, ComponentMetadata } from '@/contexts/DeveloperModeContext';
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: ReactNode;
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
devMetadata?: ComponentMetadata;
|
||||
versionInfo?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageLayout({ children, maxWidth = "full", devMetadata, versionInfo }: PageLayoutProps) {
|
||||
const { setCurrentMetadata } = useDeveloperMode();
|
||||
const metadataRef = useRef<ComponentMetadata | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Only update if metadata actually changed
|
||||
if (devMetadata && JSON.stringify(devMetadata) !== JSON.stringify(metadataRef.current)) {
|
||||
metadataRef.current = devMetadata;
|
||||
setCurrentMetadata(devMetadata);
|
||||
}
|
||||
|
||||
// 컴포넌트 언마운트 시 메타데이터 초기화
|
||||
return () => {
|
||||
setCurrentMetadata(null);
|
||||
metadataRef.current = null;
|
||||
};
|
||||
}, []); // Empty dependency array - only run on mount/unmount
|
||||
|
||||
const maxWidthClasses = {
|
||||
sm: "max-w-3xl",
|
||||
md: "max-w-5xl",
|
||||
lg: "max-w-6xl",
|
||||
xl: "max-w-7xl",
|
||||
"2xl": "max-w-[1600px]",
|
||||
full: "w-full"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 md:p-6 space-y-4 md:space-y-6 ${maxWidthClasses[maxWidth]} mx-auto w-full relative`}>
|
||||
{versionInfo && (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
{versionInfo}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = "AlertDialogOverlay";
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
React.ElementRef<"h5">,
|
||||
React.ComponentPropsWithoutRef<"h5">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
|
||||
"bg-card text-card-foreground flex flex-col rounded-xl border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { VisuallyHidden } from "./visually-hidden";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@@ -131,4 +132,5 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
VisuallyHidden,
|
||||
};
|
||||
|
||||
132
src/components/ui/drawer.tsx
Normal file
132
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
disableSlideAnimation = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
|
||||
disableSlideAnimation?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
!disableSlideAnimation && "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
26
src/components/ui/slider.tsx
Normal file
26
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
31
src/components/ui/switch.tsx
Normal file
31
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
117
src/components/ui/table.tsx
Normal file
117
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
48
src/components/ui/tooltip.tsx
Normal file
48
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "./utils";
|
||||
|
||||
function TooltipProvider({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" {...props} />;
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return (
|
||||
<TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 max-w-xs overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
14
src/components/ui/visually-hidden.tsx
Normal file
14
src/components/ui/visually-hidden.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as VisuallyHiddenPrimitive from "@radix-ui/react-visually-hidden";
|
||||
|
||||
const VisuallyHidden = React.forwardRef<
|
||||
React.ElementRef<typeof VisuallyHiddenPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof VisuallyHiddenPrimitive.Root>
|
||||
>((props, ref) => (
|
||||
<VisuallyHiddenPrimitive.Root ref={ref} {...props} />
|
||||
));
|
||||
VisuallyHidden.displayName = "VisuallyHidden";
|
||||
|
||||
export { VisuallyHidden };
|
||||
Reference in New Issue
Block a user