[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

@@ -2,8 +2,7 @@
import { useAuthGuard } from '@/hooks/useAuthGuard';
import DashboardLayout from '@/layouts/DashboardLayout';
import { DataProvider } from '@/contexts/DataContext';
import { DeveloperModeProvider } from '@/contexts/DeveloperModeContext';
import { RootProvider } from '@/contexts/RootProvider';
/**
* Protected Layout
@@ -11,7 +10,7 @@ import { DeveloperModeProvider } from '@/contexts/DeveloperModeContext';
* Purpose:
* - Apply authentication guard to all protected pages
* - Apply common layout (sidebar, header) to all protected pages
* - Provide global context (DataProvider, DeveloperModeProvider)
* - Provide global context (RootProvider)
* - Prevent browser back button cache issues
* - Centralized protection for all routes under (protected)
*
@@ -32,10 +31,8 @@ export default function ProtectedLayout({
// 🎨 모든 하위 페이지에 공통 레이아웃 및 Context 적용
return (
<DataProvider>
<DeveloperModeProvider>
<DashboardLayout>{children}</DashboardLayout>
</DeveloperModeProvider>
</DataProvider>
<RootProvider>
<DashboardLayout>{children}</DashboardLayout>
</RootProvider>
);
}

View File

@@ -0,0 +1,60 @@
import { NextRequest } from 'next/server';
import { proxyToPhpBackend } from '@/lib/api/php-proxy';
/**
* 특정 페이지 조회 API
*
* 엔드포인트: GET /api/tenants/{tenantId}/item-master-config/pages/{pageId}
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string; pageId: string }> }
) {
const { tenantId, pageId } = await params;
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`,
{ method: 'GET' }
);
}
/**
* 특정 페이지 업데이트 API
*
* 엔드포인트: PUT /api/tenants/{tenantId}/item-master-config/pages/{pageId}
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string; pageId: string }> }
) {
const { tenantId, pageId } = await params;
const body = await request.json();
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`,
{
method: 'PUT',
body: JSON.stringify(body),
}
);
}
/**
* 특정 페이지 삭제 API
*
* 엔드포인트: DELETE /api/tenants/{tenantId}/item-master-config/pages/{pageId}
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string; pageId: string }> }
) {
const { tenantId, pageId } = await params;
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`,
{ method: 'DELETE' }
);
}

View File

@@ -0,0 +1,74 @@
import { NextRequest } from 'next/server';
import { proxyToPhpBackend, appendQueryParams } from '@/lib/api/php-proxy';
/**
* 품목기준관리 전체 설정 조회 API
*
* 엔드포인트: GET /api/tenants/{tenantId}/item-master-config
*
* 역할:
* - PHP 백엔드로 단순 프록시
* - tenant.id 검증은 PHP에서 수행
* - PHP가 403 반환하면 그대로 전달
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string }> }
) {
const { tenantId } = await params;
const { searchParams } = new URL(request.url);
// PHP 엔드포인트 생성 (query params 포함)
const phpEndpoint = appendQueryParams(
`/api/v1/tenants/${tenantId}/item-master-config`,
searchParams
);
return proxyToPhpBackend(request, phpEndpoint, {
method: 'GET',
});
}
/**
* 품목기준관리 전체 설정 저장 API
*
* 엔드포인트: POST /api/tenants/{tenantId}/item-master-config
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string }> }
) {
const { tenantId } = await params;
const body = await request.json();
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config`,
{
method: 'POST',
body: JSON.stringify(body),
}
);
}
/**
* 품목기준관리 전체 설정 업데이트 API
*
* 엔드포인트: PUT /api/tenants/{tenantId}/item-master-config
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ tenantId: string }> }
) {
const { tenantId } = await params;
const body = await request.json();
return proxyToPhpBackend(
request,
`/api/v1/tenants/${tenantId}/item-master-config`,
{
method: 'PUT',
body: JSON.stringify(body),
}
);
}

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] || '기타';
};

View File

@@ -1,32 +1,14 @@
'use client';
import { ReactNode, useEffect, useRef } from "react";
import { useDeveloperMode, ComponentMetadata } from '@/contexts/DeveloperModeContext';
import { ReactNode } from "react";
interface PageLayoutProps {
children: ReactNode;
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
devMetadata?: ComponentMetadata;
versionInfo?: ReactNode;
}
export function PageLayout({ children, maxWidth = "full", devMetadata, versionInfo }: PageLayoutProps) {
const { setCurrentMetadata } = useDeveloperMode();
const metadataRef = useRef<ComponentMetadata | null>(null);
useEffect(() => {
// Only update if metadata actually changed
if (devMetadata && JSON.stringify(devMetadata) !== JSON.stringify(metadataRef.current)) {
metadataRef.current = devMetadata;
setCurrentMetadata(devMetadata);
}
// 컴포넌트 언마운트 시 메타데이터 초기화
return () => {
setCurrentMetadata(null);
metadataRef.current = null;
};
}, []); // Empty dependency array - only run on mount/unmount
export function PageLayout({ children, maxWidth = "full", versionInfo }: PageLayoutProps) {
const maxWidthClasses = {
sm: "max-w-3xl",

View File

@@ -57,7 +57,7 @@ const DialogContent = React.forwardRef<
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg overflow-hidden",
className,
)}
{...props}

View File

@@ -0,0 +1,38 @@
// 에러 메시지 컴포넌트
// API 오류 메시지 일관된 UI로 표시
import React from 'react';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface ErrorMessageProps {
title?: string;
message: string;
onRetry?: () => void;
className?: string;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
title = '오류 발생',
message,
onRetry,
className = ''
}) => {
return (
<Alert variant="destructive" className={className}>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="mt-2">
<p>{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-2 text-sm underline hover:no-underline"
>
</button>
)}
</AlertDescription>
</Alert>
);
};

View File

@@ -0,0 +1,29 @@
// 로딩 스피너 컴포넌트
// API 호출 중 로딩 상태 표시용
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
text?: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
className = '',
text
}) => {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12'
};
return (
<div className={`flex flex-col items-center justify-center gap-2 ${className}`}>
<div className={`animate-spin rounded-full border-b-2 border-primary ${sizeClasses[size]}`} />
{text && <p className="text-sm text-muted-foreground">{text}</p>}
</div>
);
};

View File

@@ -0,0 +1,267 @@
'use client';
import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
// ===== 타입 정의 =====
// ✅ 추가: 테넌트 타입 (실제 서버 응답 구조)
export interface Tenant {
id: number; // 테넌트 고유 ID (number)
company_name: string; // 회사명
business_num: string; // 사업자번호
tenant_st_code: string; // 테넌트 상태 코드 (trial, active 등)
options?: { // 테넌트 옵션 (선택)
company_scale?: string; // 회사 규모
industry?: string; // 업종
};
}
// ✅ 추가: 권한 타입
export interface Role {
id: number;
name: string;
description: string;
}
// ✅ 추가: 메뉴 아이템 타입
export interface MenuItem {
id: string;
label: string;
iconName: string;
path: string;
}
// ✅ 수정: User 타입을 실제 서버 응답에 맞게 변경
export interface User {
userId: string; // 사용자 ID (username 아님)
name: string; // 사용자 이름
position: string; // 직책
roles: Role[]; // 권한 목록 (배열)
tenant: Tenant; // ✅ 테넌트 정보 (필수!)
menu: MenuItem[]; // 메뉴 목록
}
// ❌ 삭제 예정: 기존 UserRole (더 이상 사용하지 않음)
export type UserRole = 'CEO' | 'ProductionManager' | 'Worker' | 'SystemAdmin' | 'Sales';
// ===== Context 타입 =====
interface AuthContextType {
users: User[];
currentUser: User | null;
setCurrentUser: (user: User | null) => void;
addUser: (user: User) => void;
updateUser: (userId: string, updates: Partial<User>) => void;
deleteUser: (userId: string) => void;
getUserByUserId: (userId: string) => User | undefined;
logout: () => void; // ✅ 추가: 로그아웃
clearTenantCache: (tenantId: number) => void; // ✅ 추가: 테넌트 캐시 삭제
resetAllData: () => void;
}
// ===== 초기 데이터 =====
const initialUsers: User[] = [
{
userId: "TestUser1",
name: "김대표",
position: "대표이사",
roles: [
{
id: 1,
name: "ceo",
description: "최고경영자"
}
],
tenant: {
id: 282,
company_name: "(주)테크컴퍼니",
business_num: "123-45-67890",
tenant_st_code: "trial"
},
menu: [
{
id: "13664",
label: "시스템 대시보드",
iconName: "layout-dashboard",
path: "/dashboard"
}
]
},
{
userId: "TestUser2",
name: "박관리",
position: "생산관리자",
roles: [
{
id: 2,
name: "production_manager",
description: "생산관리자"
}
],
tenant: {
id: 282,
company_name: "(주)테크컴퍼니",
business_num: "123-45-67890",
tenant_st_code: "trial"
},
menu: [
{
id: "13664",
label: "시스템 대시보드",
iconName: "layout-dashboard",
path: "/dashboard"
}
]
},
{
userId: "TestUser3",
name: "드미트리",
position: "시스템 관리자",
roles: [
{
id: 19,
name: "system_manager",
description: "시스템 관리자"
}
],
tenant: {
id: 282,
company_name: "(주)테크컴퍼니",
business_num: "123-45-67890",
tenant_st_code: "trial"
},
menu: [
{
id: "13664",
label: "시스템 대시보드",
iconName: "layout-dashboard",
path: "/dashboard"
}
]
}
];
// ===== Context 생성 =====
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// ===== Provider 컴포넌트 =====
export function AuthProvider({ children }: { children: ReactNode }) {
// 상태 관리 (SSR-safe: 항상 초기값으로 시작)
const [users, setUsers] = useState<User[]>(initialUsers);
const [currentUser, setCurrentUser] = useState<User | null>(initialUsers[2]); // TestUser3 (드미트리)
// ✅ 추가: 이전 tenant.id 추적 (테넌트 전환 감지용)
const previousTenantIdRef = useRef<number | null>(null);
// localStorage에서 초기 데이터 로드 (클라이언트에서만 실행)
useEffect(() => {
try {
const savedUsers = localStorage.getItem('mes-users');
if (savedUsers) {
setUsers(JSON.parse(savedUsers));
}
const savedCurrentUser = localStorage.getItem('mes-currentUser');
if (savedCurrentUser) {
setCurrentUser(JSON.parse(savedCurrentUser));
}
} catch (error) {
console.error('Failed to load auth data from localStorage:', error);
// 손상된 데이터 제거
localStorage.removeItem('mes-users');
localStorage.removeItem('mes-currentUser');
}
}, []);
// localStorage 동기화 (상태 변경 시 자동 저장)
useEffect(() => {
localStorage.setItem('mes-users', JSON.stringify(users));
}, [users]);
useEffect(() => {
if (currentUser) {
localStorage.setItem('mes-currentUser', JSON.stringify(currentUser));
}
}, [currentUser]);
// ✅ 추가: 테넌트 전환 감지
useEffect(() => {
const prevTenantId = previousTenantIdRef.current;
const currentTenantId = currentUser?.tenant?.id;
if (prevTenantId && currentTenantId && prevTenantId !== currentTenantId) {
console.log(`[Auth] Tenant changed: ${prevTenantId}${currentTenantId}`);
clearTenantCache(prevTenantId);
}
previousTenantIdRef.current = currentTenantId || null;
}, [currentUser?.tenant?.id]);
// ✅ 추가: 테넌트별 캐시 삭제 함수 (SSR-safe)
const clearTenantCache = (tenantId: number) => {
// 서버 환경에서는 실행 안함
if (typeof window === 'undefined') return;
const prefix = `mes-${tenantId}-`;
// localStorage 캐시 삭제
Object.keys(localStorage).forEach(key => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key);
console.log(`[Cache] Cleared localStorage: ${key}`);
}
});
// sessionStorage 캐시 삭제
Object.keys(sessionStorage).forEach(key => {
if (key.startsWith(prefix)) {
sessionStorage.removeItem(key);
console.log(`[Cache] Cleared sessionStorage: ${key}`);
}
});
};
// ✅ 추가: 로그아웃 함수
const logout = () => {
if (currentUser?.tenant?.id) {
clearTenantCache(currentUser.tenant.id);
}
setCurrentUser(null);
localStorage.removeItem('mes-currentUser');
console.log('[Auth] Logged out and cleared tenant cache');
};
// Context value
const value: AuthContextType = {
users,
currentUser,
setCurrentUser,
addUser: (user) => setUsers(prev => [...prev, user]),
updateUser: (userId, updates) => setUsers(prev =>
prev.map(user => user.userId === userId ? { ...user, ...updates } : user)
),
deleteUser: (userId) => setUsers(prev => prev.filter(user => user.userId !== userId)),
getUserByUserId: (userId) => users.find(user => user.userId === userId),
logout,
clearTenantCache,
resetAllData: () => {
setUsers(initialUsers);
setCurrentUser(initialUsers[2]); // TestUser3
}
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// ===== Custom Hook =====
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -5219,41 +5219,49 @@ const DataContext = createContext<DataContextType | undefined>(undefined);
export function DataProvider({ children }: { children: ReactNode }) {
// 상태 관리
const [salesOrders, setSalesOrders] = useState<SalesOrder[]>(() => {
if (typeof window === 'undefined') return initialSalesOrders;
const saved = localStorage.getItem('mes-salesOrders');
return saved ? JSON.parse(saved) : initialSalesOrders;
});
const [quotes, setQuotes] = useState<Quote[]>(() => {
if (typeof window === 'undefined') return initialQuotes;
const saved = localStorage.getItem('mes-quotes');
return saved ? JSON.parse(saved) : initialQuotes;
});
const [productionOrders, setProductionOrders] = useState<ProductionOrder[]>(() => {
if (typeof window === 'undefined') return initialProductionOrders;
const saved = localStorage.getItem('mes-productionOrders');
return saved ? JSON.parse(saved) : initialProductionOrders;
});
const [qualityInspections, setQualityInspections] = useState<QualityInspection[]>(() => {
if (typeof window === 'undefined') return initialQualityInspections;
const saved = localStorage.getItem('mes-qualityInspections');
return saved ? JSON.parse(saved) : initialQualityInspections;
});
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>(() => {
if (typeof window === 'undefined') return initialInventoryItems;
const saved = localStorage.getItem('mes-inventoryItems');
return saved ? JSON.parse(saved) : initialInventoryItems;
});
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>(() => {
if (typeof window === 'undefined') return initialPurchaseOrders;
const saved = localStorage.getItem('mes-purchaseOrders');
return saved ? JSON.parse(saved) : initialPurchaseOrders;
});
const [employees, setEmployees] = useState<Employee[]>(() => {
if (typeof window === 'undefined') return initialEmployees;
const saved = localStorage.getItem('mes-employees');
return saved ? JSON.parse(saved) : initialEmployees;
});
const [attendances, setAttendances] = useState<Attendance[]>(() => {
if (typeof window === 'undefined') return initialAttendances;
const saved = localStorage.getItem('mes-attendances');
return saved ? JSON.parse(saved) : initialAttendances;
});

View File

@@ -1,113 +0,0 @@
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
export interface ComponentMetadata {
componentName: string;
pagePath: string;
description: string;
// API 정보
apis?: {
endpoint: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
description: string;
requestBody?: any;
responseBody?: any;
queryParams?: { name: string; type: string; required: boolean; description: string }[];
pathParams?: { name: string; type: string; description: string }[];
}[];
// 데이터 구조
dataStructures?: {
name: string;
type: string;
fields: { name: string; type: string; required: boolean; description: string }[];
example?: any;
}[];
// 컴포넌트 정보
components?: {
name: string;
path: string;
props?: { name: string; type: string; required: boolean; description: string }[];
children?: string[];
}[];
// 상태 관리
stateManagement?: {
type: 'Context' | 'Local' | 'Props';
name: string;
description: string;
methods?: string[];
}[];
// 의존성
dependencies?: {
package: string;
version?: string;
usage: string;
}[];
// DB 스키마 (백엔드)
dbSchema?: {
tableName: string;
columns: { name: string; type: string; nullable: boolean; key?: 'PK' | 'FK'; description: string }[];
indexes?: string[];
relations?: { table: string; type: '1:1' | '1:N' | 'N:M'; description: string }[];
}[];
// 비즈니스 로직
businessLogic?: {
name: string;
description: string;
steps: string[];
}[];
// 유효성 검사
validations?: {
field: string;
rules: string[];
errorMessages: string[];
}[];
}
interface DeveloperModeContextType {
isDeveloperMode: boolean;
setIsDeveloperMode: (value: boolean) => void;
currentMetadata: ComponentMetadata | null;
setCurrentMetadata: (metadata: ComponentMetadata | null) => void;
isConsoleExpanded: boolean;
setIsConsoleExpanded: (value: boolean) => void;
}
const DeveloperModeContext = createContext<DeveloperModeContextType | undefined>(undefined);
export function DeveloperModeProvider({ children }: { children: ReactNode }) {
const [isDeveloperMode, setIsDeveloperMode] = useState(false);
const [currentMetadata, setCurrentMetadata] = useState<ComponentMetadata | null>(null);
const [isConsoleExpanded, setIsConsoleExpanded] = useState(true);
return (
<DeveloperModeContext.Provider
value={{
isDeveloperMode,
setIsDeveloperMode,
currentMetadata,
setCurrentMetadata,
isConsoleExpanded,
setIsConsoleExpanded,
}}
>
{children}
</DeveloperModeContext.Provider>
);
}
export function useDeveloperMode() {
const context = useContext(DeveloperModeContext);
if (!context) {
throw new Error('useDeveloperMode must be used within DeveloperModeProvider');
}
return context;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
'use client';
import { ReactNode } from 'react';
import { AuthProvider } from './AuthContext';
import { ItemMasterProvider } from './ItemMasterContext';
/**
* RootProvider - 모든 Context Provider를 통합하는 최상위 Provider
*
* 현재 사용 중인 Context:
* 1. AuthContext - 사용자/인증 (2개 상태)
* 2. ItemMasterContext - 품목관리 (13개 상태)
*
* 미사용 Context (contexts/_unused/로 이동됨):
* - FacilitiesContext, AccountingContext, HRContext, ShippingContext
* - InventoryContext, ProductionContext, PricingContext, SalesContext
*/
export function RootProvider({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<ItemMasterProvider>
{children}
</ItemMasterProvider>
</AuthProvider>
);
}
/**
* 사용법:
*
* // app/layout.tsx
* import { RootProvider } from '@/contexts/RootProvider';
*
* export default function RootLayout({ children }) {
* return (
* <html>
* <body>
* <RootProvider>
* {children}
* </RootProvider>
* </body>
* </html>
* );
* }
*
* // 각 페이지/컴포넌트에서 사용:
* import { useAuth } from '@/contexts/AuthContext';
* import { useItemMaster } from '@/contexts/ItemMasterContext';
* import { useSales } from '@/contexts/SalesContext';
* // ... 등등
*/

View File

@@ -0,0 +1,48 @@
// 인증 헤더 유틸리티
// API 요청 시 자동으로 인증 헤더 추가
/**
* API 요청에 사용할 인증 헤더 생성
* - Content-Type: application/json
* - X-API-KEY: 환경변수에서 로드
* - Authorization: Bearer 토큰 (쿠키에서 추출)
*/
export const getAuthHeaders = (): HeadersInit => {
// TODO: 실제 프로젝트의 토큰 저장 방식에 맞춰 수정 필요
// 현재는 쿠키에서 'auth_token' 추출하는 방식
const token = typeof window !== 'undefined'
? document.cookie.split('; ').find(row => row.startsWith('auth_token='))?.split('=')[1]
: '';
return {
'Content-Type': 'application/json',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'Authorization': token ? `Bearer ${token}` : '',
};
};
/**
* Multipart/form-data 요청에 사용할 헤더 생성
* - Content-Type은 브라우저가 자동으로 설정 (boundary 포함)
* - X-API-KEY와 Authorization만 포함
*/
export const getMultipartHeaders = (): HeadersInit => {
const token = typeof window !== 'undefined'
? document.cookie.split('; ').find(row => row.startsWith('auth_token='))?.split('=')[1]
: '';
return {
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
'Authorization': token ? `Bearer ${token}` : '',
// Content-Type은 명시하지 않음 (multipart/form-data; boundary=... 자동 설정)
};
};
/**
* 토큰 존재 여부 확인
*/
export const hasAuthToken = (): boolean => {
if (typeof window === 'undefined') return false;
const token = document.cookie.split('; ').find(row => row.startsWith('auth_token='))?.split('=')[1];
return !!token;
};

View File

@@ -0,0 +1,85 @@
// API 에러 핸들링 헬퍼 유틸리티
// API 요청 실패 시 에러 처리 및 사용자 친화적 메시지 생성
/**
* API 에러 클래스
* - 표준 Error를 확장하여 HTTP 상태 코드와 validation errors 포함
*/
export class ApiError extends Error {
constructor(
public status: number,
public message: string,
public errors?: Record<string, string[]>
) {
super(message);
this.name = 'ApiError';
}
}
/**
* API 응답 에러를 처리하고 ApiError를 throw
* @param response - fetch Response 객체
* @throws {ApiError} HTTP 상태 코드, 메시지, validation errors 포함
*/
export const handleApiError = async (response: Response): Promise<never> => {
const data = await response.json().catch(() => ({}));
// 401 Unauthorized - 토큰 만료 또는 인증 실패
if (response.status === 401) {
// 로그인 페이지로 리다이렉트
if (typeof window !== 'undefined') {
// 현재 페이지 URL을 저장 (로그인 후 돌아오기 위함)
const currentPath = window.location.pathname + window.location.search;
sessionStorage.setItem('redirectAfterLogin', currentPath);
// 로그인 페이지로 이동
window.location.href = '/login?session=expired';
}
throw new ApiError(
401,
'인증이 만료되었습니다. 다시 로그인해주세요.',
data.errors
);
}
// 403 Forbidden - 권한 없음
if (response.status === 403) {
throw new ApiError(
403,
data.message || '접근 권한이 없습니다.',
data.errors
);
}
// 422 Unprocessable Entity - Validation 에러
if (response.status === 422) {
throw new ApiError(
422,
data.message || '입력값을 확인해주세요.',
data.errors
);
}
// 기타 에러
throw new ApiError(
response.status,
data.message || '서버 오류가 발생했습니다',
data.errors
);
};
/**
* 에러 객체에서 사용자 친화적인 메시지 추출
* @param error - 발생한 에러 객체 (ApiError, Error, unknown)
* @returns 사용자에게 표시할 에러 메시지
*/
export const getErrorMessage = (error: unknown): string => {
if (error instanceof ApiError) {
return error.message;
}
if (error instanceof Error) {
return error.message;
}
return '알 수 없는 오류가 발생했습니다';
};

1184
src/lib/api/item-master.ts Normal file

File diff suppressed because it is too large Load Diff

360
src/lib/api/logger.ts Normal file
View File

@@ -0,0 +1,360 @@
// API 호출 로깅 유틸리티
// 개발 중 API 요청/응답을 추적하고 디버깅하기 위한 로거
/**
* 로그 레벨
*/
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
}
/**
* API 로그 항목 인터페이스
*/
interface ApiLogEntry {
timestamp: string;
level: LogLevel;
method: string;
url: string;
requestData?: any;
responseData?: any;
statusCode?: number;
error?: Error;
duration?: number;
}
/**
* API Logger 클래스
*/
class ApiLogger {
private enabled: boolean;
private logs: ApiLogEntry[] = [];
private maxLogs: number = 100;
constructor() {
// 개발 환경에서만 로깅 활성화
this.enabled =
process.env.NODE_ENV === 'development' ||
process.env.NEXT_PUBLIC_API_LOGGING === 'true';
}
/**
* 로깅 활성화 여부 설정
*/
setEnabled(enabled: boolean) {
this.enabled = enabled;
}
/**
* 로그 최대 개수 설정
*/
setMaxLogs(max: number) {
this.maxLogs = max;
}
/**
* API 요청 시작 로그
*/
logRequest(method: string, url: string, data?: any): number {
if (!this.enabled) return Date.now();
const startTime = Date.now();
const entry: ApiLogEntry = {
timestamp: new Date().toISOString(),
level: LogLevel.INFO,
method,
url,
requestData: data,
};
console.group(`🚀 API Request: ${method} ${url}`);
console.log('⏰ Time:', entry.timestamp);
if (data) {
console.log('📤 Request Data:', data);
}
console.groupEnd();
this.addLog(entry);
return startTime;
}
/**
* API 응답 성공 로그
*/
logResponse(
method: string,
url: string,
statusCode: number,
data: any,
startTime: number
) {
if (!this.enabled) return;
const duration = Date.now() - startTime;
const entry: ApiLogEntry = {
timestamp: new Date().toISOString(),
level: LogLevel.INFO,
method,
url,
responseData: data,
statusCode,
duration,
};
console.group(`✅ API Response: ${method} ${url}`);
console.log('⏰ Time:', entry.timestamp);
console.log('📊 Status:', statusCode);
console.log('⏱️ Duration:', `${duration}ms`);
console.log('📥 Response Data:', data);
console.groupEnd();
this.addLog(entry);
}
/**
* API 에러 로그
*/
logError(
method: string,
url: string,
error: Error,
statusCode?: number,
startTime?: number
) {
if (!this.enabled) return;
const duration = startTime ? Date.now() - startTime : undefined;
const entry: ApiLogEntry = {
timestamp: new Date().toISOString(),
level: LogLevel.ERROR,
method,
url,
error,
statusCode,
duration,
};
console.group(`❌ API Error: ${method} ${url}`);
console.log('⏰ Time:', entry.timestamp);
if (statusCode) {
console.log('📊 Status:', statusCode);
}
if (duration) {
console.log('⏱️ Duration:', `${duration}ms`);
}
console.error('💥 Error:', error);
console.groupEnd();
this.addLog(entry);
}
/**
* 경고 로그
*/
logWarning(message: string, data?: any) {
if (!this.enabled) return;
const entry: ApiLogEntry = {
timestamp: new Date().toISOString(),
level: LogLevel.WARN,
method: 'WARN',
url: message,
requestData: data,
};
console.warn(`⚠️ API Warning: ${message}`, data);
this.addLog(entry);
}
/**
* 디버그 로그
*/
logDebug(message: string, data?: any) {
if (!this.enabled) return;
const entry: ApiLogEntry = {
timestamp: new Date().toISOString(),
level: LogLevel.DEBUG,
method: 'DEBUG',
url: message,
requestData: data,
};
console.debug(`🔍 API Debug: ${message}`, data);
this.addLog(entry);
}
/**
* 로그 추가 및 최대 개수 관리
*/
private addLog(entry: ApiLogEntry) {
this.logs.push(entry);
if (this.logs.length > this.maxLogs) {
this.logs.shift(); // 가장 오래된 로그 제거
}
}
/**
* 모든 로그 조회
*/
getLogs(): ApiLogEntry[] {
return [...this.logs];
}
/**
* 특정 레벨의 로그만 조회
*/
getLogsByLevel(level: LogLevel): ApiLogEntry[] {
return this.logs.filter((log) => log.level === level);
}
/**
* 에러 로그만 조회
*/
getErrors(): ApiLogEntry[] {
return this.getLogsByLevel(LogLevel.ERROR);
}
/**
* 모든 로그 삭제
*/
clearLogs() {
this.logs = [];
console.log('🗑️ API logs cleared');
}
/**
* 로그 통계 조회
*/
getStats() {
const stats = {
total: this.logs.length,
byLevel: {
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 0,
[LogLevel.WARN]: 0,
[LogLevel.ERROR]: 0,
},
averageDuration: 0,
errorRate: 0,
};
let totalDuration = 0;
let countWithDuration = 0;
this.logs.forEach((log) => {
stats.byLevel[log.level]++;
if (log.duration) {
totalDuration += log.duration;
countWithDuration++;
}
});
if (countWithDuration > 0) {
stats.averageDuration = totalDuration / countWithDuration;
}
if (stats.total > 0) {
stats.errorRate =
(stats.byLevel[LogLevel.ERROR] / stats.total) * 100;
}
return stats;
}
/**
* 로그 통계 출력
*/
printStats() {
const stats = this.getStats();
console.group('📊 API Logger Statistics');
console.log('Total Logs:', stats.total);
console.log('By Level:', stats.byLevel);
console.log(
'Average Duration:',
`${stats.averageDuration.toFixed(2)}ms`
);
console.log('Error Rate:', `${stats.errorRate.toFixed(2)}%`);
console.groupEnd();
}
/**
* 로그를 JSON으로 내보내기
*/
exportLogs(): string {
return JSON.stringify(this.logs, null, 2);
}
/**
* 로그를 콘솔에 테이블로 출력
*/
printLogsAsTable() {
if (this.logs.length === 0) {
console.log('📭 No logs available');
return;
}
const tableData = this.logs.map((log) => ({
Timestamp: log.timestamp,
Level: log.level,
Method: log.method,
URL: log.url,
Status: log.statusCode || '-',
Duration: log.duration ? `${log.duration}ms` : '-',
Error: log.error?.message || '-',
}));
console.table(tableData);
}
}
// 싱글톤 인스턴스 생성
export const apiLogger = new ApiLogger();
/**
* API 호출 래퍼 함수
* 자동으로 요청/응답을 로깅합니다
*/
export async function loggedFetch<T>(
method: string,
url: string,
options?: RequestInit
): Promise<T> {
const startTime = apiLogger.logRequest(method, url, options?.body);
try {
const response = await fetch(url, {
...options,
method,
});
const data = await response.json();
apiLogger.logResponse(method, url, response.status, data, startTime);
if (!response.ok) {
throw new Error(data.message || 'API request failed');
}
return data;
} catch (error) {
apiLogger.logError(method, url, error as Error, undefined, startTime);
throw error;
}
}
// 개발 도구를 window에 노출 (브라우저 콘솔에서 사용 가능)
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
(window as any).apiLogger = apiLogger;
console.log(
'💡 API Logger is available in console as "apiLogger"\n' +
' - apiLogger.getLogs() - View all logs\n' +
' - apiLogger.getErrors() - View errors only\n' +
' - apiLogger.printStats() - View statistics\n' +
' - apiLogger.printLogsAsTable() - View logs as table\n' +
' - apiLogger.clearLogs() - Clear all logs'
);
}

