[feat]: Item Master 데이터 관리 기능 구현 및 타입 에러 수정

- ItemMasterDataManagement 컴포넌트 구조화 (tabs, dialogs, components 분리)
- HierarchyTab 타입 에러 수정 (BOMItem section_id, updated_at 추가)
- API 클라이언트 구현 (item-master.ts, 13개 엔드포인트)
- ItemMasterContext 구현 (상태 관리 및 데이터 흐름)
- 백엔드 요구사항 문서 작성 (CORS 설정, API 스펙 등)
- SSR 호환성 수정 (navigator API typeof window 체크)
- 미사용 변수 ESLint 에러 해결
- Context 리팩토링 (AuthContext, RootProvider 추가)
- API 유틸리티 추가 (error-handler, logger, transformers)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-23 16:10:27 +09:00
parent 63f5df7d7d
commit df3db155dd
69 changed files with 31467 additions and 4796 deletions

View File

@@ -10,25 +10,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
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;
}
import type { BOMItem } from '@/contexts/ItemMasterContext';
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;
onAddItem: (item: Omit<BOMItem, 'id' | 'createdAt' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
onUpdateItem: (id: number, item: Partial<BOMItem>) => void;
onDeleteItem: (id: number) => void;
itemTypeOptions?: { value: string; label: string }[];
unitOptions?: { value: string; label: string }[];
}
@@ -53,7 +43,7 @@ export function BOMManagementSection({
],
}: BOMManagementSectionProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [itemCode, setItemCode] = useState('');
const [itemName, setItemName] = useState('');
const [quantity, setQuantity] = useState('1');
@@ -64,12 +54,12 @@ export function BOMManagementSection({
const handleOpenDialog = (item?: BOMItem) => {
if (item) {
setEditingId(item.id);
setItemCode(item.itemCode);
setItemName(item.itemName);
setItemCode(item.item_code || '');
setItemName(item.item_name);
setQuantity(item.quantity.toString());
setUnit(item.unit);
setItemType(item.itemType || 'part');
setNote(item.note || '');
setUnit(item.unit || 'EA');
setItemType('part');
setNote(item.spec || '');
} else {
setEditingId(null);
setItemCode('');
@@ -93,12 +83,11 @@ export function BOMManagementSection({
}
const itemData = {
itemCode,
itemName,
item_code: itemCode,
item_name: itemName,
quantity: qty,
unit,
itemType,
note: note.trim() || undefined,
spec: note.trim() || undefined,
};
if (editingId) {
@@ -112,7 +101,7 @@ export function BOMManagementSection({
setIsDialogOpen(false);
};
const handleDelete = (id: string) => {
const handleDelete = (id: number) => {
if (confirm('이 BOM 품목을 삭제하시겠습니까?')) {
onDeleteItem(id);
toast.success('BOM 품목이 삭제되었습니다');
@@ -159,19 +148,16 @@ export function BOMManagementSection({
<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}
<span className="font-medium">{item.item_name}</span>
{item.item_code && (
<Badge variant="outline" className="text-xs">
{item.item_code}
</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>}
: {item.quantity} {item.unit || 'EA'}
{item.spec && <span className="ml-2"> {item.spec}</span>}
</div>
</div>
<div className="flex gap-2">

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { ItemPage, ItemSection } from '@/contexts/ItemMasterContext';
export interface ConditionalFieldConfig {
fieldKey: string;
expectedValue: string;
targetFieldIds?: string[];
targetSectionIds?: string[];
}
interface ConditionalDisplayUIProps {
// States
newFieldConditionEnabled: boolean;
setNewFieldConditionEnabled: (value: boolean) => void;
newFieldConditionTargetType: 'field' | 'section';
setNewFieldConditionTargetType: (value: 'field' | 'section') => void;
newFieldConditionFields: ConditionalFieldConfig[];
setNewFieldConditionFields: (value: ConditionalFieldConfig[] | ((prev: ConditionalFieldConfig[]) => ConditionalFieldConfig[])) => void;
tempConditionValue: string;
setTempConditionValue: (value: string) => void;
// Context data
newFieldKey: string;
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
selectedPage: ItemPage | null;
selectedSectionForField: ItemSection | null;
editingFieldId: string | null;
// Constants
INPUT_TYPE_OPTIONS: Array<{value: string; label: string}>;
}
export function ConditionalDisplayUI({
newFieldConditionEnabled,
setNewFieldConditionEnabled,
newFieldConditionTargetType,
setNewFieldConditionTargetType,
newFieldConditionFields,
setNewFieldConditionFields,
tempConditionValue,
setTempConditionValue,
newFieldKey,
newFieldInputType,
selectedPage,
selectedSectionForField,
editingFieldId,
INPUT_TYPE_OPTIONS,
}: ConditionalDisplayUIProps) {
const getPlaceholderText = () => {
switch (newFieldInputType) {
case 'dropdown': return '드롭다운 옵션값을 입력하세요';
case 'checkbox': return '체크박스 상태값(true/false)을 입력하세요';
case 'textbox': return '텍스트 값을 입력하세요 (예: "제품", "부품")';
case 'number': return '숫자 값을 입력하세요 (예: 100, 200)';
case 'date': return '날짜 값을 입력하세요 (예: 2025-01-01)';
case 'textarea': return '텍스트 값을 입력하세요';
default: return '값을 입력하세요';
}
};
const handleAddCondition = () => {
if (!tempConditionValue) {
toast.error('조건값을 입력해주세요.');
return;
}
if (newFieldConditionFields.find(f => f.expectedValue === tempConditionValue)) {
toast.error('이미 추가된 조건값입니다.');
return;
}
setNewFieldConditionFields(prev => [...prev, {
fieldKey: newFieldKey,
expectedValue: tempConditionValue,
targetFieldIds: newFieldConditionTargetType === 'field' ? [] : undefined,
targetSectionIds: newFieldConditionTargetType === 'section' ? [] : undefined,
}]);
setTempConditionValue('');
toast.success('조건값이 추가되었습니다. 표시할 대상을 선택하세요.');
};
const handleRemoveCondition = (index: number) => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
toast.success('조건이 제거되었습니다.');
};
// selectedSectionForField는 이미 ItemSection 객체이므로 바로 사용
const availableFields = selectedSectionForField?.fields?.filter(f => f.id !== editingFieldId) || [];
const availableSections = selectedPage?.sections.filter(s => s.type !== 'bom') || [];
return (
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch checked={newFieldConditionEnabled} onCheckedChange={setNewFieldConditionEnabled} />
<Label className="text-base"> </Label>
</div>
<p className="text-xs text-muted-foreground pl-8">
/
</p>
</div>
{newFieldConditionEnabled && selectedSectionForField && selectedPage && (
<div className="space-y-4 pl-6 pt-3 border-l-2 border-blue-200">
{/* 대상 타입 선택 */}
<div className="space-y-2 bg-blue-50 p-3 rounded">
<Label className="text-sm font-semibold"> ?</Label>
<div className="flex gap-4 pl-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={newFieldConditionTargetType === 'field'}
onChange={() => setNewFieldConditionTargetType('field')}
className="cursor-pointer"
/>
<span className="text-sm"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={newFieldConditionTargetType === 'section'}
onChange={() => setNewFieldConditionTargetType('section')}
className="cursor-pointer"
/>
<span className="text-sm"> </span>
</label>
</div>
</div>
{/* 일반항목용 조건 설정 */}
{newFieldConditionTargetType === 'field' && (
<div className="space-y-4">
<div className="bg-yellow-50 p-3 rounded border border-yellow-200">
<p className="text-xs text-yellow-800">
<strong>💡 :</strong><br/>
1. <br/>
2. <br/>
3.
</p>
</div>
{/* 추가된 조건 목록 */}
{newFieldConditionFields.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
{newFieldConditionFields.map((condition, conditionIndex) => (
<div key={conditionIndex} className="border border-blue-300 rounded-lg p-4 bg-blue-50 space-y-3">
<div className="flex items-center justify-between">
<div className="flex-1">
<span className="text-sm font-bold text-blue-900">
: "{condition.expectedValue}"
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveCondition(conditionIndex)}
className="h-8 w-8 p-0 hover:bg-red-100"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
{/* 이 조건값일 때 표시할 항목들 선택 */}
{availableFields.length > 0 ? (
<div className="space-y-2 pl-3 border-l-2 border-blue-300">
<Label className="text-xs font-semibold text-blue-800">
({condition.targetFieldIds?.length || 0} ):
</Label>
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
{availableFields.map(field => (
<label key={field.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={condition.targetFieldIds?.includes(field.id) || false}
onChange={(e) => {
const newFields = [...newFieldConditionFields];
if (e.target.checked) {
newFields[conditionIndex].targetFieldIds = [
...(newFields[conditionIndex].targetFieldIds || []),
field.id
];
} else {
newFields[conditionIndex].targetFieldIds =
(newFields[conditionIndex].targetFieldIds || []).filter(id => id !== field.id);
}
setNewFieldConditionFields(newFields);
}}
className="cursor-pointer"
/>
<span className="text-xs flex-1">{field.name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
</Badge>
</label>
))}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground pl-3">
.
</p>
)}
</div>
))}
</div>
)}
{/* 새 조건 추가 */}
<div className="space-y-2 p-3 bg-gray-50 rounded border border-gray-200">
<Label className="text-sm font-semibold"> </Label>
<p className="text-xs text-muted-foreground">{getPlaceholderText()}</p>
<div className="flex gap-2">
<Input
value={tempConditionValue}
onChange={(e) => setTempConditionValue(e.target.value)}
placeholder="조건값 입력"
className="flex-1"
onKeyDown={(e) => e.key === 'Enter' && handleAddCondition()}
/>
<Button
variant="default"
size="sm"
onClick={handleAddCondition}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</div>
)}
{/* 섹션용 조건 설정 */}
{newFieldConditionTargetType === 'section' && (
<div className="space-y-4">
<div className="bg-yellow-50 p-3 rounded border border-yellow-200">
<p className="text-xs text-yellow-800">
<strong>💡 :</strong><br/>
1. <br/>
2. <br/>
3.
</p>
</div>
{/* 추가된 조건 목록 */}
{newFieldConditionFields.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
{newFieldConditionFields.map((condition, conditionIndex) => (
<div key={conditionIndex} className="border border-blue-300 rounded-lg p-4 bg-blue-50 space-y-3">
<div className="flex items-center justify-between">
<div className="flex-1">
<span className="text-sm font-bold text-blue-900">
: "{condition.expectedValue}"
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveCondition(conditionIndex)}
className="h-8 w-8 p-0 hover:bg-red-100"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
{/* 이 조건값일 때 표시할 섹션들 선택 */}
<div className="space-y-2 pl-3 border-l-2 border-blue-300">
<Label className="text-xs font-semibold text-blue-800">
({condition.targetSectionIds?.length || 0} ):
</Label>
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
{availableSections.map(section => (
<label key={section.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={condition.targetSectionIds?.includes(section.id) || false}
onChange={(e) => {
const newFields = [...newFieldConditionFields];
if (e.target.checked) {
newFields[conditionIndex].targetSectionIds = [
...(newFields[conditionIndex].targetSectionIds || []),
section.id
];
} else {
newFields[conditionIndex].targetSectionIds =
(newFields[conditionIndex].targetSectionIds || []).filter(id => id !== section.id);
}
setNewFieldConditionFields(newFields);
}}
className="cursor-pointer"
/>
<span className="text-xs flex-1">{section.title}</span>
</label>
))}
</div>
</div>
</div>
))}
</div>
)}
{/* 새 조건 추가 */}
<div className="space-y-2 p-3 bg-gray-50 rounded border border-gray-200">
<Label className="text-sm font-semibold"> </Label>
<p className="text-xs text-muted-foreground">{getPlaceholderText()}</p>
<div className="flex gap-2">
<Input
value={tempConditionValue}
onChange={(e) => setTempConditionValue(e.target.value)}
placeholder="조건값 입력"
className="flex-1"
onKeyDown={(e) => e.key === 'Enter' && handleAddCondition()}
/>
<Button
variant="default"
size="sm"
onClick={handleAddCondition}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { useState } from 'react';
import type { ItemField } from '@/contexts/ItemMasterContext';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
GripVertical,
Edit,
X
} from 'lucide-react';
// 입력방식 옵션 (ItemMasterDataManagement에서 사용하는 상수)
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
{ value: 'number', label: '숫자' },
{ value: 'date', label: '날짜' },
{ value: 'textarea', label: '텍스트영역' }
];
interface DraggableFieldProps {
field: ItemField;
index: number;
moveField: (dragIndex: number, hoverIndex: number) => void;
onDelete: () => void;
onEdit?: () => void;
}
export function DraggableField({ field, index, moveField, onDelete, onEdit }: DraggableFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ index, id: field.id }));
setIsDragging(true);
};
const handleDragEnd = () => {
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.index !== index) {
moveField(data.index, index);
}
} catch (err) {
// Ignore
}
};
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={`flex items-center justify-between p-3 border rounded hover:bg-gray-50 transition-opacity ${
isDragging ? 'opacity-50' : 'opacity-100'
}`}
style={{ cursor: 'move' }}
>
<div className="flex-1">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<span className="text-sm">{field.name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.property.inputType)?.label}
</Badge>
{field.property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
{field.displayCondition && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
{field.order !== undefined && (
<Badge variant="outline" className="text-xs">: {field.order + 1}</Badge>
)}
</div>
<div className="ml-6 text-xs text-gray-500 mt-1">
: {field.fieldKey}
{field.displayCondition && (
<span className="ml-2">
(: {field.displayCondition.fieldKey} = {field.displayCondition.expectedValue})
</span>
)}
{field.description && (
<span className="ml-2"> {field.description}</span>
)}
</div>
</div>
<div className="flex gap-2">
{onEdit && (
<Button
size="sm"
variant="ghost"
onClick={onEdit}
>
<Edit className="h-4 w-4 text-blue-500" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={onDelete}
>
<X className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import type { ItemSection } from '@/contexts/ItemMasterContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
GripVertical,
FileText,
Edit,
Check,
X,
Trash2
} from 'lucide-react';
interface DraggableSectionProps {
section: ItemSection;
index: number;
moveSection: (dragIndex: number, hoverIndex: number) => void;
onDelete: () => void;
onEditTitle: (id: string, title: string) => void;
editingSectionId: string | null;
editingSectionTitle: string;
setEditingSectionTitle: (title: string) => void;
setEditingSectionId: (id: string | null) => void;
handleSaveSectionTitle: () => void;
children: React.ReactNode;
}
export function DraggableSection({
section,
index,
moveSection,
onDelete,
onEditTitle,
editingSectionId,
editingSectionTitle,
setEditingSectionTitle,
setEditingSectionId,
handleSaveSectionTitle,
children
}: DraggableSectionProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ index, id: section.id }));
setIsDragging(true);
};
const handleDragEnd = () => {
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.index !== index) {
moveSection(data.index, index);
}
} catch (err) {
// Ignore
}
};
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={`border rounded-lg overflow-hidden transition-opacity ${
isDragging ? 'opacity-50' : 'opacity-100'
}`}
>
{/* 섹션 헤더 */}
<div className="bg-blue-50 border-b p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<GripVertical className="h-4 w-4 text-gray-400" style={{ cursor: 'move' }} />
<FileText className="h-4 w-4 text-blue-600" />
{editingSectionId === section.id ? (
<div className="flex items-center gap-2 flex-1">
<Input
value={editingSectionTitle}
onChange={(e) => setEditingSectionTitle(e.target.value)}
className="h-8 bg-white"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveSectionTitle();
if (e.key === 'Escape') setEditingSectionId(null);
}}
/>
<Button size="sm" onClick={handleSaveSectionTitle}>
<Check className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingSectionId(null)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div
className="flex items-center gap-2 flex-1 cursor-pointer group min-w-0"
onClick={() => onEditTitle(section.id, section.title)}
>
<span className="text-blue-900 truncate text-sm sm:text-base">{section.title}</span>
<Edit className="h-3 w-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
onClick={onDelete}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</div>
{/* 섹션 컨텐츠 */}
<div className="p-4 bg-white space-y-2">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { DraggableSection } from './DraggableSection';
export { DraggableField } from './DraggableField';

View File

@@ -0,0 +1,106 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
interface ColumnDialogProps {
isColumnDialogOpen: boolean;
setIsColumnDialogOpen: (open: boolean) => void;
editingColumnId: string | null;
setEditingColumnId: (id: string | null) => void;
columnName: string;
setColumnName: (name: string) => void;
columnKey: string;
setColumnKey: (key: string) => void;
textboxColumns: Array<{ id: string; name: string; key: string }>;
setTextboxColumns: React.Dispatch<React.SetStateAction<Array<{ id: string; name: string; key: string }>>>;
}
export function ColumnDialog({
isColumnDialogOpen,
setIsColumnDialogOpen,
editingColumnId,
setEditingColumnId,
columnName,
setColumnName,
columnKey,
setColumnKey,
textboxColumns,
setTextboxColumns,
}: ColumnDialogProps) {
const handleSubmit = () => {
if (!columnName.trim() || !columnKey.trim()) {
return toast.error('모든 필드를 입력해주세요');
}
if (editingColumnId) {
// 수정
setTextboxColumns(prev => prev.map(col =>
col.id === editingColumnId
? { ...col, name: columnName, key: columnKey }
: col
));
toast.success('컬럼이 수정되었습니다');
} else {
// 추가
setTextboxColumns(prev => [...prev, {
id: `col-${Date.now()}`,
name: columnName,
key: columnKey
}]);
toast.success('컬럼이 추가되었습니다');
}
setIsColumnDialogOpen(false);
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
};
return (
<Dialog open={isColumnDialogOpen} onOpenChange={(open) => {
setIsColumnDialogOpen(open);
if (!open) {
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingColumnId ? '컬럼 수정' : '컬럼 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={columnName}
onChange={(e) => setColumnName(e.target.value)}
placeholder="예: 가로"
/>
</div>
<div>
<Label> *</Label>
<Input
value={columnKey}
onChange={(e) => setColumnKey(e.target.value)}
placeholder="예: width"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsColumnDialogOpen(false)}></Button>
<Button onClick={handleSubmit}>
{editingColumnId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { OptionColumn } from '../types';
interface AttributeSubTab {
id: string;
label: string;
key: string;
order: number;
isDefault?: boolean;
}
interface ColumnManageDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
managingColumnType: string | null;
attributeSubTabs: AttributeSubTab[];
attributeColumns: Record<string, OptionColumn[]>;
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>;
newColumnName: string;
setNewColumnName: (name: string) => void;
newColumnKey: string;
setNewColumnKey: (key: string) => void;
newColumnType: 'text' | 'number';
setNewColumnType: (type: 'text' | 'number') => void;
newColumnRequired: boolean;
setNewColumnRequired: (required: boolean) => void;
}
export function ColumnManageDialog({
isOpen,
setIsOpen,
managingColumnType,
attributeSubTabs,
attributeColumns,
setAttributeColumns,
newColumnName,
setNewColumnName,
newColumnKey,
setNewColumnKey,
newColumnType,
setNewColumnType,
newColumnRequired,
setNewColumnRequired,
}: ColumnManageDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
}
}}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{managingColumnType === 'units' && '단위'}
{managingColumnType === 'materials' && '재질'}
{managingColumnType === 'surface' && '표면처리'}
{managingColumnType && !['units', 'materials', 'surface'].includes(managingColumnType) &&
(attributeSubTabs.find(t => t.key === managingColumnType)?.label || '속성')}
{' '} (: 규격 // )
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 기존 칼럼 목록 */}
{managingColumnType && attributeColumns[managingColumnType]?.length > 0 && (
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-3"> </h4>
<div className="space-y-2">
{attributeColumns[managingColumnType].map((column, idx) => (
<div key={column.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div className="flex items-center gap-3">
<Badge variant="outline">{idx + 1}</Badge>
<div>
<p className="font-medium">{column.name}</p>
<p className="text-xs text-muted-foreground">
: {column.key} | : {column.type === 'text' ? '텍스트' : '숫자'}
{column.required && ' | 필수'}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (managingColumnType) {
setAttributeColumns(prev => ({
...prev,
[managingColumnType]: prev[managingColumnType]?.filter(c => c.id !== column.id) || []
}));
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
))}
</div>
</div>
)}
{/* 새 칼럼 추가 폼 */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium"> </h4>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input
value={newColumnName}
onChange={(e) => setNewColumnName(e.target.value)}
placeholder="예: 속성, 값, 단위"
/>
</div>
<div>
<Label> () *</Label>
<Input
value={newColumnKey}
onChange={(e) => setNewColumnKey(e.target.value)}
placeholder="예: property, value, unit"
/>
</div>
<div>
<Label></Label>
<Select value={newColumnType} onValueChange={(value: 'text' | 'number') => setNewColumnType(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 pt-6">
<Switch
checked={newColumnRequired}
onCheckedChange={setNewColumnRequired}
/>
<Label> </Label>
</div>
</div>
<Button
size="sm"
className="w-full"
onClick={() => {
if (!newColumnName.trim() || !newColumnKey.trim()) {
toast.error('칼럼명과 키를 입력해주세요');
return;
}
if (managingColumnType) {
const newColumn: OptionColumn = {
id: `col-${Date.now()}`,
name: newColumnName,
key: newColumnKey,
type: newColumnType,
required: newColumnRequired
};
setAttributeColumns(prev => ({
...prev,
[managingColumnType]: [...(prev[managingColumnType] || []), newColumn]
}));
// 입력 필드 초기화
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
toast.success('칼럼이 추가되었습니다');
}
}}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<DialogFooter>
<Button onClick={() => setIsOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,408 @@
'use client';
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 { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Plus, X, Edit, Trash2, Check } from 'lucide-react';
import { toast } from 'sonner';
import type { ItemPage, ItemSection, ItemMasterField } from '@/contexts/ItemMasterContext';
import { ConditionalDisplayUI, type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
// 텍스트박스 칼럼 타입 (단순 구조)
interface OptionColumn {
id: string;
name: string;
key: string;
}
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트박스' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
{ value: 'number', label: '숫자' },
{ value: 'date', label: '날짜' },
{ value: 'textarea', label: '텍스트영역' }
];
interface FieldDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
editingFieldId: number | null;
setEditingFieldId: (id: number | null) => void;
fieldInputMode: 'custom' | 'master';
setFieldInputMode: (mode: 'custom' | 'master') => void;
showMasterFieldList: boolean;
setShowMasterFieldList: (show: boolean) => void;
selectedMasterFieldId: string;
setSelectedMasterFieldId: (id: string) => void;
textboxColumns: OptionColumn[];
setTextboxColumns: React.Dispatch<React.SetStateAction<OptionColumn[]>>;
newFieldConditionEnabled: boolean;
setNewFieldConditionEnabled: (enabled: boolean) => void;
newFieldConditionTargetType: 'field' | 'section';
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
newFieldConditionFields: ConditionalFieldConfig[];
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionalFieldConfig[]>>;
newFieldConditionSections: string[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>>;
tempConditionValue: string;
setTempConditionValue: (value: string) => void;
newFieldName: string;
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section';
setNewFieldInputType: (type: any) => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldDescription: string;
setNewFieldDescription: (description: string) => void;
newFieldOptions: string;
setNewFieldOptions: (options: string) => void;
selectedSectionForField: ItemSection | null;
selectedPage: ItemPage | null;
itemMasterFields: ItemMasterField[];
handleAddField: () => void;
setIsColumnDialogOpen: (open: boolean) => void;
setEditingColumnId: (id: string | null) => void;
setColumnName: (name: string) => void;
setColumnKey: (key: string) => void;
}
export function FieldDialog({
isOpen,
onOpenChange,
editingFieldId,
setEditingFieldId,
fieldInputMode,
setFieldInputMode,
showMasterFieldList,
setShowMasterFieldList,
selectedMasterFieldId,
setSelectedMasterFieldId,
textboxColumns,
setTextboxColumns,
newFieldConditionEnabled,
setNewFieldConditionEnabled,
newFieldConditionTargetType,
setNewFieldConditionTargetType,
newFieldConditionFields,
setNewFieldConditionFields,
newFieldConditionSections,
setNewFieldConditionSections,
tempConditionValue,
setTempConditionValue,
newFieldName,
setNewFieldName,
newFieldKey,
setNewFieldKey,
newFieldInputType,
setNewFieldInputType,
newFieldRequired,
setNewFieldRequired,
newFieldDescription,
setNewFieldDescription,
newFieldOptions,
setNewFieldOptions,
selectedSectionForField,
selectedPage,
itemMasterFields,
handleAddField,
setIsColumnDialogOpen,
setEditingColumnId,
setColumnName,
setColumnKey,
}: FieldDialogProps) {
const handleClose = () => {
onOpenChange(false);
setEditingFieldId(null);
setFieldInputMode('custom');
setShowMasterFieldList(false);
setSelectedMasterFieldId('');
setTextboxColumns([]);
setNewFieldConditionEnabled(false);
setNewFieldConditionTargetType('field');
setNewFieldConditionFields([]);
setNewFieldConditionSections([]);
setTempConditionValue('');
// 핵심 입력 필드 초기화 (취소 시에도 이전 데이터 남지 않도록)
setNewFieldName('');
setNewFieldKey('');
setNewFieldInputType('textbox');
setNewFieldRequired(false);
setNewFieldOptions('');
setNewFieldDescription('');
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-0">
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
<DialogTitle>{editingFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
{!editingFieldId && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={fieldInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => setFieldInputMode('custom')}
className="flex-1"
>
</Button>
<Button
variant={fieldInputMode === 'master' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setFieldInputMode('master');
setShowMasterFieldList(true);
}}
className="flex-1"
>
</Button>
</div>
)}
{/* 마스터 항목 목록 */}
{fieldInputMode === 'master' && !editingFieldId && showMasterFieldList && (
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> </Label>
<Button
variant="ghost"
size="sm"
onClick={() => setShowMasterFieldList(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{itemMasterFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
<div className="space-y-2">
{itemMasterFields.map(field => (
<div
key={field.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedMasterFieldId === field.id
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => {
setSelectedMasterFieldId(field.id);
setNewFieldName(field.name);
setNewFieldKey(field.fieldKey);
setNewFieldInputType(field.property.inputType);
setNewFieldRequired(field.property.required);
setNewFieldDescription(field.description || '');
setNewFieldOptions(field.property.options?.join(', ') || '');
if (field.property.multiColumn && field.property.columnNames) {
setTextboxColumns(
field.property.columnNames.map((name, idx) => ({
id: `col-${idx}`,
name,
key: `column${idx + 1}`
}))
);
}
}}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{field.name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
</Badge>
{field.property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{field.description && (
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
)}
{Array.isArray(field.category) && field.category.length > 0 && (
<div className="flex gap-1 mt-1">
{field.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
</div>
)}
</div>
{selectedMasterFieldId === field.id && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="예: 품목명"
/>
</div>
<div>
<Label> *</Label>
<Input
value={newFieldKey}
onChange={(e) => setNewFieldKey(e.target.value)}
placeholder="예: itemName"
/>
</div>
</div>
<div>
<Label> *</Label>
<Select value={newFieldInputType} onValueChange={(v: any) => setNewFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newFieldInputType === 'dropdown' && (
<div>
<Label> </Label>
<Input
value={newFieldOptions}
onChange={(e) => setNewFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
)}
{/* 텍스트박스 컬럼 관리 */}
{newFieldInputType === 'textbox' && (
<div className="border rounded p-3 space-y-3">
<div className="flex items-center justify-between">
<Label> </Label>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsColumnDialogOpen(true);
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{textboxColumns.length > 0 ? (
<div className="space-y-2">
{textboxColumns.map((col, index) => (
<div key={col.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<span className="text-sm flex-1">
{index + 1}. {col.name} ({col.key})
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingColumnId(col.id);
setColumnName(col.name);
setColumnKey(col.key);
setIsColumnDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setTextboxColumns(prev => prev.filter(c => c.id !== col.id));
toast.success('컬럼이 삭제되었습니다');
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
)}
</div>
)}
<div>
<Label> ()</Label>
<Textarea
value={newFieldDescription}
onChange={(e) => setNewFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={newFieldRequired} onCheckedChange={setNewFieldRequired} />
<Label> </Label>
</div>
</>
)}
{/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
<ConditionalDisplayUI
newFieldConditionEnabled={newFieldConditionEnabled}
setNewFieldConditionEnabled={setNewFieldConditionEnabled}
newFieldConditionTargetType={newFieldConditionTargetType}
setNewFieldConditionTargetType={setNewFieldConditionTargetType}
newFieldConditionFields={newFieldConditionFields}
setNewFieldConditionFields={setNewFieldConditionFields}
tempConditionValue={tempConditionValue}
setTempConditionValue={setTempConditionValue}
newFieldKey={newFieldKey}
newFieldInputType={newFieldInputType}
selectedPage={selectedPage}
selectedSectionForField={selectedSectionForField}
editingFieldId={editingFieldId}
INPUT_TYPE_OPTIONS={INPUT_TYPE_OPTIONS}
/>
)}
</div>
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t">
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={handleAddField}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,628 @@
'use client';
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 { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Plus, X, Edit, Trash2, Check } from 'lucide-react';
import { toast } from 'sonner';
import type { ItemPage, ItemSection, ItemMasterField } from '@/contexts/ItemMasterContext';
interface OptionColumn {
id: string;
name: string;
key: string;
}
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트박스' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
{ value: 'number', label: '숫자' },
{ value: 'date', label: '날짜' },
{ value: 'textarea', label: '텍스트영역' }
];
interface FieldDrawerProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
editingFieldId: number | null;
setEditingFieldId: (id: number | null) => void;
fieldInputMode: 'custom' | 'master';
setFieldInputMode: (mode: 'custom' | 'master') => void;
showMasterFieldList: boolean;
setShowMasterFieldList: (show: boolean) => void;
selectedMasterFieldId: string;
setSelectedMasterFieldId: (id: string) => void;
textboxColumns: OptionColumn[];
setTextboxColumns: React.Dispatch<React.SetStateAction<OptionColumn[]>>;
newFieldConditionEnabled: boolean;
setNewFieldConditionEnabled: (enabled: boolean) => void;
newFieldConditionTargetType: 'field' | 'section';
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
newFieldConditionFields: Array<{ fieldKey: string; expectedValue: string }>;
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<Array<{ fieldKey: string; expectedValue: string }>>>;
newFieldConditionSections: string[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>>;
tempConditionValue: string;
setTempConditionValue: (value: string) => void;
newFieldName: string;
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setNewFieldInputType: (type: any) => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldDescription: string;
setNewFieldDescription: (description: string) => void;
newFieldOptions: string;
setNewFieldOptions: (options: string) => void;
selectedSectionForField: ItemSection | null;
selectedPage: ItemPage | null;
itemMasterFields: ItemMasterField[];
handleAddField: () => void;
setIsColumnDialogOpen: (open: boolean) => void;
setEditingColumnId: (id: string | null) => void;
setColumnName: (name: string) => void;
setColumnKey: (key: string) => void;
}
export function FieldDrawer({
isOpen,
onOpenChange,
editingFieldId,
setEditingFieldId,
fieldInputMode,
setFieldInputMode,
showMasterFieldList,
setShowMasterFieldList,
selectedMasterFieldId,
setSelectedMasterFieldId,
textboxColumns,
setTextboxColumns,
newFieldConditionEnabled,
setNewFieldConditionEnabled,
newFieldConditionTargetType,
setNewFieldConditionTargetType,
newFieldConditionFields,
setNewFieldConditionFields,
newFieldConditionSections,
setNewFieldConditionSections,
tempConditionValue,
setTempConditionValue,
newFieldName,
setNewFieldName,
newFieldKey,
setNewFieldKey,
newFieldInputType,
setNewFieldInputType,
newFieldRequired,
setNewFieldRequired,
newFieldDescription,
setNewFieldDescription,
newFieldOptions,
setNewFieldOptions,
selectedSectionForField,
selectedPage,
itemMasterFields,
handleAddField,
setIsColumnDialogOpen,
setEditingColumnId,
setColumnName,
setColumnKey
}: FieldDrawerProps) {
const handleClose = () => {
onOpenChange(false);
setEditingFieldId(null);
setFieldInputMode('custom');
setShowMasterFieldList(false);
setSelectedMasterFieldId('');
setTextboxColumns([]);
setNewFieldConditionEnabled(false);
setNewFieldConditionTargetType('field');
setNewFieldConditionFields([]);
setNewFieldConditionSections([]);
setTempConditionValue('');
};
return (
<Drawer open={isOpen} onOpenChange={handleClose}>
<DrawerContent className="max-h-[90vh] flex flex-col">
<DrawerHeader className="px-4 py-3 border-b">
<DrawerTitle>{editingFieldId ? '항목 수정' : '항목 추가'}</DrawerTitle>
<DrawerDescription>
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
{!editingFieldId && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={fieldInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => setFieldInputMode('custom')}
className="flex-1"
>
</Button>
<Button
variant={fieldInputMode === 'master' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setFieldInputMode('master');
setShowMasterFieldList(true);
}}
className="flex-1"
>
</Button>
</div>
)}
{/* 마스터 항목 목록 */}
{fieldInputMode === 'master' && !editingFieldId && showMasterFieldList && (
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> </Label>
<Button
variant="ghost"
size="sm"
onClick={() => setShowMasterFieldList(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{itemMasterFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
<div className="space-y-2">
{itemMasterFields.map(field => (
<div
key={field.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedMasterFieldId === field.id
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => {
setSelectedMasterFieldId(field.id);
setNewFieldName(field.name);
setNewFieldKey(field.fieldKey);
setNewFieldInputType(field.property.inputType);
setNewFieldRequired(field.property.required);
setNewFieldDescription(field.description || '');
setNewFieldOptions(field.property.options?.join(', ') || '');
if (field.property.multiColumn && field.property.columnNames) {
setTextboxColumns(
field.property.columnNames.map((name, idx) => ({
id: `col-${idx}`,
name,
key: `column${idx + 1}`
}))
);
}
}}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{field.name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
</Badge>
{field.property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{field.description && (
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
)}
{Array.isArray(field.category) && field.category.length > 0 && (
<div className="flex gap-1 mt-1">
{field.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
</div>
)}
</div>
{selectedMasterFieldId === field.id && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="예: 품목명"
/>
</div>
<div>
<Label> *</Label>
<Input
value={newFieldKey}
onChange={(e) => setNewFieldKey(e.target.value)}
placeholder="예: itemName"
/>
</div>
</div>
<div>
<Label> *</Label>
<Select value={newFieldInputType} onValueChange={(v: any) => setNewFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newFieldInputType === 'dropdown' && (
<div>
<Label> </Label>
<Input
value={newFieldOptions}
onChange={(e) => setNewFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
)}
{/* 텍스트박스 컬럼 관리 */}
{newFieldInputType === 'textbox' && (
<div className="border rounded p-3 space-y-3">
<div className="flex items-center justify-between">
<Label> </Label>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsColumnDialogOpen(true);
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{textboxColumns.length > 0 ? (
<div className="space-y-2">
{textboxColumns.map((col, index) => (
<div key={col.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<span className="text-sm flex-1">
{index + 1}. {col.name} ({col.key})
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingColumnId(col.id);
setColumnName(col.name);
setColumnKey(col.key);
setIsColumnDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setTextboxColumns(prev => prev.filter(c => c.id !== col.id));
toast.success('컬럼이 삭제되었습니다');
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
)}
</div>
)}
<div>
<Label> ()</Label>
<Textarea
value={newFieldDescription}
onChange={(e) => setNewFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={newFieldRequired} onCheckedChange={setNewFieldRequired} />
<Label> </Label>
</div>
</>
)}
{/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch checked={newFieldConditionEnabled} onCheckedChange={setNewFieldConditionEnabled} />
<Label className="text-base"> </Label>
</div>
<p className="text-xs text-muted-foreground pl-8">
/ ( )
</p>
</div>
{newFieldConditionEnabled && selectedSectionForField && (
<div className="space-y-4 pl-6 pt-3 border-l-2 border-blue-200">
{/* 대상 타입 선택 */}
<div className="space-y-2 bg-blue-50 p-3 rounded">
<Label className="text-sm font-semibold"> ?</Label>
<div className="flex gap-4 pl-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={newFieldConditionTargetType === 'field'}
onChange={() => setNewFieldConditionTargetType('field')}
className="cursor-pointer"
/>
<span className="text-sm"> ( )</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={newFieldConditionTargetType === 'section'}
onChange={() => setNewFieldConditionTargetType('section')}
className="cursor-pointer"
/>
<span className="text-sm"> </span>
</label>
</div>
</div>
{/* 일반항목용 조건 설정 */}
{newFieldConditionTargetType === 'field' && (
<div className="space-y-3">
<div className="bg-yellow-50 p-3 rounded border border-yellow-200">
<p className="text-xs text-yellow-800">
<strong>💡 :</strong><br/>
1. (: "제품", "원자재") <br/>
2. , <br/>
3. "조건부 표시" <br/>
4.
</p>
</div>
<div>
<Label className="text-sm font-semibold"> ( )</Label>
<p className="text-xs text-muted-foreground mt-1">
{newFieldInputType === 'dropdown' && '드롭다운 옵션값을 입력하세요'}
{newFieldInputType === 'checkbox' && '체크박스 상태값(true/false)을 입력하세요'}
{newFieldInputType === 'textbox' && '텍스트 값을 입력하세요 (예: "제품", "부품")'}
{newFieldInputType === 'number' && '숫자 값을 입력하세요 (예: 100, 200)'}
{newFieldInputType === 'date' && '날짜 값을 입력하세요 (예: 2025-01-01)'}
{newFieldInputType === 'textarea' && '텍스트 값을 입력하세요'}
</p>
</div>
{/* 추가된 조건 목록 */}
{newFieldConditionFields.length > 0 && (
<div className="space-y-2">
{newFieldConditionFields.map((condition, index) => (
<div key={index} className="flex items-center gap-2 p-3 bg-blue-50 rounded border border-blue-200">
<div className="flex-1">
<span className="text-sm font-medium text-blue-900">
"{condition.expectedValue}"
</span>
<p className="text-xs text-blue-700 mt-1">
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
toast.success('조건이 제거되었습니다.');
}}
className="h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{/* 새 조건 추가 */}
<div className="flex gap-2">
<Input
value={tempConditionValue}
onChange={(e) => setTempConditionValue(e.target.value)}
placeholder={
newFieldInputType === 'dropdown' ? "예: 제품, 부품, 원자재..." :
newFieldInputType === 'checkbox' ? "예: true 또는 false" :
newFieldInputType === 'number' ? "예: 100" :
newFieldInputType === 'date' ? "예: 2025-01-01" :
"예: 값을 입력하세요"
}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (tempConditionValue) {
setNewFieldConditionFields(prev => [...prev, {
fieldKey: newFieldKey, // 현재 항목 자신의 키를 사용
expectedValue: tempConditionValue
}]);
setTempConditionValue('');
toast.success('조건값이 추가되었습니다.');
} else {
toast.error('조건값을 입력해주세요.');
}
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
)}
{/* 섹션용 조건 설정 */}
{newFieldConditionTargetType === 'section' && selectedPage && (
<div className="space-y-3">
<div className="bg-yellow-50 p-3 rounded border border-yellow-200">
<p className="text-xs text-yellow-800">
<strong>💡 :</strong><br/>
1. (: "제품", "부품")<br/>
2. <br/>
3. /
</p>
</div>
{/* 조건값 추가 */}
<div>
<Label className="text-sm font-semibold"> </Label>
<div className="flex gap-2 mt-2">
<Input
value={tempConditionValue}
onChange={(e) => setTempConditionValue(e.target.value)}
placeholder={
newFieldInputType === 'dropdown' ? "예: 제품, 부품, 원자재..." :
newFieldInputType === 'checkbox' ? "예: true 또는 false" :
newFieldInputType === 'number' ? "예: 100" :
newFieldInputType === 'date' ? "예: 2025-01-01" :
"예: 값을 입력하세요"
}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (tempConditionValue) {
if (!newFieldConditionFields.find(f => f.expectedValue === tempConditionValue)) {
setNewFieldConditionFields(prev => [...prev, {
fieldKey: newFieldKey,
expectedValue: tempConditionValue
}]);
setTempConditionValue('');
toast.success('조건값이 추가되었습니다.');
} else {
toast.error('이미 추가된 조건값입니다.');
}
} else {
toast.error('조건값을 입력해주세요.');
}
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
{/* 조건값 목록 표시 */}
{newFieldConditionFields.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground"> :</Label>
<div className="flex flex-wrap gap-2">
{newFieldConditionFields.map((condition, index) => (
<Badge key={index} variant="secondary" className="gap-1">
{condition.expectedValue}
<button
onClick={() => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
toast.success('조건값이 제거되었습니다.');
}}
className="ml-1 hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
)}
{/* 섹션 선택 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> :</Label>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{selectedPage.sections
.filter(section => section.type !== 'bom')
.map(section => (
<label key={section.id} className="flex items-center gap-2 p-2 bg-muted rounded cursor-pointer hover:bg-muted/80">
<input
type="checkbox"
checked={newFieldConditionSections.includes(section.id)}
onChange={(e) => {
if (e.target.checked) {
setNewFieldConditionSections(prev => [...prev, section.id]);
} else {
setNewFieldConditionSections(prev => prev.filter(id => id !== section.id));
}
}}
className="cursor-pointer"
/>
<span className="flex-1 text-sm">{section.title}</span>
</label>
))}
</div>
{newFieldConditionSections.length > 0 && (
<div className="text-sm text-blue-600 font-medium mt-2">
{newFieldConditionSections.length}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
<DrawerFooter className="px-4 py-3 border-t flex-row gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1"></Button>
<Button onClick={handleAddField} className="flex-1"></Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Package, Folder } from 'lucide-react';
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
interface LoadTemplateDialogProps {
isLoadTemplateDialogOpen: boolean;
setIsLoadTemplateDialogOpen: (open: boolean) => void;
sectionTemplates: SectionTemplate[];
selectedTemplateId: string | null;
setSelectedTemplateId: (id: string | null) => void;
handleLoadTemplate: () => void;
}
const ITEM_TYPE_OPTIONS = [
{ value: 'product', label: '제품' },
{ value: 'part', label: '부품' },
{ value: 'material', label: '자재' },
{ value: 'assembly', label: '조립품' },
];
export function LoadTemplateDialog({
isLoadTemplateDialogOpen,
setIsLoadTemplateDialogOpen,
sectionTemplates,
selectedTemplateId,
setSelectedTemplateId,
handleLoadTemplate,
}: LoadTemplateDialogProps) {
return (
<Dialog open={isLoadTemplateDialogOpen} onOpenChange={(open) => {
setIsLoadTemplateDialogOpen(open);
if (!open) {
setSelectedTemplateId(null);
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{sectionTemplates.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{sectionTemplates.map((template) => (
<div
key={template.id}
onClick={() => setSelectedTemplateId(String(template.id))}
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
selectedTemplateId === String(template.id)
? 'border-blue-500 bg-blue-50 dark:bg-blue-950'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
{template.section_type === 'BOM' ? (
<Package className="w-5 h-5 text-blue-600 dark:text-blue-400" />
) : (
<Folder className="w-5 h-5 text-blue-600 dark:text-blue-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{template.template_name}</h4>
<Badge variant={template.section_type === 'BOM' ? 'default' : 'secondary'}>
{template.section_type}
</Badge>
</div>
{template.description && (
<p className="text-sm text-muted-foreground mb-2">{template.description}</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsLoadTemplateDialogOpen(false)}></Button>
<Button
onClick={handleLoadTemplate}
disabled={!selectedTemplateId}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,262 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트 입력' },
{ value: 'number', label: '숫자 입력' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
{ value: 'date', label: '날짜' },
{ value: 'textarea', label: '긴 텍스트' },
];
interface MasterFieldDialogProps {
isMasterFieldDialogOpen: boolean;
setIsMasterFieldDialogOpen: (open: boolean) => void;
editingMasterFieldId: number | null;
setEditingMasterFieldId: (id: number | null) => void;
newMasterFieldName: string;
setNewMasterFieldName: (name: string) => void;
newMasterFieldKey: string;
setNewMasterFieldKey: (key: string) => void;
newMasterFieldInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
setNewMasterFieldInputType: (type: any) => void;
newMasterFieldRequired: boolean;
setNewMasterFieldRequired: (required: boolean) => void;
newMasterFieldCategory: string;
setNewMasterFieldCategory: (category: string) => void;
newMasterFieldDescription: string;
setNewMasterFieldDescription: (description: string) => void;
newMasterFieldOptions: string;
setNewMasterFieldOptions: (options: string) => void;
newMasterFieldAttributeType: 'custom' | 'unit' | 'material' | 'surface';
setNewMasterFieldAttributeType: (type: 'custom' | 'unit' | 'material' | 'surface') => void;
newMasterFieldMultiColumn: boolean;
setNewMasterFieldMultiColumn: (multi: boolean) => void;
newMasterFieldColumnCount: number;
setNewMasterFieldColumnCount: (count: number) => void;
newMasterFieldColumnNames: string[];
setNewMasterFieldColumnNames: (names: string[]) => void;
handleUpdateMasterField: () => void;
handleAddMasterField: () => void;
}
export function MasterFieldDialog({
isMasterFieldDialogOpen,
setIsMasterFieldDialogOpen,
editingMasterFieldId,
setEditingMasterFieldId,
newMasterFieldName,
setNewMasterFieldName,
newMasterFieldKey,
setNewMasterFieldKey,
newMasterFieldInputType,
setNewMasterFieldInputType,
newMasterFieldRequired,
setNewMasterFieldRequired,
newMasterFieldCategory,
setNewMasterFieldCategory,
newMasterFieldDescription,
setNewMasterFieldDescription,
newMasterFieldOptions,
setNewMasterFieldOptions,
newMasterFieldAttributeType,
setNewMasterFieldAttributeType,
newMasterFieldMultiColumn,
setNewMasterFieldMultiColumn,
newMasterFieldColumnCount,
setNewMasterFieldColumnCount,
newMasterFieldColumnNames,
setNewMasterFieldColumnNames,
handleUpdateMasterField,
handleAddMasterField,
}: MasterFieldDialogProps) {
return (
<Dialog open={isMasterFieldDialogOpen} onOpenChange={(open) => {
setIsMasterFieldDialogOpen(open);
if (!open) {
setEditingMasterFieldId(null);
setNewMasterFieldName('');
setNewMasterFieldKey('');
setNewMasterFieldInputType('textbox');
setNewMasterFieldRequired(false);
setNewMasterFieldCategory('공통');
setNewMasterFieldDescription('');
setNewMasterFieldOptions('');
setNewMasterFieldAttributeType('custom');
setNewMasterFieldMultiColumn(false);
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingMasterFieldId ? '마스터 항목 수정' : '마스터 항목 추가'}</DialogTitle>
<DialogDescription>
릿
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={newMasterFieldName}
onChange={(e) => setNewMasterFieldName(e.target.value)}
placeholder="예: 품목명"
/>
</div>
<div>
<Label> *</Label>
<Input
value={newMasterFieldKey}
onChange={(e) => setNewMasterFieldKey(e.target.value)}
placeholder="예: itemName"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Select value={newMasterFieldInputType} onValueChange={(v: any) => setNewMasterFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={newMasterFieldRequired} onCheckedChange={setNewMasterFieldRequired} />
<Label> </Label>
</div>
<div>
<Label></Label>
<Textarea
value={newMasterFieldDescription}
onChange={(e) => setNewMasterFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
<p className="text-xs text-gray-500 mt-1">* []</p>
</div>
{(newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && (
<div className="space-y-4 p-4 border rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<Switch
checked={newMasterFieldMultiColumn}
onCheckedChange={(checked) => {
setNewMasterFieldMultiColumn(checked);
if (!checked) {
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
}
}}
/>
<Label> </Label>
</div>
<p className="text-xs text-gray-500">
(: 규격 - , , )
</p>
{newMasterFieldMultiColumn && (
<div className="space-y-4 pt-4 border-t">
<div>
<Label> </Label>
<Input
type="number"
min="2"
max="10"
value={newMasterFieldColumnCount}
onChange={(e) => {
const count = parseInt(e.target.value) || 2;
setNewMasterFieldColumnCount(count);
// 컬럼 개수에 맞게 이름 배열 조정
const newNames = Array.from({ length: count }, (_, i) =>
newMasterFieldColumnNames[i] || `컬럼${i + 1}`
);
setNewMasterFieldColumnNames(newNames);
}}
placeholder="컬럼 개수 (2~10)"
/>
</div>
<div>
<Label> </Label>
<div className="space-y-2 mt-2">
{Array.from({ length: newMasterFieldColumnCount }, (_, i) => (
<Input
key={i}
value={newMasterFieldColumnNames[i] || ''}
onChange={(e) => {
const newNames = [...newMasterFieldColumnNames];
newNames[i] = e.target.value;
setNewMasterFieldColumnNames(newNames);
}}
placeholder={`${i + 1}번째 컬럼 이름`}
/>
))}
</div>
<p className="text-xs text-gray-500 mt-2">
예시: 가로, , / , / ,
</p>
</div>
</div>
)}
</div>
)}
{newMasterFieldInputType === 'dropdown' && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<Label> </Label>
{newMasterFieldAttributeType !== 'custom' && (
<Badge variant="secondary" className="text-xs">
{newMasterFieldAttributeType === 'unit' ? '단위' :
newMasterFieldAttributeType === 'material' ? '재질' : '표면처리'}
</Badge>
)}
</div>
<Textarea
value={newMasterFieldOptions}
onChange={(e) => setNewMasterFieldOptions(e.target.value)}
placeholder="제품,부품,원자재 (쉼표로 구분)"
disabled={newMasterFieldAttributeType !== 'custom'}
className="min-h-[80px]"
/>
<p className="text-xs text-gray-500 mt-1">
{newMasterFieldAttributeType === 'custom'
? '쉼표(,)로 구분하여 입력하세요'
: '속성 탭에서 옵션을 추가/삭제하면 자동으로 반영됩니다'
}
</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMasterFieldDialogOpen(false)}></Button>
<Button onClick={editingMasterFieldId ? handleUpdateMasterField : handleAddMasterField}>
{editingMasterFieldId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
interface OptionColumn {
id: string;
name: string;
key: string;
type: string;
required: boolean;
}
interface AttributeSubTab {
id: string;
label: string;
key: string;
order: number;
isDefault?: boolean;
}
interface OptionDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
newOptionValue: string;
setNewOptionValue: (value: string) => void;
newOptionLabel: string;
setNewOptionLabel: (label: string) => void;
newOptionColumnValues: Record<string, string>;
setNewOptionColumnValues: (values: Record<string, string>) => void;
newOptionInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
setNewOptionInputType: (type: any) => void;
newOptionRequired: boolean;
setNewOptionRequired: (required: boolean) => void;
newOptionOptions: string;
setNewOptionOptions: (options: string) => void;
newOptionPlaceholder: string;
setNewOptionPlaceholder: (placeholder: string) => void;
newOptionDefaultValue: string;
setNewOptionDefaultValue: (defaultValue: string) => void;
editingOptionType: string | null;
attributeSubTabs: AttributeSubTab[];
attributeColumns: Record<string, OptionColumn[]>;
handleAddOption: () => void;
}
export function OptionDialog({
isOpen,
setIsOpen,
newOptionValue,
setNewOptionValue,
newOptionLabel,
setNewOptionLabel,
newOptionColumnValues,
setNewOptionColumnValues,
newOptionInputType,
setNewOptionInputType,
newOptionRequired,
setNewOptionRequired,
newOptionOptions,
setNewOptionOptions,
newOptionPlaceholder,
setNewOptionPlaceholder,
newOptionDefaultValue,
setNewOptionDefaultValue,
editingOptionType,
attributeSubTabs,
attributeColumns,
handleAddOption,
}: OptionDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setNewOptionInputType('textbox');
setNewOptionRequired(false);
setNewOptionOptions('');
setNewOptionPlaceholder('');
setNewOptionDefaultValue('');
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{editingOptionType === 'unit' && '단위'}
{editingOptionType === 'material' && '재질'}
{editingOptionType === 'surface' && '표면처리'}
{editingOptionType && !['unit', 'material', 'surface'].includes(editingOptionType) &&
(attributeSubTabs.find(t => t.key === editingOptionType)?.label || '속성')}
{' '} . .
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 기본 정보 */}
<div className="border rounded-lg p-4 space-y-3 bg-blue-50">
<h4 className="font-medium text-sm"> </h4>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> (Value) *</Label>
<Input
value={newOptionValue}
onChange={(e) => setNewOptionValue(e.target.value)}
placeholder="예: kg, stainless"
/>
</div>
<div>
<Label> () *</Label>
<Input
value={newOptionLabel}
onChange={(e) => setNewOptionLabel(e.target.value)}
placeholder="예: 킬로그램, 스테인리스"
/>
</div>
</div>
</div>
{/* 입력 방식 설정 */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm"> </h4>
<div>
<Label> *</Label>
<Select value={newOptionInputType} onValueChange={(v: any) => setNewOptionInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="textbox"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="dropdown"></SelectItem>
<SelectItem value="checkbox"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="textarea"></SelectItem>
</SelectContent>
</Select>
</div>
{newOptionInputType === 'dropdown' && (
<div>
<Label className="flex items-center gap-1">
<span className="text-red-500">*</span>
</Label>
<Input
value={newOptionOptions}
onChange={(e) => setNewOptionOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
)}
<div>
<Label> ()</Label>
<Input
value={newOptionPlaceholder}
onChange={(e) => setNewOptionPlaceholder(e.target.value)}
placeholder="예: 값을 입력하세요"
/>
</div>
<div>
<Label> ()</Label>
<Input
value={newOptionDefaultValue}
onChange={(e) => setNewOptionDefaultValue(e.target.value)}
placeholder={
newOptionInputType === 'checkbox' ? 'true 또는 false' :
newOptionInputType === 'number' ? '숫자' :
'기본값'
}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={newOptionRequired} onCheckedChange={setNewOptionRequired} />
<Label> </Label>
</div>
</div>
{/* 추가 칼럼 (기존 칼럼 시스템과 호환) */}
{editingOptionType && attributeColumns[editingOptionType]?.length > 0 && (
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm"> </h4>
<div className="grid grid-cols-2 gap-4">
{attributeColumns[editingOptionType].map((column) => (
<div key={column.id}>
<Label className="flex items-center gap-1">
{column.name}
{column.required && <span className="text-red-500">*</span>}
</Label>
<Input
type={column.type === 'number' ? 'number' : 'text'}
value={newOptionColumnValues[column.key] || ''}
onChange={(e) => setNewOptionColumnValues({
...newOptionColumnValues,
[column.key]: e.target.value
})}
placeholder={`${column.name} 입력`}
/>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}></Button>
<Button onClick={handleAddOption}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const ITEM_TYPE_OPTIONS = [
{ value: 'product', label: '제품' },
{ value: 'part', label: '부품' },
{ value: 'material', label: '자재' },
{ value: 'assembly', label: '조립품' },
];
interface PageDialogProps {
isPageDialogOpen: boolean;
setIsPageDialogOpen: (open: boolean) => void;
newPageName: string;
setNewPageName: (name: string) => void;
newPageItemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
setNewPageItemType: React.Dispatch<React.SetStateAction<'FG' | 'PT' | 'SM' | 'RM' | 'CS'>>;
handleAddPage: () => void;
}
export function PageDialog({
isPageDialogOpen,
setIsPageDialogOpen,
newPageName,
setNewPageName,
newPageItemType,
setNewPageItemType,
handleAddPage,
}: PageDialogProps) {
return (
<Dialog open={isPageDialogOpen} onOpenChange={(open) => {
setIsPageDialogOpen(open);
if (!open) {
setNewPageName('');
setNewPageItemType('FG');
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newPageName}
onChange={(e) => setNewPageName(e.target.value)}
placeholder="예: 품목 등록"
/>
</div>
<div>
<Label> *</Label>
<Select value={newPageItemType} onValueChange={(v: any) => setNewPageItemType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsPageDialogOpen(false)}></Button>
<Button onClick={handleAddPage}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
interface PathEditDialogProps {
editingPathPageId: number | null;
setEditingPathPageId: (id: number | null) => void;
editingAbsolutePath: string;
setEditingAbsolutePath: (path: string) => void;
updateItemPage: (id: number, updates: any) => void;
trackChange: (type: 'pages' | 'sections' | 'fields' | 'masterFields' | 'attributes' | 'sectionTemplates', id: string, action: 'add' | 'update', data: any, attributeType?: string) => void;
}
export function PathEditDialog({
editingPathPageId,
setEditingPathPageId,
editingAbsolutePath,
setEditingAbsolutePath,
updateItemPage,
trackChange,
}: PathEditDialogProps) {
return (
<Dialog open={editingPathPageId !== null} onOpenChange={(open) => {
if (!open) {
setEditingPathPageId(null);
setEditingAbsolutePath('');
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> (: //)</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={editingAbsolutePath}
onChange={(e) => setEditingAbsolutePath(e.target.value)}
placeholder="/제품관리/제품등록"
/>
<p className="text-xs text-gray-500 mt-1">(/) , </p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingPathPageId(null)}></Button>
<Button onClick={() => {
if (!editingAbsolutePath.trim()) {
toast.error('절대경로를 입력해주세요');
return;
}
if (!editingAbsolutePath.startsWith('/')) {
toast.error('절대경로는 슬래시(/)로 시작해야 합니다');
return;
}
if (editingPathPageId) {
updateItemPage(editingPathPageId, { absolutePath: editingAbsolutePath });
trackChange('pages', editingPathPageId, 'update', { absolutePath: editingAbsolutePath });
setEditingPathPageId(null);
toast.success('절대경로가 수정되었습니다 (저장 필요)');
}
}}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
interface SectionDialogProps {
isSectionDialogOpen: boolean;
setIsSectionDialogOpen: (open: boolean) => void;
newSectionType: 'fields' | 'bom';
setNewSectionType: (type: 'fields' | 'bom') => void;
newSectionTitle: string;
setNewSectionTitle: (title: string) => void;
newSectionDescription: string;
setNewSectionDescription: (description: string) => void;
handleAddSection: () => void;
}
export function SectionDialog({
isSectionDialogOpen,
setIsSectionDialogOpen,
newSectionType,
setNewSectionType,
newSectionTitle,
setNewSectionTitle,
newSectionDescription,
setNewSectionDescription,
handleAddSection,
}: SectionDialogProps) {
return (
<Dialog open={isSectionDialogOpen} onOpenChange={(open) => {
setIsSectionDialogOpen(open);
if (!open) {
setNewSectionType('fields');
setNewSectionTitle('');
setNewSectionDescription('');
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{newSectionType === 'bom' ? 'BOM 섹션' : '일반 섹션'} </DialogTitle>
<DialogDescription>
{newSectionType === 'bom'
? '새로운 BOM(자재명세서) 섹션을 추가합니다'
: '새로운 일반 섹션을 추가합니다'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newSectionTitle}
onChange={(e) => setNewSectionTitle(e.target.value)}
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
/>
</div>
<div>
<Label> ()</Label>
<Textarea
value={newSectionDescription}
onChange={(e) => setNewSectionDescription(e.target.value)}
placeholder="섹션에 대한 설명"
/>
</div>
{newSectionType === 'bom' && (
<div className="bg-blue-50 p-3 rounded-md">
<p className="text-sm text-blue-700">
<strong>BOM :</strong> (BOM) .
, , .
</p>
</div>
)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => {
setIsSectionDialogOpen(false);
setNewSectionType('fields');
}} className="w-full sm:w-auto"></Button>
<Button onClick={handleAddSection} className="w-full sm:w-auto"></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const ITEM_TYPE_OPTIONS = [
{ value: 'product', label: '제품' },
{ value: 'part', label: '부품' },
{ value: 'material', label: '자재' },
{ value: 'assembly', label: '조립품' },
];
interface SectionTemplateDialogProps {
isSectionTemplateDialogOpen: boolean;
setIsSectionTemplateDialogOpen: (open: boolean) => void;
editingSectionTemplateId: number | null;
setEditingSectionTemplateId: (id: number | null) => void;
newSectionTemplateTitle: string;
setNewSectionTemplateTitle: (title: string) => void;
newSectionTemplateDescription: string;
setNewSectionTemplateDescription: (description: string) => void;
newSectionTemplateCategory: string[];
setNewSectionTemplateCategory: (category: string[]) => void;
newSectionTemplateType: 'fields' | 'bom';
setNewSectionTemplateType: (type: 'fields' | 'bom') => void;
handleUpdateSectionTemplate: () => void;
handleAddSectionTemplate: () => void;
}
export function SectionTemplateDialog({
isSectionTemplateDialogOpen,
setIsSectionTemplateDialogOpen,
editingSectionTemplateId,
setEditingSectionTemplateId,
newSectionTemplateTitle,
setNewSectionTemplateTitle,
newSectionTemplateDescription,
setNewSectionTemplateDescription,
newSectionTemplateCategory,
setNewSectionTemplateCategory,
newSectionTemplateType,
setNewSectionTemplateType,
handleUpdateSectionTemplate,
handleAddSectionTemplate,
}: SectionTemplateDialogProps) {
return (
<Dialog open={isSectionTemplateDialogOpen} onOpenChange={(open) => {
setIsSectionTemplateDialogOpen(open);
if (!open) {
setEditingSectionTemplateId(null);
setNewSectionTemplateTitle('');
setNewSectionTemplateDescription('');
setNewSectionTemplateCategory([]);
setNewSectionTemplateType('fields');
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingSectionTemplateId ? '섹션 수정' : '섹션 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newSectionTemplateTitle}
onChange={(e) => setNewSectionTemplateTitle(e.target.value)}
placeholder="예: 기본 정보"
/>
</div>
<div>
<Label> ()</Label>
<Textarea
value={newSectionTemplateDescription}
onChange={(e) => setNewSectionTemplateDescription(e.target.value)}
placeholder="섹션에 대한 설명"
/>
</div>
<div>
<Label> *</Label>
<Select
value={newSectionTemplateType}
onValueChange={(val) => setNewSectionTemplateType(val as 'fields' | 'bom')}
disabled={!!editingSectionTemplateId}
>
<SelectTrigger>
<SelectValue placeholder="섹션 타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fields"> </SelectItem>
<SelectItem value="bom">BOM ( )</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">
{editingSectionTemplateId
? '※ 템플릿 타입은 수정할 수 없습니다.'
: '일반 필드: 텍스트, 드롭다운 등의 항목 관리 | BOM: 하위 품목 구성 관리'}
</p>
</div>
<div>
<Label> ()</Label>
<div className="grid grid-cols-3 gap-2 mt-2">
{ITEM_TYPE_OPTIONS.map((type) => (
<div key={type.value} className="flex items-center gap-2">
<input
type="checkbox"
id={`cat-${type.value}`}
checked={newSectionTemplateCategory.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
setNewSectionTemplateCategory([...newSectionTemplateCategory, type.value]);
} else {
setNewSectionTemplateCategory(newSectionTemplateCategory.filter(c => c !== type.value));
}
}}
className="rounded"
/>
<label htmlFor={`cat-${type.value}`} className="text-sm cursor-pointer">
{type.label}
</label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsSectionTemplateDialogOpen(false)}></Button>
<Button onClick={editingSectionTemplateId ? handleUpdateSectionTemplate : handleAddSectionTemplate}>
{editingSectionTemplateId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,428 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { ChevronUp, ChevronDown, Edit, Trash2, Plus, Settings } from 'lucide-react';
interface TabManagementDialogsProps {
// Manage Tabs Dialog
isManageTabsDialogOpen: boolean;
setIsManageTabsDialogOpen: (open: boolean) => void;
customTabs: Array<{ id: string; label: string; icon: string; order: number; isDefault?: boolean; key?: string }>;
moveTabUp: (id: string) => void;
moveTabDown: (id: string) => void;
handleEditTabFromManage: (tab: any) => void;
handleDeleteTab: (id: string) => void;
getTabIcon: (iconName: string) => any;
setIsAddTabDialogOpen: (open: boolean) => void;
// Delete Tab Dialog
isDeleteTabDialogOpen: boolean;
setIsDeleteTabDialogOpen: (open: boolean) => void;
deletingTabId: string | null;
setDeletingTabId: (id: string | null) => void;
confirmDeleteTab: () => void;
// Add/Edit Tab Dialog
isAddTabDialogOpen: boolean;
editingTabId: string | null;
setEditingTabId: (id: string | null) => void;
newTabLabel: string;
setNewTabLabel: (label: string) => void;
handleUpdateTab: () => void;
handleAddTab: () => void;
// Manage Attribute Tabs Dialog
isManageAttributeTabsDialogOpen: boolean;
setIsManageAttributeTabsDialogOpen: (open: boolean) => void;
attributeSubTabs: Array<{ id: string; label: string; key: string; order: number; isDefault?: boolean }>;
moveAttributeTabUp: (id: string) => void;
moveAttributeTabDown: (id: string) => void;
handleDeleteAttributeTab: (id: string) => void;
setIsAddAttributeTabDialogOpen: (open: boolean) => void;
// Delete Attribute Tab Dialog
isDeleteAttributeTabDialogOpen: boolean;
setIsDeleteAttributeTabDialogOpen: (open: boolean) => void;
deletingAttributeTabId: string | null;
setDeletingAttributeTabId: (id: string | null) => void;
confirmDeleteAttributeTab: () => void;
// Add/Edit Attribute Tab Dialog
isAddAttributeTabDialogOpen: boolean;
editingAttributeTabId: string | null;
setEditingAttributeTabId: (id: string | null) => void;
newAttributeTabLabel: string;
setNewAttributeTabLabel: (label: string) => void;
handleUpdateAttributeTab: () => void;
handleAddAttributeTab: () => void;
}
export function TabManagementDialogs({
// Manage Tabs Dialog
isManageTabsDialogOpen,
setIsManageTabsDialogOpen,
customTabs,
moveTabUp,
moveTabDown,
handleEditTabFromManage,
handleDeleteTab,
getTabIcon,
setIsAddTabDialogOpen,
// Delete Tab Dialog
isDeleteTabDialogOpen,
setIsDeleteTabDialogOpen,
deletingTabId,
setDeletingTabId,
confirmDeleteTab,
// Add/Edit Tab Dialog
isAddTabDialogOpen,
editingTabId,
setEditingTabId,
newTabLabel,
setNewTabLabel,
handleUpdateTab,
handleAddTab,
// Manage Attribute Tabs Dialog
isManageAttributeTabsDialogOpen,
setIsManageAttributeTabsDialogOpen,
attributeSubTabs,
moveAttributeTabUp,
moveAttributeTabDown,
handleDeleteAttributeTab,
setIsAddAttributeTabDialogOpen,
// Delete Attribute Tab Dialog
isDeleteAttributeTabDialogOpen,
setIsDeleteAttributeTabDialogOpen,
deletingAttributeTabId,
setDeletingAttributeTabId,
confirmDeleteAttributeTab,
// Add/Edit Attribute Tab Dialog
isAddAttributeTabDialogOpen,
editingAttributeTabId,
setEditingAttributeTabId,
newAttributeTabLabel,
setNewAttributeTabLabel,
handleUpdateAttributeTab,
handleAddAttributeTab,
}: TabManagementDialogsProps) {
return (
<>
{/* 탭 관리 다이얼로그 */}
<Dialog open={isManageTabsDialogOpen} onOpenChange={setIsManageTabsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
,
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{customTabs.sort((a, b) => a.order - b.order).map((tab, index) => {
const Icon = getTabIcon(tab.icon);
return (
<div
key={tab.id}
className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex flex-col gap-1">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => moveTabUp(tab.id)}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => moveTabDown(tab.id)}
disabled={index === customTabs.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">{tab.label}</div>
<div className="text-xs text-gray-500">
{tab.isDefault ? '기본 탭' : '사용자 정의 탭'} : {tab.order}
</div>
</div>
<div className="flex gap-2">
{!tab.isDefault && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleEditTabFromManage(tab)}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteTab(tab.id)}
>
<Trash2 className="h-4 w-4 mr-1 text-red-500" />
</Button>
</>
)}
{tab.isDefault && (
<Badge variant="secondary"> </Badge>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsManageTabsDialogOpen(false);
setIsAddTabDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => setIsManageTabsDialogOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 탭 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteTabDialogOpen} onOpenChange={setIsDeleteTabDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{customTabs.find(t => t.id === deletingTabId)?.label}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setIsDeleteTabDialogOpen(false);
setDeletingTabId(null);
}}>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteTab}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 탭 추가/수정 다이얼로그 */}
<Dialog open={isAddTabDialogOpen} onOpenChange={(open) => {
setIsAddTabDialogOpen(open);
if (!open) {
setEditingTabId(null);
setNewTabLabel('');
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTabId ? '탭 수정' : '탭 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newTabLabel}
onChange={(e) => setNewTabLabel(e.target.value)}
placeholder="예: 거래처, 창고"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddTabDialogOpen(false)}></Button>
<Button onClick={editingTabId ? handleUpdateTab : handleAddTab}>
{editingTabId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 속성 하위 탭 관리 다이얼로그 */}
<Dialog open={isManageAttributeTabsDialogOpen} onOpenChange={setIsManageAttributeTabsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
,
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{attributeSubTabs.sort((a, b) => a.order - b.order).map((tab, index) => {
const Icon = Settings;
return (
<div key={tab.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50">
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-gray-500" />
<div>
<div className="font-medium">{tab.label}</div>
<div className="text-sm text-gray-500">ID: {tab.key}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => moveAttributeTabUp(tab.id)}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => moveAttributeTabDown(tab.id)}
disabled={index === attributeSubTabs.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
{!tab.isDefault && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingAttributeTabId(tab.id);
setNewAttributeTabLabel(tab.label);
setIsManageAttributeTabsDialogOpen(false);
setIsAddAttributeTabDialogOpen(true);
}}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteAttributeTab(tab.id)}
>
<Trash2 className="h-4 w-4 mr-1 text-red-500" />
</Button>
</>
)}
{tab.isDefault && (
<Badge variant="secondary"> </Badge>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsManageAttributeTabsDialogOpen(false);
setIsAddAttributeTabDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => setIsManageAttributeTabsDialogOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 속성 하위 탭 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteAttributeTabDialogOpen} onOpenChange={setIsDeleteAttributeTabDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{attributeSubTabs.find(t => t.id === deletingAttributeTabId)?.label}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setIsDeleteAttributeTabDialogOpen(false);
setDeletingAttributeTabId(null);
}}>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteAttributeTab}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 속성 하위 탭 추가/수정 다이얼로그 */}
<Dialog open={isAddAttributeTabDialogOpen} onOpenChange={(open) => {
setIsAddAttributeTabDialogOpen(open);
if (!open) {
setEditingAttributeTabId(null);
setNewAttributeTabLabel('');
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingAttributeTabId ? '속성 탭 수정' : '속성 탭 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newAttributeTabLabel}
onChange={(e) => setNewAttributeTabLabel(e.target.value)}
placeholder="예: 색상, 규격"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddAttributeTabDialogOpen(false)}></Button>
<Button onClick={editingAttributeTabId ? handleUpdateAttributeTab : handleAddAttributeTab}>
{editingAttributeTabId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트 입력' },
{ value: 'number', label: '숫자 입력' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
{ value: 'date', label: '날짜' },
{ value: 'textarea', label: '긴 텍스트' },
];
interface TemplateFieldDialogProps {
isTemplateFieldDialogOpen: boolean;
setIsTemplateFieldDialogOpen: (open: boolean) => void;
editingTemplateFieldId: number | null;
setEditingTemplateFieldId: (id: number | null) => void;
templateFieldName: string;
setTemplateFieldName: (name: string) => void;
templateFieldKey: string;
setTemplateFieldKey: (key: string) => void;
templateFieldInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
setTemplateFieldInputType: (type: any) => void;
templateFieldRequired: boolean;
setTemplateFieldRequired: (required: boolean) => void;
templateFieldOptions: string;
setTemplateFieldOptions: (options: string) => void;
templateFieldDescription: string;
setTemplateFieldDescription: (description: string) => void;
templateFieldMultiColumn: boolean;
setTemplateFieldMultiColumn: (multi: boolean) => void;
templateFieldColumnCount: number;
setTemplateFieldColumnCount: (count: number) => void;
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: (names: string[]) => void;
handleAddTemplateField: () => void;
}
export function TemplateFieldDialog({
isTemplateFieldDialogOpen,
setIsTemplateFieldDialogOpen,
editingTemplateFieldId,
setEditingTemplateFieldId,
templateFieldName,
setTemplateFieldName,
templateFieldKey,
setTemplateFieldKey,
templateFieldInputType,
setTemplateFieldInputType,
templateFieldRequired,
setTemplateFieldRequired,
templateFieldOptions,
setTemplateFieldOptions,
templateFieldDescription,
setTemplateFieldDescription,
templateFieldMultiColumn,
setTemplateFieldMultiColumn,
templateFieldColumnCount,
setTemplateFieldColumnCount,
templateFieldColumnNames,
setTemplateFieldColumnNames,
handleAddTemplateField,
}: TemplateFieldDialogProps) {
return (
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={(open) => {
setIsTemplateFieldDialogOpen(open);
if (!open) {
setEditingTemplateFieldId(null);
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
setTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingTemplateFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={templateFieldName}
onChange={(e) => setTemplateFieldName(e.target.value)}
placeholder="예: 품목명"
/>
</div>
<div>
<Label> *</Label>
<Input
value={templateFieldKey}
onChange={(e) => setTemplateFieldKey(e.target.value)}
placeholder="예: itemName"
/>
</div>
</div>
<div>
<Label> *</Label>
<Select value={templateFieldInputType} onValueChange={(v: any) => setTemplateFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{templateFieldInputType === 'dropdown' && (
<div>
<Label> </Label>
<Input
value={templateFieldOptions}
onChange={(e) => setTemplateFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
)}
{(templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && (
<div className="space-y-3 border rounded-lg p-4 bg-muted/30">
<div className="flex items-center gap-2">
<Switch
checked={templateFieldMultiColumn}
onCheckedChange={setTemplateFieldMultiColumn}
/>
<Label> </Label>
</div>
{templateFieldMultiColumn && (
<>
<div>
<Label> </Label>
<Input
type="number"
min={2}
max={10}
value={templateFieldColumnCount}
onChange={(e) => {
const count = parseInt(e.target.value) || 2;
setTemplateFieldColumnCount(count);
const newNames = Array.from({ length: count }, (_, i) =>
templateFieldColumnNames[i] || `컬럼${i + 1}`
);
setTemplateFieldColumnNames(newNames);
}}
/>
</div>
<div className="space-y-2">
<Label></Label>
{Array.from({ length: templateFieldColumnCount }).map((_, idx) => (
<Input
key={idx}
placeholder={`컬럼 ${idx + 1}`}
value={templateFieldColumnNames[idx] || ''}
onChange={(e) => {
const newNames = [...templateFieldColumnNames];
newNames[idx] = e.target.value;
setTemplateFieldColumnNames(newNames);
}}
/>
))}
</div>
</>
)}
</div>
)}
<div>
<Label> ()</Label>
<Textarea
value={templateFieldDescription}
onChange={(e) => setTemplateFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={templateFieldRequired} onCheckedChange={setTemplateFieldRequired} />
<Label> </Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTemplateFieldDialogOpen(false)}></Button>
<Button onClick={handleAddTemplateField}>
{editingTemplateFieldId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,203 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Plus, Trash2, X } from 'lucide-react';
import { toast } from 'sonner';
interface ItemCategoryStructure {
[category1: string]: {
[category2: string]: string[];
};
}
interface CategoryTabProps {
itemCategories: ItemCategoryStructure;
setItemCategories: (categories: ItemCategoryStructure) => void;
newCategory1: string;
setNewCategory1: (value: string) => void;
newCategory2: string;
setNewCategory2: (value: string) => void;
newCategory3: string;
setNewCategory3: (value: string) => void;
selectedCategory1: string;
setSelectedCategory1: (value: string) => void;
selectedCategory2: string;
setSelectedCategory2: (value: string) => void;
}
export function CategoryTab({
itemCategories,
setItemCategories,
newCategory1,
setNewCategory1,
newCategory2,
setNewCategory2,
newCategory3,
setNewCategory3,
selectedCategory1,
setSelectedCategory1,
selectedCategory2,
setSelectedCategory2
}: CategoryTabProps) {
return (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> ( )</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* 대분류 추가 */}
<div className="border rounded-lg p-4">
<h3 className="font-medium mb-3"> </h3>
<div className="flex gap-2">
<Input
placeholder="대분류명 입력"
value={newCategory1}
onChange={(e) => setNewCategory1(e.target.value)}
/>
<Button onClick={() => {
if (!newCategory1.trim()) return toast.error('대분류명을 입력해주세요');
setItemCategories({ ...itemCategories, [newCategory1]: {} });
setNewCategory1('');
toast.success('대분류가 추가되었습니다');
}}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 대분류 목록 */}
<div className="space-y-4">
{Object.keys(itemCategories).map(cat1 => (
<div key={cat1} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-lg">{cat1}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newCategories = { ...itemCategories };
delete newCategories[cat1];
setItemCategories(newCategories);
toast.success('삭제되었습니다');
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
{/* 중분류 추가 */}
<div className="ml-4 mb-3">
<div className="flex gap-2">
<Input
placeholder="중분류명 입력"
value={selectedCategory1 === cat1 ? newCategory2 : ''}
onChange={(e) => {
setSelectedCategory1(cat1);
setNewCategory2(e.target.value);
}}
/>
<Button
size="sm"
onClick={() => {
if (!newCategory2.trim()) return toast.error('중분류명을 입력해주세요');
setItemCategories({
...itemCategories,
[cat1]: { ...itemCategories[cat1], [newCategory2]: [] }
});
setNewCategory2('');
toast.success('중분류가 추가되었습니다');
}}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 중분류 목록 */}
<div className="ml-4 space-y-3">
{Object.keys(itemCategories[cat1] || {}).map(cat2 => (
<div key={cat2} className="border-l-2 pl-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{cat2}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newCategories = { ...itemCategories };
delete newCategories[cat1][cat2];
setItemCategories(newCategories);
toast.success('삭제되었습니다');
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
{/* 소분류 추가 */}
<div className="ml-4 mb-2">
<div className="flex gap-2">
<Input
placeholder="소분류명 입력"
value={selectedCategory1 === cat1 && selectedCategory2 === cat2 ? newCategory3 : ''}
onChange={(e) => {
setSelectedCategory1(cat1);
setSelectedCategory2(cat2);
setNewCategory3(e.target.value);
}}
/>
<Button
size="sm"
onClick={() => {
if (!newCategory3.trim()) return toast.error('소분류명을 입력해주세요');
setItemCategories({
...itemCategories,
[cat1]: {
...itemCategories[cat1],
[cat2]: [...(itemCategories[cat1][cat2] || []), newCategory3]
}
});
setNewCategory3('');
toast.success('소분류가 추가되었습니다');
}}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 소분류 목록 */}
<div className="ml-4 flex flex-wrap gap-2">
{(itemCategories[cat1]?.[cat2] || []).map((cat3, idx) => (
<Badge key={idx} variant="secondary" className="flex items-center gap-1">
{cat3}
<button
onClick={() => {
const newCategories = { ...itemCategories };
newCategories[cat1][cat2] = newCategories[cat1][cat2].filter(c => c !== cat3);
setItemCategories(newCategories);
toast.success('삭제되었습니다');
}}
className="ml-1 hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,428 @@
import type { Dispatch, SetStateAction } from 'react';
import type { ItemPage, ItemSection } from '@/contexts/ItemMasterContext';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Edit, Trash2, Link, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { DraggableSection, DraggableField } from '../../components';
import { BOMManagementSection } from '@/components/items/BOMManagementSection';
interface HierarchyTabProps {
// Data
itemPages: ItemPage[];
selectedPage: ItemPage | undefined;
ITEM_TYPE_OPTIONS: Array<{ value: string; label: string }>;
// State
editingPageId: number | null;
setEditingPageId: (id: number | null) => void;
editingPageName: string;
setEditingPageName: (name: string) => void;
selectedPageId: number | null;
setSelectedPageId: (id: number | null) => void;
editingPathPageId: number | null;
setEditingPathPageId: (id: number | null) => void;
editingAbsolutePath: string;
setEditingAbsolutePath: (path: string) => void;
editingSectionId: string | null;
setEditingSectionId: (id: string | null) => void;
editingSectionTitle: string;
setEditingSectionTitle: (title: string) => void;
hasUnsavedChanges: boolean;
pendingChanges: {
pages: any[];
sections: any[];
fields: any[];
masterFields: any[];
attributes: any[];
sectionTemplates: any[];
};
selectedSectionForField: number | null;
setSelectedSectionForField: (id: number | null) => void;
newSectionType: 'fields' | 'bom';
setNewSectionType: Dispatch<SetStateAction<'fields' | 'bom'>>;
// Functions
updateItemPage: (id: number, data: any) => void;
trackChange: (type: 'pages' | 'sections' | 'fields' | 'masterFields' | 'attributes' | 'sectionTemplates', id: string, action: 'add' | 'update', data: any, attributeType?: string) => void;
deleteItemPage: (id: number) => void;
duplicatePage: (id: number) => void;
setIsPageDialogOpen: (open: boolean) => void;
setIsSectionDialogOpen: (open: boolean) => void;
setIsFieldDialogOpen: (open: boolean) => void;
handleEditSectionTitle: (sectionId: string, title: string) => void;
handleSaveSectionTitle: () => void;
moveSection: (dragIndex: number, hoverIndex: number) => void;
deleteSection: (pageId: number, sectionId: number) => void;
updateSection: (sectionId: number, updates: Partial<ItemSection>) => Promise<void>;
deleteField: (pageId: string, sectionId: string, fieldId: string) => void;
handleEditField: (sectionId: string, field: any) => void;
moveField: (sectionId: number, dragIndex: number, hoverIndex: number) => void;
}
export function HierarchyTab({
itemPages,
selectedPage,
ITEM_TYPE_OPTIONS,
editingPageId,
setEditingPageId,
editingPageName,
setEditingPageName,
selectedPageId,
setSelectedPageId,
editingPathPageId: _editingPathPageId,
setEditingPathPageId,
editingAbsolutePath: _editingAbsolutePath,
setEditingAbsolutePath,
editingSectionId,
setEditingSectionId,
editingSectionTitle,
setEditingSectionTitle,
hasUnsavedChanges: _hasUnsavedChanges,
pendingChanges: _pendingChanges,
selectedSectionForField: _selectedSectionForField,
setSelectedSectionForField,
newSectionType: _newSectionType,
setNewSectionType,
updateItemPage,
trackChange,
deleteItemPage,
duplicatePage: _duplicatePage,
setIsPageDialogOpen,
setIsSectionDialogOpen,
setIsFieldDialogOpen,
handleEditSectionTitle,
handleSaveSectionTitle,
moveSection,
deleteSection,
updateSection,
deleteField,
handleEditField,
moveField
}: HierarchyTabProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 섹션 목록 */}
<Card className="col-span-full md:col-span-1 max-h-[500px] md:max-h-[calc(100vh-300px)] flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button size="sm" onClick={() => setIsPageDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2 overflow-y-auto flex-1">
{itemPages.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4"> </p>
) : (
itemPages.map(page => (
<div key={page.id} className="relative group">
{editingPageId === page.id ? (
<div className="flex items-center gap-1 p-2 border rounded bg-white">
<Input
value={editingPageName}
onChange={(e) => setEditingPageName(e.target.value)}
className="h-7 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (!editingPageName.trim()) return toast.error('섹션명을 입력해주세요');
updateItemPage(page.id, { page_name: editingPageName });
trackChange('pages', String(page.id), 'update', { page_name: editingPageName });
setEditingPageId(null);
toast.success('페이지명이 수정되었습니다 (저장 필요)');
}
if (e.key === 'Escape') setEditingPageId(null);
}}
/>
</div>
) : (
<div
onClick={() => setSelectedPageId(page.id)}
onDoubleClick={() => {
setEditingPageId(page.id);
setEditingPageName(page.page_name);
}}
className={`w-full text-left p-2 rounded hover:bg-gray-50 transition-colors cursor-pointer ${
selectedPage?.id === page.id ? 'bg-blue-50 border border-blue-200' : 'border'
}`}
>
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{page.page_name}</div>
<div className="text-xs text-gray-500 truncate">
{ITEM_TYPE_OPTIONS.find(t => t.value === page.item_type)?.label}
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingPageId(page.id);
setEditingPageName(page.page_name);
}}
title="페이지명 수정"
>
<Edit className="h-3 w-3" />
</Button>
{/* 페이지 복제 기능 - 향후 사용을 위해 보관 (2025-11-20)
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
duplicatePage(page.id);
}}
title="복제"
>
<Copy className="h-3 w-3 text-green-500" />
</Button>
*/}
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
if (confirm('이 섹션과 모든 하위섹션, 항목을 삭제하시겠습니까?')) {
deleteItemPage(page.id);
if (selectedPageId === page.id) {
setSelectedPageId(itemPages[0]?.id || null);
}
toast.success('섹션이 삭제되었습니다');
}
}}
title="삭제"
>
<Trash2 className="h-3 w-3 text-red-500" />
</Button>
</div>
</div>
{/* 절대경로 표시 */}
{page.absolute_path && (
<div className="flex items-start gap-1 text-xs">
<Link className="h-3 w-3 text-gray-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-500 font-mono break-all flex-1 min-w-0">{page.absolute_path}</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingPathPageId(page.id);
setEditingAbsolutePath(page.absolute_path || '');
}}
title="Edit Path"
>
<Edit className="h-3 w-3 text-blue-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
const text = page.absolute_path || '';
// Modern API 시도 (브라우저 환경 체크)
if (typeof window !== 'undefined' && window.navigator.clipboard && window.navigator.clipboard.writeText) {
window.navigator.clipboard.writeText(text)
.then(() => alert('경로가 클립보드에 복사되었습니다'))
.catch(() => {
// Fallback 방식
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
alert('경로가 클립보드에 복사되었습니다');
} catch {
alert('복사에 실패했습니다');
}
document.body.removeChild(textArea);
});
} else {
// Fallback 방식
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
alert('경로가 클립보드에 복사되었습니다');
} catch {
alert('복사에 실패했습니다');
}
document.body.removeChild(textArea);
}
}}
title="경로 복사"
>
<Copy className="h-3 w-3 text-green-500" />
</Button>
</div>
</div>
)}
</div>
</div>
)}
</div>
))
)}
</CardContent>
</Card>
{/* 계층구조 */}
<Card className="md:col-span-3 max-h-[600px] md:max-h-[calc(100vh-300px)] flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-sm sm:text-base">{selectedPage?.page_name || '섹션을 선택하세요'}</CardTitle>
{/* 변경사항 배지 - 나중에 사용 예정으로 임시 숨김
{hasUnsavedChanges && (
<Badge variant="destructive" className="animate-pulse text-xs">
{pendingChanges.pages.length + pendingChanges.sectionTemplates.length + pendingChanges.fields.length + pendingChanges.masterFields.length + pendingChanges.attributes.length}개 변경
</Badge>
)}
*/}
</div>
{selectedPage && (
<Button
size="sm"
onClick={() => {
setNewSectionType('fields');
setIsSectionDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="overflow-y-auto flex-1">
{selectedPage ? (
<div className="h-full flex flex-col space-y-4">
{/* 일반 섹션 */}
<div className="space-y-4">
<div className="space-y-6">
{selectedPage.sections.length === 0 ? (
<p className="text-center text-gray-500 py-8"> </p>
) : (
selectedPage.sections
.map((section, index) => (
<DraggableSection
key={section.id}
section={section}
index={index}
moveSection={(dragIndex, hoverIndex) => {
moveSection(dragIndex, hoverIndex);
}}
onDelete={() => {
if (confirm('이 섹션과 모든 항목을 삭제하시겠습니까?')) {
deleteSection(selectedPage.id, section.id);
toast.success('섹션이 삭제되었습니다');
}
}}
onEditTitle={handleEditSectionTitle}
editingSectionId={editingSectionId}
editingSectionTitle={editingSectionTitle}
setEditingSectionTitle={setEditingSectionTitle}
setEditingSectionId={setEditingSectionId}
handleSaveSectionTitle={handleSaveSectionTitle}
>
{/* BOM 타입 섹션 */}
{section.section_type === 'BOM' ? (
<BOMManagementSection
title=""
description=""
bomItems={section.bomItems || []}
onAddItem={(item) => {
const now = new Date().toISOString();
const newBomItems = [...(section.bomItems || []), {
...item,
id: Date.now(),
section_id: section.id,
created_at: now,
updated_at: now
}];
updateSection(section.id, { bomItems: newBomItems });
toast.success('BOM 항목이 추가되었습니다');
}}
onUpdateItem={(id, updatedItem) => {
const newBomItems = (section.bomItems || []).map(item =>
item.id === id ? { ...item, ...updatedItem } : item
);
updateSection(section.id, { bomItems: newBomItems });
toast.success('BOM 항목이 수정되었습니다');
}}
onDeleteItem={(itemId) => {
const newBomItems = (section.bomItems || []).filter(item => item.id !== itemId);
updateSection(section.id, { bomItems: newBomItems });
toast.success('BOM 항목이 삭제되었습니다');
}}
/>
) : (
/* 일반 필드 타입 섹션 */
<>
{!section.fields || section.fields.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4"> </p>
) : (
section.fields
.sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0))
.map((field, fieldIndex) => (
<DraggableField
key={field.id}
field={field}
index={fieldIndex}
moveField={(dragIndex, hoverIndex) => moveField(section.id, dragIndex, hoverIndex)}
onDelete={() => {
if (confirm('이 항목을 삭제하시겠습니까?')) {
deleteField(String(selectedPage.id), String(section.id), String(field.id));
toast.success('항목이 삭제되었습니다');
}
}}
onEdit={() => handleEditField(String(section.id), field)}
/>
))
)}
<Button
size="sm"
variant="outline"
className="w-full mt-3"
onClick={() => {
setSelectedSectionForField(section.id);
setIsFieldDialogOpen(true);
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</>
)}
</DraggableSection>
))
)}
</div>
</div>
</div>
) : (
<p className="text-center text-gray-500 py-8"> </p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Trash2 } from 'lucide-react';
// 입력방식 옵션 (ItemMasterDataManagement에서 사용하는 상수)
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
{ value: 'number', label: '숫자' },
{ value: 'date', label: '날짜' },
{ value: 'textarea', label: '텍스트영역' }
];
// 변경 레코드 타입 (임시 - 나중에 공통 타입으로 분리)
interface ChangeRecord {
masterFields: Array<{
type: 'add' | 'update' | 'delete';
id: string;
data?: any;
}>;
[key: string]: any;
}
interface MasterFieldTabProps {
itemMasterFields: ItemMasterField[];
setIsMasterFieldDialogOpen: (open: boolean) => void;
handleEditMasterField: (field: ItemMasterField) => void;
handleDeleteMasterField: (id: number) => void;
hasUnsavedChanges: boolean;
pendingChanges: ChangeRecord;
}
export function MasterFieldTab({
itemMasterFields,
setIsMasterFieldDialogOpen,
handleEditMasterField,
handleDeleteMasterField,
hasUnsavedChanges,
pendingChanges
}: MasterFieldTabProps) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<CardTitle> </CardTitle>
<CardDescription> 릿 </CardDescription>
</div>
{/* 변경사항 배지 - 나중에 사용 예정으로 임시 숨김 */}
{false && hasUnsavedChanges && pendingChanges.masterFields.length > 0 && (
<Badge variant="destructive" className="animate-pulse">
{pendingChanges.masterFields.length}
</Badge>
)}
</div>
<Button onClick={() => setIsMasterFieldDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
{itemMasterFields.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
) : (
<div className="space-y-2">
{itemMasterFields.map((field) => (
<div key={field.id} className="flex items-center justify-between p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex-1">
<div className="flex items-center gap-2">
<span>{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.properties?.inputType)?.label}
</Badge>
{field.properties?.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
{(field.properties as any)?.attributeType && (field.properties as any).attributeType !== 'custom' && (
<Badge variant="default" className="text-xs bg-blue-500">
{(field.properties as any).attributeType === 'unit' ? '단위 연동' :
(field.properties as any).attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
ID: {field.id}
{field.description && (
<span className="ml-2"> {field.description}</span>
)}
</div>
{field.properties?.options && field.properties.options.length > 0 && (
<div className="text-xs text-gray-500 mt-1">
: {field.properties.options.join(', ')}
{(field.properties as any)?.attributeType && (field.properties as any).attributeType !== 'custom' && (
<span className="ml-2 text-blue-600">
( )
</span>
)}
</div>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleEditMasterField(field)}
>
<Edit className="h-4 w-4 text-blue-500" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteMasterField(field.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,309 @@
'use client';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Plus, Edit, Trash2, Folder, Package, FileText, GripVertical } from 'lucide-react';
import type { SectionTemplate, BOMItem } from '@/contexts/ItemMasterContext';
import { BOMManagementSection } from '../../BOMManagementSection';
interface SectionsTabProps {
// 섹션 템플릿 데이터
sectionTemplates: SectionTemplate[];
// 다이얼로그 상태
setIsSectionTemplateDialogOpen: (open: boolean) => void;
setCurrentTemplateId: (id: number | null) => void;
setIsTemplateFieldDialogOpen: (open: boolean) => void;
// 템플릿 핸들러
handleEditSectionTemplate: (template: SectionTemplate) => void;
handleDeleteSectionTemplate: (id: number) => void;
// 템플릿 필드 핸들러
handleEditTemplateField: (templateId: number, field: any) => void;
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
// BOM 핸들러
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'createdAt'>) => void;
handleUpdateBOMItemInTemplate: (templateId: number, itemId: number, item: Partial<BOMItem>) => void;
handleDeleteBOMItemFromTemplate: (templateId: number, itemId: number) => void;
// 옵션
ITEM_TYPE_OPTIONS: Array<{ value: string; label: string }>;
INPUT_TYPE_OPTIONS: Array<{ value: string; label: string }>;
// 변경사항 추적 (나중에 사용 예정)
hasUnsavedChanges?: boolean;
pendingChanges?: {
sectionTemplates: any[];
};
}
export function SectionsTab({
sectionTemplates,
setIsSectionTemplateDialogOpen,
setCurrentTemplateId,
setIsTemplateFieldDialogOpen,
handleEditSectionTemplate,
handleDeleteSectionTemplate,
handleEditTemplateField,
handleDeleteTemplateField,
handleAddBOMItemToTemplate,
handleUpdateBOMItemInTemplate,
handleDeleteBOMItemFromTemplate,
ITEM_TYPE_OPTIONS,
INPUT_TYPE_OPTIONS,
hasUnsavedChanges = false,
pendingChanges = { sectionTemplates: [] },
}: SectionsTabProps) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<CardTitle> 릿 </CardTitle>
<CardDescription> 릿 </CardDescription>
</div>
{/* 변경사항 배지 - 나중에 사용 예정으로 임시 숨김 */}
{false && hasUnsavedChanges && pendingChanges.sectionTemplates.length > 0 && (
<Badge variant="destructive" className="animate-pulse">
{pendingChanges.sectionTemplates.length}
</Badge>
)}
</div>
<Button onClick={() => setIsSectionTemplateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="general" className="flex items-center gap-2">
<Folder className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="module" className="flex items-center gap-2">
<Package className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 일반 섹션 탭 */}
<TabsContent value="general">
{(() => {
console.log('Rendering section templates:', {
totalTemplates: sectionTemplates.length,
generalTemplates: sectionTemplates.filter(t => t.section_type !== 'BOM').length,
templates: sectionTemplates.map(t => ({ id: t.id, template_name: t.template_name, section_type: t.section_type }))
});
return null;
})()}
{sectionTemplates.filter(t => t.section_type !== 'BOM').length === 0 ? (
<div className="text-center py-12">
<Folder className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
) : (
<div className="space-y-4">
{sectionTemplates.filter(t => t.section_type !== 'BOM').map((template) => (
<Card key={template.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<Folder className="h-5 w-5 text-blue-500" />
<div className="flex-1">
<CardTitle className="text-base">{template.template_name}</CardTitle>
{template.description && (
<CardDescription className="text-sm mt-0.5">{template.description}</CardDescription>
)}
</div>
</div>
<div className="flex items-center gap-2">
{template.category && template.category.length > 0 && (
<div className="flex flex-wrap gap-1 mr-2">
{template.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{ITEM_TYPE_OPTIONS.find(t => t.value === cat)?.label || cat}
</Badge>
))}
</div>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleEditSectionTemplate(template)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSectionTemplate(template.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
릿
</p>
<Button
size="sm"
onClick={() => {
setCurrentTemplateId(template.id);
setIsTemplateFieldDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{template.fields.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">
<FileText className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-600 mb-1">
</p>
<p className="text-sm text-gray-500">
, ,
</p>
</div>
</div>
) : (
<div className="space-y-2">
{template.fields.map((field, _index) => (
<div
key={field.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="text-sm font-medium">{field.name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.property.inputType)?.label}
</Badge>
{field.property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="ml-6 text-xs text-gray-500 mt-1">
: {field.fieldKey}
{field.description && (
<span className="ml-2"> {field.description}</span>
)}
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleEditTemplateField(template.id, field)}
>
<Edit className="h-4 w-4 text-blue-500" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteTemplateField(template.id, field.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* 모듈 섹션 (BOM) 탭 */}
<TabsContent value="module">
{sectionTemplates.filter(t => t.section_type === 'BOM').length === 0 ? (
<div className="text-center py-12">
<Package className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground">
BOM .
</p>
</div>
) : (
<div className="space-y-4">
{sectionTemplates.filter(t => t.section_type === 'BOM').map((template) => (
<Card key={template.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<Package className="h-5 w-5 text-green-500" />
<div className="flex-1">
<CardTitle className="text-base">{template.template_name}</CardTitle>
{template.description && (
<CardDescription className="text-sm mt-0.5">{template.description}</CardDescription>
)}
</div>
</div>
<div className="flex items-center gap-2">
{template.category && template.category.length > 0 && (
<div className="flex flex-wrap gap-1 mr-2">
{template.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{ITEM_TYPE_OPTIONS.find(t => t.value === cat)?.label || cat}
</Badge>
))}
</div>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleEditSectionTemplate(template)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSectionTemplate(template.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<BOMManagementSection
title=""
description=""
bomItems={template.bomItems || []}
onAddItem={(item) => handleAddBOMItemToTemplate(template.id, item)}
onUpdateItem={(itemId, item) => handleUpdateBOMItemInTemplate(template.id, itemId, item)}
onDeleteItem={(itemId) => handleDeleteBOMItemFromTemplate(template.id, itemId)}
/>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
export { CategoryTab } from './CategoryTab';
export { MasterFieldTab } from './MasterFieldTab';
export { HierarchyTab } from './HierarchyTab';
export { SectionsTab } from './SectionsTab';

View File

@@ -0,0 +1,34 @@
/**
* ItemMasterDataManagement 로컬 타입 정의
*
* 주요 타입들은 ItemMasterContext에서 import:
* - ItemPage, ItemSection, ItemField
* - FieldDisplayCondition, ItemMasterField
* - ItemFieldProperty, SectionTemplate
*/
// 옵션 칼럼 타입
export interface OptionColumn {
id: string;
name: string;
key: string;
type: 'text' | 'number';
required: boolean;
}
// 옵션 타입 (확장된 입력방식 지원)
export interface MasterOption {
id: string;
value: string;
label: string;
isActive: boolean;
// 입력 방식 및 속성
inputType?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
required?: boolean;
options?: string[]; // dropdown일 경우 선택 옵션
defaultValue?: string | number | boolean;
placeholder?: string;
// 기존 칼럼 시스템 (호환성 유지)
columns?: OptionColumn[]; // 칼럼 정의
columnValues?: Record<string, string>; // 칼럼별 값
}

View File

@@ -0,0 +1,37 @@
/**
* 경로 관련 유틸리티 함수
*/
/**
* 품목 타입과 페이지명으로 절대 경로 생성
* @param itemType - 품목 타입 (FG, PT, SM, RM, CS)
* @param pageName - 페이지명
* @returns 절대 경로 문자열
*/
export const generateAbsolutePath = (itemType: string, pageName: string): string => {
const typeMap: Record<string, string> = {
'FG': '제품관리',
'PT': '부품관리',
'SM': '부자재관리',
'RM': '원자재관리',
'CS': '소모품관리'
};
const category = typeMap[itemType] || '기타';
return `/${category}/${pageName}`;
};
/**
* 품목 타입 코드를 한글 카테고리명으로 변환
* @param itemType - 품목 타입 코드
* @returns 한글 카테고리명
*/
export const getItemTypeLabel = (itemType: string): string => {
const typeMap: Record<string, string> = {
'FG': '제품관리',
'PT': '부품관리',
'SM': '부자재관리',
'RM': '원자재관리',
'CS': '소모품관리'
};
return typeMap[itemType] || '기타';
};