[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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
74
src/app/api/tenants/[tenantId]/item-master-config/route.ts
Normal file
74
src/app/api/tenants/[tenantId]/item-master-config/route.ts
Normal 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),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DraggableSection } from './DraggableSection';
|
||||
export { DraggableField } from './DraggableField';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { CategoryTab } from './CategoryTab';
|
||||
export { MasterFieldTab } from './MasterFieldTab';
|
||||
export { HierarchyTab } from './HierarchyTab';
|
||||
export { SectionsTab } from './SectionsTab';
|
||||
34
src/components/items/ItemMasterDataManagement/types.ts
Normal file
34
src/components/items/ItemMasterDataManagement/types.ts
Normal 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>; // 칼럼별 값
|
||||
}
|
||||
@@ -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] || '기타';
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
38
src/components/ui/error-message.tsx
Normal file
38
src/components/ui/error-message.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
src/components/ui/loading-spinner.tsx
Normal file
29
src/components/ui/loading-spinner.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
267
src/contexts/AuthContext.tsx
Normal file
267
src/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
1921
src/contexts/ItemMasterContext.tsx
Normal file
1921
src/contexts/ItemMasterContext.tsx
Normal file
File diff suppressed because it is too large
Load Diff
51
src/contexts/RootProvider.tsx
Normal file
51
src/contexts/RootProvider.tsx
Normal 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';
|
||||
* // ... 등등
|
||||
*/
|
||||
48
src/lib/api/auth-headers.ts
Normal file
48
src/lib/api/auth-headers.ts
Normal 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;
|
||||
};
|
||||
85
src/lib/api/error-handler.ts
Normal file
85
src/lib/api/error-handler.ts
Normal 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
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
360
src/lib/api/logger.ts
Normal 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
449
src/lib/api/mock-data.ts
Normal 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
97
src/lib/api/php-proxy.ts
Normal 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
421
src/lib/api/transformers.ts
Normal 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
265
src/lib/cache/TenantAwareCache.ts
vendored
Normal 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
8
src/lib/cache/index.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 캐시 유틸리티 모듈
|
||||
*
|
||||
* @example
|
||||
* import { TenantAwareCache } from '@/lib/cache';
|
||||
*/
|
||||
|
||||
export { TenantAwareCache } from './TenantAwareCache';
|
||||
412
src/types/item-master-api.ts
Normal file
412
src/types/item-master-api.ts
Normal 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[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user