449
src/lib/api/mock-data.ts Normal file
View File

@@ -0,0 +1,449 @@
// API Mock 데이터
// 백엔드 API 준비 전 프론트엔드 개발용 Mock 데이터
import type {
ItemPageResponse,
ItemSectionResponse,
ItemFieldResponse,
BomItemResponse,
SectionTemplateResponse,
MasterFieldResponse,
InitResponse,
} from '@/types/item-master-api';
// ============================================
// Mock Pages
// ============================================
export const mockPages: ItemPageResponse[] = [
{
id: 1,
tenant_id: 1,
page_name: '완제품(FG)',
item_type: 'FG',
absolute_path: '/item-master/FG',
is_active: true,
sections: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
page_name: '반제품(PT)',
item_type: 'PT',
absolute_path: '/item-master/PT',
is_active: true,
sections: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
tenant_id: 1,
page_name: '원자재(RM)',
item_type: 'RM',
absolute_path: '/item-master/RM',
is_active: true,
sections: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Sections
// ============================================
export const mockSections: ItemSectionResponse[] = [
{
id: 1,
tenant_id: 1,
page_id: 1,
title: '기본 정보',
type: 'fields',
order_no: 1,
fields: [],
bomItems: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
page_id: 1,
title: 'BOM',
type: 'bom',
order_no: 2,
fields: [],
bomItems: [],
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Fields
// ============================================
export const mockFields: ItemFieldResponse[] = [
{
id: 1,
tenant_id: 1,
section_id: 1,
field_name: '품목코드',
field_type: 'textbox',
order_no: 1,
is_required: true,
placeholder: '품목코드를 입력하세요',
default_value: null,
display_condition: null,
validation_rules: { maxLength: 50 },
options: null,
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
section_id: 1,
field_name: '품목명',
field_type: 'textbox',
order_no: 2,
is_required: true,
placeholder: '품목명을 입력하세요',
default_value: null,
display_condition: null,
validation_rules: { maxLength: 100 },
options: null,
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
tenant_id: 1,
section_id: 1,
field_name: '단위',
field_type: 'dropdown',
order_no: 3,
is_required: true,
placeholder: '단위를 선택하세요',
default_value: null,
display_condition: null,
validation_rules: null,
options: ['EA', 'KG', 'L', 'M', 'SET'],
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 4,
tenant_id: 1,
section_id: 1,
field_name: '수량',
field_type: 'number',
order_no: 4,
is_required: false,
placeholder: '수량을 입력하세요',
default_value: '0',
display_condition: null,
validation_rules: { min: 0 },
options: null,
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock BOM Items
// ============================================
export const mockBomItems: BomItemResponse[] = [
{
id: 1,
tenant_id: 1,
section_id: 2,
item_code: 'RM-001',
item_name: '철판',
quantity: 5,
unit: 'KG',
unit_price: 10000,
total_price: 50000,
spec: 'SUS304 2T',
note: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
section_id: 2,
item_code: 'PT-001',
item_name: '플레이트',
quantity: 2,
unit: 'EA',
unit_price: 25000,
total_price: 50000,
spec: '200x200mm',
note: '표면처리 필요',
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Section Templates
// ============================================
export const mockSectionTemplates: SectionTemplateResponse[] = [
{
id: 1,
tenant_id: 1,
title: '기본정보 템플릿',
type: 'fields',
description: '품목 기본 정보 입력용 템플릿',
is_default: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
title: 'BOM 템플릿',
type: 'bom',
description: 'BOM 관리용 템플릿',
is_default: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Master Fields
// ============================================
export const mockMasterFields: MasterFieldResponse[] = [
{
id: 1,
tenant_id: 1,
field_name: '품목코드',
field_type: 'textbox',
category: 'basic',
description: '품목 고유 코드',
is_common: true,
default_value: null,
options: null,
validation_rules: { required: true, maxLength: 50 },
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
field_name: '품목명',
field_type: 'textbox',
category: 'basic',
description: '품목 명칭',
is_common: true,
default_value: null,
options: null,
validation_rules: { required: true, maxLength: 100 },
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
tenant_id: 1,
field_name: '단위',
field_type: 'dropdown',
category: 'basic',
description: '수량 단위',
is_common: true,
default_value: 'EA',
options: ['EA', 'KG', 'L', 'M', 'SET'],
validation_rules: { required: true },
properties: null,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
];
// ============================================
// Mock Init Response
// ============================================
export const mockInitResponse: InitResponse = {
pages: mockPages,
sections: mockSections,
fields: mockFields,
bom_items: mockBomItems,
section_templates: mockSectionTemplates,
master_fields: mockMasterFields,
custom_tabs: [
{
id: 1,
tenant_id: 1,
tab_name: '사용자 정의 탭',
item_type: 'FG',
order_no: 10,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
unit_options: [
{
id: 1,
tenant_id: 1,
option_type: 'unit',
option_value: 'EA',
display_name: '개',
order_no: 1,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
tenant_id: 1,
option_type: 'unit',
option_value: 'KG',
display_name: '킬로그램',
order_no: 2,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 3,
tenant_id: 1,
option_type: 'unit',
option_value: 'L',
display_name: '리터',
order_no: 3,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
material_options: [
{
id: 4,
tenant_id: 1,
option_type: 'material',
option_value: 'SUS304',
display_name: '스테인리스 304',
order_no: 1,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 5,
tenant_id: 1,
option_type: 'material',
option_value: 'AL',
display_name: '알루미늄',
order_no: 2,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
surface_treatment_options: [
{
id: 6,
tenant_id: 1,
option_type: 'surface_treatment',
option_value: 'ANODIZING',
display_name: '아노다이징',
order_no: 1,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 7,
tenant_id: 1,
option_type: 'surface_treatment',
option_value: 'PAINTING',
display_name: '도장',
order_no: 2,
is_active: true,
created_by: 1,
updated_by: 1,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
],
};
// ============================================
// Mock 모드 활성화 플래그
// ============================================
/**
* Mock 모드 활성화 여부
* - true: Mock 데이터 사용 (백엔드 없이 프론트엔드 개발)
* - false: 실제 API 호출
*/
export const MOCK_MODE = process.env.NEXT_PUBLIC_MOCK_MODE === 'true';
/**
* Mock API 응답 시뮬레이션 (네트워크 지연 재현)
*/
export const simulateNetworkDelay = async (ms: number = 500) => {
if (!MOCK_MODE) return;
await new Promise((resolve) => setTimeout(resolve, ms));
};

97
src/lib/api/php-proxy.ts Normal file
View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* PHP 백엔드 프록시 유틸리티
*
* 역할:
* - Next.js API Routes → PHP Backend 단순 프록시
* - HttpOnly 쿠키의 access_token을 Bearer token으로 전달
* - PHP 응답을 그대로 프론트엔드로 반환
*
* 보안:
* - tenant.id 검증은 PHP 백엔드에서 수행
* - Next.js는 단순히 요청/응답 전달만
*/
/**
* PHP 백엔드로 프록시 요청 전송
*
* @param request NextRequest 객체
* @param phpEndpoint PHP 백엔드 엔드포인트 (예: '/api/v1/tenants/282/item-master-config')
* @param options fetch options (method, body 등)
* @returns NextResponse
*/
export async function proxyToPhpBackend(
request: NextRequest,
phpEndpoint: string,
options?: RequestInit
): Promise<NextResponse> {
try {
// 1. 쿠키에서 access_token 추출
const accessToken = request.cookies.get('access_token')?.value;
if (!accessToken) {
return NextResponse.json(
{
success: false,
error: {
code: 'UNAUTHORIZED',
message: '인증이 필요합니다.',
},
},
{ status: 401 }
);
}
// 2. PHP 백엔드 URL 생성
const phpUrl = `${process.env.NEXT_PUBLIC_API_URL}${phpEndpoint}`;
// 3. PHP 백엔드 호출
const response = await fetch(phpUrl, {
...options,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
...options?.headers,
},
});
// 4. PHP 응답을 그대로 반환
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('[PHP Proxy Error]', error);
return NextResponse.json(
{
success: false,
error: {
code: 'SERVER_ERROR',
message: '서버 오류가 발생했습니다.',
},
},
{ status: 500 }
);
}
}
/**
* Query Parameters를 URL에 추가하는 헬퍼
*
* @param baseUrl 기본 URL
* @param searchParams URLSearchParams
* @returns Query string이 추가된 URL
*/
export function appendQueryParams(baseUrl: string, searchParams: URLSearchParams): string {
const params = new URLSearchParams();
searchParams.forEach((value, key) => {
params.append(key, value);
});
const queryString = params.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}

421
src/lib/api/transformers.ts Normal file
View File

@@ -0,0 +1,421 @@
// API 응답 데이터 변환 헬퍼
// API 응답 (snake_case + 특정 값) ↔ Frontend State (snake_case + 변환된 값)
import type {
ItemPageResponse,
ItemSectionResponse,
ItemFieldResponse,
BomItemResponse,
SectionTemplateResponse,
MasterFieldResponse,
UnitOptionResponse,
CustomTabResponse,
} from '@/types/item-master-api';
import type {
ItemPage,
ItemSection,
ItemField,
BOMItem,
SectionTemplate,
ItemMasterField,
} from '@/contexts/ItemMasterContext';
// ============================================
// 타입 값 변환 매핑
// ============================================
/**
* API section type → Frontend section_type 변환
* API: 'fields' | 'bom'
* Frontend: 'BASIC' | 'BOM' | 'CUSTOM'
*/
const SECTION_TYPE_MAP: Record<string, 'BASIC' | 'BOM' | 'CUSTOM'> = {
fields: 'BASIC',
bom: 'BOM',
};
/**
* Frontend section_type → API section type 변환
*/
const SECTION_TYPE_REVERSE_MAP: Record<string, 'fields' | 'bom'> = {
BASIC: 'fields',
BOM: 'bom',
CUSTOM: 'fields', // CUSTOM은 fields로 매핑
};
/**
* API field_type → Frontend field_type 변환
* API: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'
* Frontend: 'TEXT' | 'NUMBER' | 'DATE' | 'SELECT' | 'TEXTAREA' | 'CHECKBOX'
*/
const FIELD_TYPE_MAP: Record<
string,
'TEXT' | 'NUMBER' | 'DATE' | 'SELECT' | 'TEXTAREA' | 'CHECKBOX'
> = {
textbox: 'TEXT',
number: 'NUMBER',
dropdown: 'SELECT',
checkbox: 'CHECKBOX',
date: 'DATE',
textarea: 'TEXTAREA',
};
/**
* Frontend field_type → API field_type 변환
*/
const FIELD_TYPE_REVERSE_MAP: Record<
string,
'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'
> = {
TEXT: 'textbox',
NUMBER: 'number',
SELECT: 'dropdown',
CHECKBOX: 'checkbox',
DATE: 'date',
TEXTAREA: 'textarea',
};
// ============================================
// API Response → Frontend State 변환
// ============================================
/**
* ItemPageResponse → ItemPage 변환
*/
export const transformPageResponse = (
response: ItemPageResponse
): ItemPage => {
return {
id: response.id,
tenant_id: response.tenant_id,
page_name: response.page_name,
item_type: response.item_type as 'FG' | 'PT' | 'SM' | 'RM' | 'CS',
absolute_path: response.absolute_path,
is_active: response.is_active,
sections: response.sections?.map(transformSectionResponse) || [],
created_by: response.created_by,
updated_by: response.updated_by,
created_at: response.created_at,
updated_at: response.updated_at,
};
};
/**
* ItemSectionResponse → ItemSection 변환
* 주요 변환: type → section_type, 값 변환 (fields → BASIC, bom → BOM)
*/
export const transformSectionResponse = (
response: ItemSectionResponse
): ItemSection => {
return {
id: response.id,
tenant_id: response.tenant_id,
page_id: response.page_id,
title: response.title,
section_type: SECTION_TYPE_MAP[response.type] || 'BASIC', // 타입 값 변환
order_no: response.order_no,
fields: response.fields?.map(transformFieldResponse) || [],
bom_items: response.bomItems?.map(transformBomItemResponse) || [],
created_by: response.created_by,
updated_by: response.updated_by,
created_at: response.created_at,
updated_at: response.updated_at,
};
};
/**
* ItemFieldResponse → ItemField 변환
* 주요 변환: field_type 값 변환 (textbox → TEXT, dropdown → SELECT 등)
*/
export const transformFieldResponse = (
response: ItemFieldResponse
): ItemField => {
return {
id: response.id,
tenant_id: response.tenant_id,
section_id: response.section_id,
field_name: response.field_name,
field_type: FIELD_TYPE_MAP[response.field_type] || 'TEXT', // 타입 값 변환
order_no: response.order_no,
is_required: response.is_required,
placeholder: response.placeholder,
default_value: response.default_value,
display_condition: response.display_condition,
validation_rules: response.validation_rules,
options: response.options,
properties: response.properties,
created_by: response.created_by,
updated_by: response.updated_by,
created_at: response.created_at,
updated_at: response.updated_at,
};
};
/**
* BomItemResponse → BOMItem 변환
*/
export const transformBomItemResponse = (
response: BomItemResponse
): BOMItem => {
return {
id: response.id,
tenant_id: response.tenant_id,
section_id: response.section_id,
item_code: response.item_code,
item_name: response.item_name,
quantity: response.quantity,
unit: response.unit,
unit_price: response.unit_price,
total_price: response.total_price,
spec: response.spec,
note: response.note,
created_by: response.created_by,
updated_by: response.updated_by,
created_at: response.created_at,
updated_at: response.updated_at,
};
};
/**
* SectionTemplateResponse → SectionTemplate 변환
* 주요 변환: title → template_name, type → section_type, 값 변환
*/
export const transformSectionTemplateResponse = (
response: SectionTemplateResponse
): SectionTemplate => {
return {
id: response.id,
tenant_id: response.tenant_id,
template_name: response.title, // 필드명 변환
section_type: SECTION_TYPE_MAP[response.type] || 'BASIC', // 타입 값 변환
description: response.description,
default_fields: null, // API 응답에 없으므로 null
created_by: response.created_by,
updated_by: response.updated_by,
created_at: response.created_at,
updated_at: response.updated_at,
};
};
/**
* MasterFieldResponse → ItemMasterField 변환
* 주요 변환: field_type 값 변환 (textbox → TEXT, dropdown → SELECT 등)
*/
export const transformMasterFieldResponse = (
response: MasterFieldResponse
): ItemMasterField => {
return {
id: response.id,
tenant_id: response.tenant_id,
field_name: response.field_name,
field_type: FIELD_TYPE_MAP[response.field_type] || 'TEXT', // 타입 값 변환
category: response.category,
description: response.description,
default_validation: response.validation_rules, // 필드명 매핑
default_properties: response.properties, // 필드명 매핑
created_by: response.created_by,
updated_by: response.updated_by,
created_at: response.created_at,
updated_at: response.updated_at,
};
};
// ============================================
// Frontend State → API Request 변환
// ============================================
/**
* ItemSection → ItemSectionRequest 변환
* 주요 변환: section_type → type, 값 역변환 (BASIC → fields, BOM → bom)
*/
export const transformSectionToRequest = (
section: Partial<ItemSection>
): { title: string; type: 'fields' | 'bom' } => {
return {
title: section.title || '',
type: section.section_type
? SECTION_TYPE_REVERSE_MAP[section.section_type] || 'fields'
: 'fields',
};
};
/**
* ItemField → ItemFieldRequest 변환
* 주요 변환: field_type 값 역변환 (TEXT → textbox, SELECT → dropdown 등)
*/
export const transformFieldToRequest = (field: Partial<ItemField>) => {
return {
field_name: field.field_name || '',
field_type: field.field_type
? FIELD_TYPE_REVERSE_MAP[field.field_type] || 'textbox'
: 'textbox',
is_required: field.is_required ?? false,
placeholder: field.placeholder || null,
default_value: field.default_value || null,
display_condition: field.display_condition || null,
validation_rules: field.validation_rules || null,
options: field.options || null,
properties: field.properties || null,
};
};
/**
* BOMItem → BomItemRequest 변환
*/
export const transformBomItemToRequest = (bomItem: Partial<BOMItem>) => {
return {
item_code: bomItem.item_code || undefined,
item_name: bomItem.item_name || '',
quantity: bomItem.quantity || 0,
unit: bomItem.unit || undefined,
unit_price: bomItem.unit_price || undefined,
total_price: bomItem.total_price || undefined,
spec: bomItem.spec || undefined,
note: bomItem.note || undefined,
};
};
/**
* SectionTemplate → SectionTemplateRequest 변환
* 주요 변환: template_name → title, section_type → type, 값 역변환
*/
export const transformSectionTemplateToRequest = (
template: Partial<SectionTemplate>
) => {
return {
title: template.template_name || '', // 필드명 역변환
type: template.section_type
? SECTION_TYPE_REVERSE_MAP[template.section_type] || 'fields'
: 'fields',
description: template.description || undefined,
is_default: false, // 기본값
};
};
/**
* ItemMasterField → MasterFieldRequest 변환
* 주요 변환: field_type 값 역변환, default_validation/properties 필드명 변환
*/
export const transformMasterFieldToRequest = (
field: Partial<ItemMasterField>
) => {
return {
field_name: field.field_name || '',
field_type: field.field_type
? FIELD_TYPE_REVERSE_MAP[field.field_type] || 'textbox'
: 'textbox',
category: field.category || undefined,
description: field.description || undefined,
is_common: false, // 기본값
default_value: undefined,
options: undefined,
validation_rules: field.default_validation || undefined, // 필드명 역변환
properties: field.default_properties || undefined, // 필드명 역변환
};
};
// ============================================
// 배치 변환 헬퍼
// ============================================
/**
* 여러 페이지 응답을 한번에 변환
*/
export const transformPagesResponse = (
responses: ItemPageResponse[]
): ItemPage[] => {
return responses.map(transformPageResponse);
};
/**
* 여러 섹션 응답을 한번에 변환
*/
export const transformSectionsResponse = (
responses: ItemSectionResponse[]
): ItemSection[] => {
return responses.map(transformSectionResponse);
};
/**
* 여러 필드 응답을 한번에 변환
*/
export const transformFieldsResponse = (
responses: ItemFieldResponse[]
): ItemField[] => {
return responses.map(transformFieldResponse);
};
/**
* 여러 BOM 아이템 응답을 한번에 변환
*/
export const transformBomItemsResponse = (
responses: BomItemResponse[]
): BOMItem[] => {
return responses.map(transformBomItemResponse);
};
/**
* 여러 섹션 템플릿 응답을 한번에 변환
*/
export const transformSectionTemplatesResponse = (
responses: SectionTemplateResponse[]
): SectionTemplate[] => {
return responses.map(transformSectionTemplateResponse);
};
/**
* 여러 마스터 필드 응답을 한번에 변환
*/
export const transformMasterFieldsResponse = (
responses: MasterFieldResponse[]
): ItemMasterField[] => {
return responses.map(transformMasterFieldResponse);
};
/**
* UnitOptionResponse → MasterOption 변환 (Frontend의 MasterOption 타입에 맞춤)
*/
export const transformUnitOptionResponse = (
response: UnitOptionResponse
): { id: string; value: string; label: string; isActive: boolean } => {
return {
id: response.id.toString(), // number → string 변환
value: response.value,
label: response.label,
isActive: true, // API에 없으므로 기본값
};
};
/**
* CustomTabResponse → Frontend customTabs 타입 변환
*/
export const transformCustomTabResponse = (
response: CustomTabResponse
): { id: string; label: string; icon: string; isDefault: boolean; order: number } => {
return {
id: response.id.toString(), // number → string 변환
label: response.label,
icon: response.icon || 'FileText', // null이면 기본 아이콘
isDefault: response.is_default,
order: response.order_no,
};
};
/**
* 여러 단위 옵션 응답을 한번에 변환
*/
export const transformUnitOptionsResponse = (
responses: UnitOptionResponse[]
) => {
return responses.map(transformUnitOptionResponse);
};
/**
* 여러 커스텀 탭 응답을 한번에 변환
*/
export const transformCustomTabsResponse = (
responses: CustomTabResponse[]
) => {
return responses.map(transformCustomTabResponse);
};

265
src/lib/cache/TenantAwareCache.ts vendored Normal file
View File

@@ -0,0 +1,265 @@
/**
* TenantAwareCache - 테넌트별 데이터 격리 캐시 유틸리티
*
* 기능:
* - tenant.id 기반 캐시 키 생성 (예: 'mes-282-itemMasters')
* - TTL (Time To Live) 만료 처리
* - tenant.id 자동 검증
* - 손상된 캐시 자동 제거
* - localStorage 및 sessionStorage 지원
*/
interface CachedData<T> {
tenantId: number; // 테넌트 ID (number)
data: T; // 실제 데이터
timestamp: number; // 저장 시간 (ms)
version?: string; // 버전 정보 (선택)
}
export class TenantAwareCache {
private tenantId: number; // 테넌트 ID
private storage: Storage; // localStorage | sessionStorage
private ttl: number; // Time to Live (ms)
/**
* TenantAwareCache 생성자
*
* @param tenantId - 테넌트 ID (user.tenant.id)
* @param storage - 사용할 스토리지 (기본: sessionStorage)
* @param ttl - 캐시 만료 시간 (기본: 1시간)
*
* @example
* const cache = new TenantAwareCache(282, sessionStorage, 3600000);
* cache.set('itemMasters', data);
*/
constructor(
tenantId: number,
storage: Storage = sessionStorage,
ttl: number = 3600000 // 1시간 기본값
) {
this.tenantId = tenantId;
this.storage = storage;
this.ttl = ttl;
}
/**
* 테넌트별 고유 키 생성
*
* @param key - 기본 키 이름
* @returns tenant.id가 포함된 고유 키
*
* @example
* getKey('itemMasters') → 'mes-282-itemMasters'
*/
private getKey(key: string): string {
return `mes-${this.tenantId}-${key}`;
}
/**
* 캐시에 데이터 저장
*
* @param key - 캐시 키
* @param data - 저장할 데이터
* @param version - 버전 정보 (선택)
*
* @example
* cache.set('itemMasters', [item1, item2], '1.0');
*/
set<T>(key: string, data: T, version?: string): void {
const cacheData: CachedData<T> = {
tenantId: this.tenantId,
data,
timestamp: Date.now(),
version
};
this.storage.setItem(this.getKey(key), JSON.stringify(cacheData));
}
/**
* 캐시에서 데이터 조회 (tenantId 및 TTL 검증 포함)
*
* @param key - 캐시 키
* @returns 캐시된 데이터 또는 null
*
* @example
* const data = cache.get<ItemMaster[]>('itemMasters');
* if (data) {
* console.log('캐시 히트:', data);
* } else {
* console.log('캐시 미스 - API 호출 필요');
* }
*/
get<T>(key: string): T | null {
const cached = this.storage.getItem(this.getKey(key));
if (!cached) return null;
try {
const parsed: CachedData<T> = JSON.parse(cached);
// 🛡️ 1. tenantId 검증
if (parsed.tenantId !== this.tenantId) {
console.warn(
`[Cache] tenantId mismatch for key "${key}": ` +
`${parsed.tenantId} !== ${this.tenantId}`
);
this.remove(key);
return null;
}
// 🛡️ 2. TTL 검증 (만료 시간)
if (Date.now() - parsed.timestamp > this.ttl) {
console.warn(`[Cache] Expired cache for key: ${key}`);
this.remove(key);
return null;
}
return parsed.data;
} catch (error) {
console.error(`[Cache] Parse error for key: ${key}`, error);
this.remove(key);
return null;
}
}
/**
* 캐시에서 특정 키 삭제
*
* @param key - 삭제할 캐시 키
*
* @example
* cache.remove('itemMasters');
*/
remove(key: string): void {
this.storage.removeItem(this.getKey(key));
}
/**
* 현재 테넌트의 모든 캐시 삭제
*
* @example
* cache.clear(); // 'mes-282-*' 모두 삭제
*/
clear(): void {
const prefix = `mes-${this.tenantId}-`;
Object.keys(this.storage).forEach(key => {
if (key.startsWith(prefix)) {
this.storage.removeItem(key);
}
});
}
/**
* 버전 일치 여부 확인
*
* @param key - 캐시 키
* @param expectedVersion - 기대하는 버전
* @returns 버전 일치 여부
*
* @example
* if (!cache.isVersionMatch('itemMasters', '1.0')) {
* // 버전 불일치 - 재조회 필요
* const newData = await fetchFromAPI();
* cache.set('itemMasters', newData, '1.0');
* }
*/
isVersionMatch(key: string, expectedVersion: string): boolean {
const cached = this.storage.getItem(this.getKey(key));
if (!cached) return false;
try {
const parsed: CachedData<any> = JSON.parse(cached);
return parsed.version === expectedVersion;
} catch {
return false;
}
}
/**
* 캐시 메타데이터 조회
*
* @param key - 캐시 키
* @returns 메타데이터 또는 null
*
* @example
* const meta = cache.getMetadata('itemMasters');
* if (meta) {
* console.log('저장 시간:', new Date(meta.timestamp));
* console.log('버전:', meta.version);
* }
*/
getMetadata(key: string): { tenantId: number; timestamp: number; version?: string } | null {
const cached = this.storage.getItem(this.getKey(key));
if (!cached) return null;
try {
const parsed: CachedData<any> = JSON.parse(cached);
return {
tenantId: parsed.tenantId,
timestamp: parsed.timestamp,
version: parsed.version
};
} catch {
return null;
}
}
/**
* 캐시 존재 여부 확인
*
* @param key - 캐시 키
* @returns 캐시 존재 여부
*
* @example
* if (cache.has('itemMasters')) {
* const data = cache.get('itemMasters');
* }
*/
has(key: string): boolean {
return this.storage.getItem(this.getKey(key)) !== null;
}
/**
* 현재 테넌트 ID 반환
*
* @returns 테넌트 ID
*
* @example
* console.log('현재 테넌트:', cache.getTenantId()); // 282
*/
getTenantId(): number {
return this.tenantId;
}
/**
* 캐시 통계 정보 조회
*
* @returns 캐시 통계
*
* @example
* const stats = cache.getStats();
* console.log(`캐시 ${stats.count}개, 총 ${stats.totalSize} bytes`);
*/
getStats(): { count: number; totalSize: number; keys: string[] } {
const prefix = `mes-${this.tenantId}-`;
const keys: string[] = [];
let totalSize = 0;
Object.keys(this.storage).forEach(key => {
if (key.startsWith(prefix)) {
keys.push(key);
const value = this.storage.getItem(key);
if (value) {
totalSize += value.length;
}
}
});
return {
count: keys.length,
totalSize,
keys
};
}
}

8
src/lib/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/**
* 캐시 유틸리티 모듈
*
* @example
* import { TenantAwareCache } from '@/lib/cache';
*/
export { TenantAwareCache } from './TenantAwareCache';

View File

@@ -0,0 +1,412 @@
// 품목기준관리 API 타입 정의
// API 응답 기준 snake_case 사용
// ============================================
// 공통 타입
// ============================================
/**
* 표준 API 응답 래퍼
*/
export interface ApiResponse<T> {
success: boolean;
message: string;
data: T;
}
/**
* 페이지네이션 메타데이터
*/
export interface PaginationMeta {
current_page: number;
per_page: number;
total: number;
last_page: number;
}
// ============================================
// 초기화 API
// ============================================
/**
* 초기화 API 응답 - 화면 진입 시 전체 데이터 로드
* GET /v1/item-master/init
*/
export interface InitResponse {
pages: ItemPageResponse[];
sectionTemplates: SectionTemplateResponse[];
masterFields: MasterFieldResponse[];
customTabs: CustomTabResponse[];
tabColumns: Record<number, TabColumnResponse[]>; // tab_id를 key로 사용
unitOptions: UnitOptionResponse[];
}
// ============================================
// 페이지 관리
// ============================================
/**
* 페이지 생성/수정 요청
* POST /v1/item-master/pages
* PUT /v1/item-master/pages/{id}
*/
export interface ItemPageRequest {
page_name: string;
item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
absolute_path?: string;
is_active?: boolean;
}
/**
* 페이지 응답
*/
export interface ItemPageResponse {
id: number;
tenant_id: number;
page_name: string;
item_type: string;
absolute_path: string | null;
is_active: boolean;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
sections?: ItemSectionResponse[]; // Nested 조회 시 포함
}
/**
* 페이지 순서 변경 요청
* PUT /v1/item-master/pages/reorder (향후 구현 가능성)
*/
export interface PageReorderRequest {
page_orders: Array<{
id: number;
order_no: number;
}>;
}
// ============================================
// 섹션 관리
// ============================================
/**
* 섹션 생성/수정 요청
* POST /v1/item-master/pages/{pageId}/sections
* PUT /v1/item-master/sections/{id}
*/
export interface ItemSectionRequest {
title: string;
type: 'fields' | 'bom';
}
/**
* 섹션 응답
*/
export interface ItemSectionResponse {
id: number;
tenant_id: number;
page_id: number;
title: string;
type: 'fields' | 'bom';
order_no: number;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
fields?: ItemFieldResponse[]; // Nested 조회 시 포함
bomItems?: BomItemResponse[]; // Nested 조회 시 포함
}
/**
* 섹션 순서 변경 요청
* PUT /v1/item-master/pages/{pageId}/sections/reorder
*/
export interface SectionReorderRequest {
section_orders: Array<{
id: number;
order_no: number;
}>;
}
// ============================================
// 필드 관리
// ============================================
/**
* 필드 생성/수정 요청
* POST /v1/item-master/sections/{sectionId}/fields
* PUT /v1/item-master/fields/{id}
*/
export interface ItemFieldRequest {
field_name: string;
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
is_required?: boolean;
placeholder?: string;
default_value?: string;
display_condition?: Record<string, any>; // {"field_id": "1", "operator": "equals", "value": "true"}
validation_rules?: Record<string, any>; // {"min": 0, "max": 100, "pattern": "regex"}
options?: Array<{ label: string; value: string }>; // dropdown 옵션
properties?: Record<string, any>; // {"unit": "mm", "precision": 2, "format": "YYYY-MM-DD"}
}
/**
* 필드 응답
*/
export interface ItemFieldResponse {
id: number;
tenant_id: number;
section_id: number;
field_name: string;
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
order_no: number;
is_required: boolean;
placeholder: string | null;
default_value: string | null;
display_condition: Record<string, any> | null;
validation_rules: Record<string, any> | null;
options: Array<{ label: string; value: string }> | null;
properties: Record<string, any> | null;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
/**
* 필드 순서 변경 요청
* PUT /v1/item-master/sections/{sectionId}/fields/reorder
*/
export interface FieldReorderRequest {
field_orders: Array<{
id: number;
order_no: number;
}>;
}
// ============================================
// BOM 관리
// ============================================
/**
* BOM 항목 생성/수정 요청
* POST /v1/item-master/sections/{sectionId}/bom-items
* PUT /v1/item-master/bom-items/{id}
*/
export interface BomItemRequest {
item_code?: string;
item_name: string;
quantity: number;
unit?: string;
unit_price?: number;
total_price?: number;
spec?: string;
note?: string;
}
/**
* BOM 항목 응답
*/
export interface BomItemResponse {
id: number;
tenant_id: number;
section_id: number;
item_code: string | null;
item_name: string;
quantity: number;
unit: string | null;
unit_price: number | null;
total_price: number | null;
spec: string | null;
note: string | null;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
// ============================================
// 섹션 템플릿
// ============================================
/**
* 섹션 템플릿 생성/수정 요청
* POST /v1/item-master/section-templates
* PUT /v1/item-master/section-templates/{id}
*/
export interface SectionTemplateRequest {
title: string;
type: 'fields' | 'bom';
description?: string;
is_default?: boolean;
}
/**
* 섹션 템플릿 응답
*/
export interface SectionTemplateResponse {
id: number;
tenant_id: number;
title: string;
type: 'fields' | 'bom';
description: string | null;
is_default: boolean;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
// ============================================
// 마스터 필드
// ============================================
/**
* 마스터 필드 생성/수정 요청
* POST /v1/item-master/master-fields
* PUT /v1/item-master/master-fields/{id}
*/
export interface MasterFieldRequest {
field_name: string;
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
category?: string;
description?: string;
is_common?: boolean;
default_value?: string;
options?: Array<{ label: string; value: string }>;
validation_rules?: Record<string, any>;
properties?: Record<string, any>;
}
/**
* 마스터 필드 응답
*/
export interface MasterFieldResponse {
id: number;
tenant_id: number;
field_name: string;
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
category: string | null;
description: string | null;
is_common: boolean;
default_value: string | null;
options: Array<{ label: string; value: string }> | null;
validation_rules: Record<string, any> | null;
properties: Record<string, any> | null;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
// ============================================
// 커스텀 탭
// ============================================
/**
* 커스텀 탭 생성/수정 요청
* POST /v1/item-master/custom-tabs
* PUT /v1/item-master/custom-tabs/{id}
*/
export interface CustomTabRequest {
label: string;
icon?: string;
is_default?: boolean;
}
/**
* 커스텀 탭 응답
*/
export interface CustomTabResponse {
id: number;
tenant_id: number;
label: string;
icon: string | null;
is_default: boolean;
order_no: number;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
/**
* 탭 순서 변경 요청
* PUT /v1/item-master/custom-tabs/reorder
*/
export interface TabReorderRequest {
tab_orders: Array<{
id: number;
order_no: number;
}>;
}
/**
* 탭 컬럼 설정 업데이트 요청
* PUT /v1/item-master/custom-tabs/{id}/columns
*/
export interface TabColumnUpdateRequest {
columns: Array<{
key: string;
label: string;
visible: boolean;
order: number;
}>;
}
/**
* 탭 컬럼 응답
*/
export interface TabColumnResponse {
key: string;
label: string;
visible: boolean;
order: number;
}
// ============================================
// 단위 옵션
// ============================================
/**
* 단위 옵션 생성 요청
* POST /v1/item-master/units
*/
export interface UnitOptionRequest {
label: string;
value: string;
}
/**
* 단위 옵션 응답
*/
export interface UnitOptionResponse {
id: number;
tenant_id: number;
label: string;
value: string;
created_by: number | null;
created_at: string;
updated_at: string;
}
// ============================================
// 에러 타입
// ============================================
/**
* API 에러 응답
*/
export interface ApiErrorResponse {
success: false;
message: string;
errors?: Record<string, string[]>; // Validation 에러
}
/**
* API 에러 클래스용 타입
*/
export interface ApiErrorData {
status: number;
message: string;
errors?: Record<string, string[]>;
}