feat: API 프록시 추가 및 품목기준관리 기능 개선
- HttpOnly 쿠키 기반 API 프록시 라우트 추가 (/api/proxy/[...path]) - 품목기준관리 컴포넌트 개선 (섹션, 필드, 다이얼로그) - ItemMasterContext API 연동 강화 - mock-data 제거 및 실제 API 연동 - 문서 명명규칙 정리 ([TYPE-DATE] 형식) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ interface ConditionalDisplayUIProps {
|
||||
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
selectedPage: ItemPage | null;
|
||||
selectedSectionForField: ItemSection | null;
|
||||
editingFieldId: string | null;
|
||||
editingFieldId: number | null;
|
||||
|
||||
// Constants
|
||||
INPUT_TYPE_OPTIONS: Array<{value: string; label: string}>;
|
||||
@@ -92,8 +92,10 @@ export function ConditionalDisplayUI({
|
||||
};
|
||||
|
||||
// selectedSectionForField는 이미 ItemSection 객체이므로 바로 사용
|
||||
// 신규 ItemField 타입: id는 number
|
||||
const availableFields = selectedSectionForField?.fields?.filter(f => f.id !== editingFieldId) || [];
|
||||
const availableSections = selectedPage?.sections.filter(s => s.type !== 'bom') || [];
|
||||
// 신규 ItemSection 타입: section_type은 'BASIC' | 'BOM' | 'CUSTOM'
|
||||
const availableSections = selectedPage?.sections.filter(s => s.section_type !== 'BOM') || [];
|
||||
|
||||
return (
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
@@ -175,32 +177,35 @@ export function ConditionalDisplayUI({
|
||||
이 값일 때 표시할 항목들 ({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>
|
||||
))}
|
||||
{availableFields.map(field => {
|
||||
const fieldIdStr = String(field.id);
|
||||
return (
|
||||
<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(fieldIdStr) || false}
|
||||
onChange={(e) => {
|
||||
const newFields = [...newFieldConditionFields];
|
||||
if (e.target.checked) {
|
||||
newFields[conditionIndex].targetFieldIds = [
|
||||
...(newFields[conditionIndex].targetFieldIds || []),
|
||||
fieldIdStr
|
||||
];
|
||||
} else {
|
||||
newFields[conditionIndex].targetFieldIds =
|
||||
(newFields[conditionIndex].targetFieldIds || []).filter(id => id !== fieldIdStr);
|
||||
}
|
||||
setNewFieldConditionFields(newFields);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs flex-1">{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -278,29 +283,32 @@ export function ConditionalDisplayUI({
|
||||
이 값일 때 표시할 섹션들 ({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>
|
||||
))}
|
||||
{availableSections.map(section => {
|
||||
const sectionIdStr = String(section.id);
|
||||
return (
|
||||
<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(sectionIdStr) || false}
|
||||
onChange={(e) => {
|
||||
const newFields = [...newFieldConditionFields];
|
||||
if (e.target.checked) {
|
||||
newFields[conditionIndex].targetSectionIds = [
|
||||
...(newFields[conditionIndex].targetSectionIds || []),
|
||||
sectionIdStr
|
||||
];
|
||||
} else {
|
||||
newFields[conditionIndex].targetSectionIds =
|
||||
(newFields[conditionIndex].targetSectionIds || []).filter(id => id !== sectionIdStr);
|
||||
}
|
||||
setNewFieldConditionFields(newFields);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs flex-1">{section.section_name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,29 +71,29 @@ export function DraggableField({ field, index, moveField, onDelete, onEdit }: Dr
|
||||
<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>
|
||||
<span className="text-sm">{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.property.inputType)?.label}
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
{field.property.required && (
|
||||
{field.is_required && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
{field.displayCondition && (
|
||||
{field.display_condition && (
|
||||
<Badge variant="secondary" className="text-xs">조건부</Badge>
|
||||
)}
|
||||
{field.order !== undefined && (
|
||||
<Badge variant="outline" className="text-xs">순서: {field.order + 1}</Badge>
|
||||
{field.order_no !== undefined && (
|
||||
<Badge variant="outline" className="text-xs">순서: {field.order_no + 1}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-6 text-xs text-gray-500 mt-1">
|
||||
필드키: {field.fieldKey}
|
||||
{field.displayCondition && (
|
||||
필드ID: {field.id}
|
||||
{field.display_condition && (
|
||||
<span className="ml-2">
|
||||
(조건: {field.displayCondition.fieldKey} = {field.displayCondition.expectedValue})
|
||||
(조건부 표시 설정됨)
|
||||
</span>
|
||||
)}
|
||||
{field.description && (
|
||||
<span className="ml-2">• {field.description}</span>
|
||||
{field.placeholder && (
|
||||
<span className="ml-2">• {field.placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,11 @@ interface DraggableSectionProps {
|
||||
index: number;
|
||||
moveSection: (dragIndex: number, hoverIndex: number) => void;
|
||||
onDelete: () => void;
|
||||
onEditTitle: (id: string, title: string) => void;
|
||||
editingSectionId: string | null;
|
||||
onEditTitle: (id: number, title: string) => void;
|
||||
editingSectionId: number | null;
|
||||
editingSectionTitle: string;
|
||||
setEditingSectionTitle: (title: string) => void;
|
||||
setEditingSectionId: (id: string | null) => void;
|
||||
setEditingSectionId: (id: number | null) => void;
|
||||
handleSaveSectionTitle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -106,9 +106,9 @@ export function DraggableSection({
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer group min-w-0"
|
||||
onClick={() => onEditTitle(section.id, section.title)}
|
||||
onClick={() => onEditTitle(section.id, section.section_name)}
|
||||
>
|
||||
<span className="text-blue-900 truncate text-sm sm:text-base">{section.title}</span>
|
||||
<span className="text-blue-900 truncate text-sm sm:text-base">{section.section_name}</span>
|
||||
<Edit className="h-3 w-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
@@ -118,8 +118,9 @@ export function DraggableSection({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
title="페이지에서 연결 해제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,9 @@ import { toast } from 'sonner';
|
||||
import type { ItemPage, ItemSection, ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import { ConditionalDisplayUI, type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
|
||||
|
||||
// 입력 타입 정의
|
||||
export type InputType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
|
||||
// 텍스트박스 칼럼 타입 (단순 구조)
|
||||
interface OptionColumn {
|
||||
id: string;
|
||||
@@ -20,7 +23,7 @@ interface OptionColumn {
|
||||
key: string;
|
||||
}
|
||||
|
||||
const INPUT_TYPE_OPTIONS = [
|
||||
const INPUT_TYPE_OPTIONS: Array<{ value: InputType; label: string }> = [
|
||||
{ value: 'textbox', label: '텍스트박스' },
|
||||
{ value: 'dropdown', label: '드롭다운' },
|
||||
{ value: 'checkbox', label: '체크박스' },
|
||||
@@ -56,8 +59,8 @@ interface FieldDialogProps {
|
||||
setNewFieldName: (name: string) => void;
|
||||
newFieldKey: string;
|
||||
setNewFieldKey: (key: string) => void;
|
||||
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section';
|
||||
setNewFieldInputType: (type: any) => void;
|
||||
newFieldInputType: InputType;
|
||||
setNewFieldInputType: (type: InputType) => void;
|
||||
newFieldRequired: boolean;
|
||||
setNewFieldRequired: (required: boolean) => void;
|
||||
newFieldDescription: string;
|
||||
@@ -198,21 +201,22 @@ export function FieldDialog({
|
||||
<div
|
||||
key={field.id}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedMasterFieldId === field.id
|
||||
selectedMasterFieldId === String(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);
|
||||
setSelectedMasterFieldId(String(field.id));
|
||||
setNewFieldName(field.field_name);
|
||||
setNewFieldKey(field.id.toString());
|
||||
setNewFieldInputType(field.field_type);
|
||||
setNewFieldRequired(field.properties?.required || false);
|
||||
setNewFieldDescription(field.description || '');
|
||||
setNewFieldOptions(field.property.options?.join(', ') || '');
|
||||
if (field.property.multiColumn && field.property.columnNames) {
|
||||
// options는 {label, value}[] 배열이므로 label만 추출
|
||||
setNewFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
|
||||
if (field.properties?.multiColumn && field.properties?.columnNames) {
|
||||
setTextboxColumns(
|
||||
field.property.columnNames.map((name, idx) => ({
|
||||
field.properties.columnNames.map((name: string, idx: number) => ({
|
||||
id: `col-${idx}`,
|
||||
name,
|
||||
key: `column${idx + 1}`
|
||||
@@ -224,28 +228,26 @@ export function FieldDialog({
|
||||
<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>
|
||||
<span className="font-medium">{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
{field.property.required && (
|
||||
{field.properties?.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 && (
|
||||
{field.category && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{field.category.map((cat, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{cat}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{field.category}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedMasterFieldId === field.id && (
|
||||
{selectedMasterFieldId === String(field.id) && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
@@ -280,7 +282,7 @@ export function FieldDialog({
|
||||
|
||||
<div>
|
||||
<Label>입력방식 *</Label>
|
||||
<Select value={newFieldInputType} onValueChange={(v: any) => setNewFieldInputType(v)}>
|
||||
<Select value={newFieldInputType} onValueChange={(v) => setNewFieldInputType(v as InputType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -7,10 +7,11 @@ 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: '조립품' },
|
||||
{ value: 'FG', label: '제품 (FG)' },
|
||||
{ value: 'PT', label: '부품 (PT)' },
|
||||
{ value: 'SM', label: '반제품 (SM)' },
|
||||
{ value: 'RM', label: '원자재 (RM)' },
|
||||
{ value: 'CS', label: '소모품 (CS)' },
|
||||
];
|
||||
|
||||
interface PageDialogProps {
|
||||
|
||||
@@ -5,6 +5,9 @@ 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 { Badge } from '@/components/ui/badge';
|
||||
import { FileText, Package, Check, X } from 'lucide-react';
|
||||
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
|
||||
|
||||
interface SectionDialogProps {
|
||||
isSectionDialogOpen: boolean;
|
||||
@@ -16,6 +19,13 @@ interface SectionDialogProps {
|
||||
newSectionDescription: string;
|
||||
setNewSectionDescription: (description: string) => void;
|
||||
handleAddSection: () => void;
|
||||
// 템플릿 선택 관련 props
|
||||
sectionInputMode: 'custom' | 'template';
|
||||
setSectionInputMode: (mode: 'custom' | 'template') => void;
|
||||
sectionTemplates: SectionTemplate[];
|
||||
selectedTemplateId: number | null;
|
||||
setSelectedTemplateId: (id: number | null) => void;
|
||||
handleLinkTemplate: (template: SectionTemplate) => void;
|
||||
}
|
||||
|
||||
export function SectionDialog({
|
||||
@@ -28,59 +38,246 @@ export function SectionDialog({
|
||||
newSectionDescription,
|
||||
setNewSectionDescription,
|
||||
handleAddSection,
|
||||
sectionInputMode,
|
||||
setSectionInputMode,
|
||||
sectionTemplates,
|
||||
selectedTemplateId,
|
||||
setSelectedTemplateId,
|
||||
handleLinkTemplate,
|
||||
}: SectionDialogProps) {
|
||||
const handleClose = () => {
|
||||
setIsSectionDialogOpen(false);
|
||||
setNewSectionType('fields');
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
setSectionInputMode('custom');
|
||||
setSelectedTemplateId(null);
|
||||
};
|
||||
|
||||
// 템플릿 선택 시 폼에 값 채우기
|
||||
const handleSelectTemplate = (template: SectionTemplate) => {
|
||||
setSelectedTemplateId(template.id);
|
||||
setNewSectionTitle(template.template_name);
|
||||
setNewSectionDescription(template.description || '');
|
||||
setNewSectionType(template.section_type === 'BOM' ? 'bom' : 'fields');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isSectionDialogOpen} onOpenChange={(open) => {
|
||||
setIsSectionDialogOpen(open);
|
||||
if (!open) {
|
||||
setNewSectionType('fields');
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
}
|
||||
if (!open) handleClose();
|
||||
else setIsSectionDialogOpen(open);
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{newSectionType === 'bom' ? 'BOM 섹션' : '일반 섹션'} 추가</DialogTitle>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
|
||||
<DialogTitle>섹션 추가</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 className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* 입력 모드 선택 */}
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={sectionInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSectionInputMode('custom');
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
직접 입력
|
||||
</Button>
|
||||
<Button
|
||||
variant={sectionInputMode === 'template' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSectionInputMode('template')}
|
||||
className="flex-1"
|
||||
>
|
||||
템플릿 선택
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
{sectionInputMode === 'template' && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[300px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>섹션 템플릿 목록</Label>
|
||||
</div>
|
||||
{sectionTemplates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 섹션 템플릿이 없습니다.<br/>
|
||||
섹션 탭에서 템플릿을 먼저 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sectionTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedTemplateId === template.id
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.section_type === 'BOM' ? (
|
||||
<Package className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-blue-500" />
|
||||
)}
|
||||
<span className="font-medium">{template.template_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{template.section_type === 'BOM' ? '모듈(BOM)' : '일반'}
|
||||
</Badge>
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{template.description}</p>
|
||||
)}
|
||||
{template.fields && template.fields.length > 0 && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{template.fields.length}개 항목 포함
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedTemplateId === template.id && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 직접 입력 폼 또는 선택된 템플릿 정보 표시 */}
|
||||
{(sectionInputMode === 'custom' || selectedTemplateId) && (
|
||||
<>
|
||||
{/* 섹션 유형 선택 - 템플릿 선택 시 비활성화 */}
|
||||
<div>
|
||||
<Label className="mb-3 block">섹션 유형 *</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 일반 섹션 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (sectionInputMode === 'custom') setNewSectionType('fields');
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
|
||||
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
newSectionType === 'fields'
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FileText className={`h-5 w-5 ${newSectionType === 'fields' ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${newSectionType === 'fields' ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
일반 섹션
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">필드 항목 관리</div>
|
||||
</div>
|
||||
{newSectionType === 'fields' && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
{/* BOM 섹션 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (sectionInputMode === 'custom') setNewSectionType('bom');
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
|
||||
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
newSectionType === 'bom'
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Package className={`h-5 w-5 ${newSectionType === 'bom' ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${newSectionType === 'bom' ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
모듈 섹션 (BOM)
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">자재명세서 관리</div>
|
||||
</div>
|
||||
{newSectionType === 'bom' && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>섹션 제목 *</Label>
|
||||
<Input
|
||||
value={newSectionTitle}
|
||||
onChange={(e) => setNewSectionTitle(e.target.value)}
|
||||
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
|
||||
disabled={sectionInputMode === 'template'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>설명 (선택)</Label>
|
||||
<Textarea
|
||||
value={newSectionDescription}
|
||||
onChange={(e) => setNewSectionDescription(e.target.value)}
|
||||
placeholder="섹션에 대한 설명"
|
||||
disabled={sectionInputMode === 'template'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sectionInputMode === 'template' && selectedTemplateId && (
|
||||
<div className="bg-green-50 p-3 rounded-md border border-green-200">
|
||||
<p className="text-sm text-green-700">
|
||||
<strong>템플릿 연결:</strong> 선택한 템플릿을 페이지에 연결합니다.
|
||||
템플릿에 포함된 항목들도 함께 추가됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newSectionType === 'bom' && sectionInputMode === 'custom' && (
|
||||
<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 className="shrink-0 bg-white z-10 px-6 py-4 border-t flex-col sm:flex-row gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="w-full sm:w-auto">
|
||||
취소
|
||||
</Button>
|
||||
{sectionInputMode === 'template' && selectedTemplateId ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const template = sectionTemplates.find(t => t.id === selectedTemplateId);
|
||||
if (template) handleLinkTemplate(template);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
템플릿 연결
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleAddSection}
|
||||
className="w-full sm:w-auto"
|
||||
disabled={sectionInputMode === 'template' && !selectedTemplateId}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ 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';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
|
||||
const INPUT_TYPE_OPTIONS = [
|
||||
{ value: 'textbox', label: '텍스트 입력' },
|
||||
@@ -41,6 +44,14 @@ interface TemplateFieldDialogProps {
|
||||
templateFieldColumnNames: string[];
|
||||
setTemplateFieldColumnNames: (names: string[]) => void;
|
||||
handleAddTemplateField: () => void;
|
||||
// 마스터 항목 관련 props
|
||||
itemMasterFields?: ItemMasterField[];
|
||||
templateFieldInputMode?: 'custom' | 'master';
|
||||
setTemplateFieldInputMode?: (mode: 'custom' | 'master') => void;
|
||||
showMasterFieldList?: boolean;
|
||||
setShowMasterFieldList?: (show: boolean) => void;
|
||||
selectedMasterFieldId?: string;
|
||||
setSelectedMasterFieldId?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TemplateFieldDialog({
|
||||
@@ -67,31 +78,151 @@ export function TemplateFieldDialog({
|
||||
templateFieldColumnNames,
|
||||
setTemplateFieldColumnNames,
|
||||
handleAddTemplateField,
|
||||
// 마스터 항목 관련 props (optional)
|
||||
itemMasterFields = [],
|
||||
templateFieldInputMode = 'custom',
|
||||
setTemplateFieldInputMode,
|
||||
showMasterFieldList = false,
|
||||
setShowMasterFieldList,
|
||||
selectedMasterFieldId = '',
|
||||
setSelectedMasterFieldId,
|
||||
}: TemplateFieldDialogProps) {
|
||||
const handleClose = () => {
|
||||
setIsTemplateFieldDialogOpen(false);
|
||||
setEditingTemplateFieldId(null);
|
||||
setTemplateFieldName('');
|
||||
setTemplateFieldKey('');
|
||||
setTemplateFieldInputType('textbox');
|
||||
setTemplateFieldRequired(false);
|
||||
setTemplateFieldOptions('');
|
||||
setTemplateFieldDescription('');
|
||||
setTemplateFieldMultiColumn(false);
|
||||
setTemplateFieldColumnCount(2);
|
||||
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
|
||||
// 마스터 항목 관련 상태 초기화
|
||||
setTemplateFieldInputMode?.('custom');
|
||||
setShowMasterFieldList?.(false);
|
||||
setSelectedMasterFieldId?.('');
|
||||
};
|
||||
|
||||
const handleSelectMasterField = (field: ItemMasterField) => {
|
||||
setSelectedMasterFieldId?.(String(field.id));
|
||||
setTemplateFieldName(field.field_name);
|
||||
setTemplateFieldKey(field.id.toString());
|
||||
setTemplateFieldInputType(field.field_type);
|
||||
setTemplateFieldRequired(field.properties?.required || false);
|
||||
setTemplateFieldDescription(field.description || '');
|
||||
// options는 {label, value}[] 배열이므로 label만 추출
|
||||
setTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
|
||||
if (field.properties?.multiColumn && field.properties?.columnNames) {
|
||||
setTemplateFieldMultiColumn(true);
|
||||
setTemplateFieldColumnCount(field.properties.columnNames.length);
|
||||
setTemplateFieldColumnNames(field.properties.columnNames);
|
||||
} else {
|
||||
setTemplateFieldMultiColumn(false);
|
||||
}
|
||||
};
|
||||
|
||||
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']);
|
||||
}
|
||||
}}>
|
||||
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={handleClose}>
|
||||
<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">
|
||||
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
|
||||
{!editingTemplateFieldId && setTemplateFieldInputMode && (
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={templateFieldInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setTemplateFieldInputMode('custom')}
|
||||
className="flex-1"
|
||||
>
|
||||
직접 입력
|
||||
</Button>
|
||||
<Button
|
||||
variant={templateFieldInputMode === 'master' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTemplateFieldInputMode('master');
|
||||
setShowMasterFieldList?.(true);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
마스터 항목 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마스터 항목 목록 */}
|
||||
{templateFieldInputMode === 'master' && !editingTemplateFieldId && 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 === String(field.id)
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectMasterField(field)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{field.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
{field.properties?.required && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
|
||||
)}
|
||||
{field.category && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{field.category}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedMasterFieldId === String(field.id) && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 직접 입력 폼 */}
|
||||
{(templateFieldInputMode === 'custom' || editingTemplateFieldId || !setTemplateFieldInputMode) && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>항목명 *</Label>
|
||||
@@ -199,6 +330,8 @@ export function TemplateFieldDialog({
|
||||
<Switch checked={templateFieldRequired} onCheckedChange={setTemplateFieldRequired} />
|
||||
<Label>필수 항목</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsTemplateFieldDialogOpen(false)}>취소</Button>
|
||||
|
||||
@@ -304,6 +304,7 @@ export function HierarchyTab({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// 다이얼로그에서 타입 선택하도록 기본값만 설정
|
||||
setNewSectionType('fields');
|
||||
setIsSectionDialogOpen(true);
|
||||
}}
|
||||
@@ -332,9 +333,9 @@ export function HierarchyTab({
|
||||
moveSection(dragIndex, hoverIndex);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (confirm('이 섹션과 모든 항목을 삭제하시겠습니까?')) {
|
||||
if (confirm('이 섹션을 페이지에서 연결 해제하시겠습니까?\n(섹션 데이터는 섹션 탭에 유지됩니다)')) {
|
||||
deleteSection(selectedPage.id, section.id);
|
||||
toast.success('섹션이 삭제되었습니다');
|
||||
toast.success('섹션 연결이 해제되었습니다');
|
||||
}
|
||||
}}
|
||||
onEditTitle={handleEditSectionTitle}
|
||||
|
||||
@@ -78,16 +78,18 @@ export function MasterFieldTab({
|
||||
<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}
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.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' && (
|
||||
{field.category && (
|
||||
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
|
||||
)}
|
||||
{field.properties?.attributeType && field.properties.attributeType !== 'custom' && (
|
||||
<Badge variant="default" className="text-xs bg-blue-500">
|
||||
{(field.properties as any).attributeType === 'unit' ? '단위 연동' :
|
||||
(field.properties as any).attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
|
||||
{field.properties.attributeType === 'unit' ? '단위 연동' :
|
||||
field.properties.attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -97,10 +99,10 @@ export function MasterFieldTab({
|
||||
<span className="ml-2">• {field.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{field.properties?.options && field.properties.options.length > 0 && (
|
||||
{field.options && field.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' && (
|
||||
옵션: {field.options.map(opt => opt.label).join(', ')}
|
||||
{field.properties?.attributeType && field.properties.attributeType !== 'custom' && (
|
||||
<span className="ml-2 text-blue-600">
|
||||
(속성 탭 자동 동기화)
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 type { SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
|
||||
import { BOMManagementSection } from '../../BOMManagementSection';
|
||||
|
||||
interface SectionsTabProps {
|
||||
@@ -22,11 +22,11 @@ interface SectionsTabProps {
|
||||
handleDeleteSectionTemplate: (id: number) => void;
|
||||
|
||||
// 템플릿 필드 핸들러
|
||||
handleEditTemplateField: (templateId: number, field: any) => void;
|
||||
handleEditTemplateField: (templateId: number, field: TemplateField) => void;
|
||||
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
|
||||
|
||||
// BOM 핸들러
|
||||
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'createdAt'>) => void;
|
||||
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
|
||||
handleUpdateBOMItemInTemplate: (templateId: number, itemId: number, item: Partial<BOMItem>) => void;
|
||||
handleDeleteBOMItemFromTemplate: (templateId: number, itemId: number) => void;
|
||||
|
||||
@@ -169,14 +169,14 @@ export function SectionsTab({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{template.fields.length === 0 ? (
|
||||
{(!template.fields || 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">
|
||||
품목의 목록명, 수량, 입력방법 고객화된 표시할 수 있습니다
|
||||
|
||||
Reference in New Issue
Block a user