fix: 품목기준관리 실시간 동기화 수정

- BOM 항목 추가/수정/삭제 시 섹션탭 즉시 반영
- 섹션 복제 시 UI 즉시 업데이트 (null vs undefined 이슈 해결)
- 항목 수정 기능 추가 (useTemplateManagement)
- 실시간 동기화 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-27 22:19:50 +09:00
parent b73603822b
commit 65a8510c0b
130 changed files with 11031 additions and 2287 deletions

View File

@@ -305,7 +305,7 @@ export function ConditionalDisplayUI({
}}
className="cursor-pointer"
/>
<span className="text-xs flex-1">{section.section_name}</span>
<span className="text-xs flex-1">{section.title}</span>
</label>
);
})}

View File

@@ -75,7 +75,7 @@ export function DraggableField({ field, index, moveField, onDelete, onEdit }: Dr
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label || field.field_type}
</Badge>
{field.is_required && (
{(field.is_required || field.properties?.required) && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
{field.display_condition && (

View File

@@ -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.section_name)}
onClick={() => onEditTitle(section.id, section.title)}
>
<span className="text-blue-900 truncate text-sm sm:text-base">{section.section_name}</span>
<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>
)}

View File

@@ -157,7 +157,7 @@ export function FieldDialog({
<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">
@@ -181,16 +181,16 @@ export function FieldDialog({
}}
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>
<Label> </Label>
<Button
variant="ghost"
size="sm"
@@ -201,7 +201,7 @@ export function FieldDialog({
</div>
{itemMasterFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
<div className="space-y-2">
@@ -418,10 +418,10 @@ export function FieldDialog({
</div>
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t">
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={() => {
<Button onClick={async () => {
setIsSubmitted(true);
if ((fieldInputMode === 'custom' || editingFieldId) && (isNameEmpty || isKeyEmpty)) return;
handleAddField();
await handleAddField();
setIsSubmitted(false);
}}></Button>
</DialogFooter>

View File

@@ -65,7 +65,7 @@ interface FieldDrawerProps {
selectedSectionForField: ItemSection | null;
selectedPage: ItemPage | null;
itemMasterFields: ItemMasterField[];
handleAddField: () => void;
handleAddField: () => Promise<void>;
setIsColumnDialogOpen: (open: boolean) => void;
setEditingColumnId: (id: string | null) => void;
setColumnName: (name: string) => void;
@@ -136,7 +136,7 @@ export function FieldDrawer({
<DrawerHeader className="px-4 py-3 border-b">
<DrawerTitle>{editingFieldId ? '항목 수정' : '항목 추가'}</DrawerTitle>
<DrawerDescription>
</DrawerDescription>
</DrawerHeader>
@@ -161,16 +161,16 @@ export function FieldDrawer({
}}
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>
<Label> </Label>
<Button
variant="ghost"
size="sm"
@@ -181,7 +181,7 @@ export function FieldDrawer({
</div>
{itemMasterFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
<div className="space-y-2">
@@ -600,7 +600,7 @@ export function FieldDrawer({
}}
className="cursor-pointer"
/>
<span className="flex-1 text-sm">{section.section_name}</span>
<span className="flex-1 text-sm">{section.title}</span>
</label>
))}
</div>
@@ -620,7 +620,7 @@ export function FieldDrawer({
<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>
<Button onClick={async () => await handleAddField()} className="flex-1"></Button>
</DrawerFooter>
</DrawerContent>
</Drawer>

View File

@@ -0,0 +1,279 @@
'use client';
/**
* 필드 불러오기 다이얼로그
*
* @description 2025-11-27: API 변경으로 "항목/독립필드" 탭 통합
* - item_master_fields 테이블 삭제됨
* - 모든 필드가 item_fields로 통합 (section_id=NULL이 독립 필드)
* - 이전: itemMasterFields + independentFields 분리
* - 현재: fields 단일 목록으로 통합
*/
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { FormInput, Search, Info, Loader2, Hash, Calendar, CheckSquare, ChevronDown, Type, AlignLeft, Database } from 'lucide-react';
import type { ItemField, ItemMasterField } from '@/contexts/ItemMasterContext';
import type { FieldUsageResponse } from '@/types/item-master-api';
interface ImportFieldDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
/** 필드 목록 (item_fields WHERE section_id IS NULL) - ItemField 또는 ItemMasterField 모두 허용 */
fields: (ItemField | ItemMasterField)[];
/** @deprecated 2025-11-27: fields로 통합됨. 하위 호환성을 위해 유지 */
independentFields?: ItemField[];
/** @deprecated 2025-11-27: fields로 통합됨. 하위 호환성을 위해 유지 */
itemMasterFields?: ItemMasterField[];
selectedFieldId: number | null;
setSelectedFieldId: (id: number | null) => void;
onImport: () => void;
onRefresh: () => Promise<void>;
onGetUsage?: (fieldId: number) => Promise<FieldUsageResponse>;
isLoading?: boolean;
targetSectionTitle?: string;
}
// Field type icon mapping
const FIELD_TYPE_ICONS: Record<string, React.ReactNode> = {
textbox: <Type className="w-4 h-4" />,
number: <Hash className="w-4 h-4" />,
dropdown: <ChevronDown className="w-4 h-4" />,
checkbox: <CheckSquare className="w-4 h-4" />,
date: <Calendar className="w-4 h-4" />,
textarea: <AlignLeft className="w-4 h-4" />,
};
const FIELD_TYPE_LABELS: Record<string, string> = {
textbox: '텍스트박스',
number: '숫자',
dropdown: '드롭다운',
checkbox: '체크박스',
date: '날짜',
textarea: '텍스트영역',
};
export function ImportFieldDialog({
isOpen,
setIsOpen,
fields = [],
independentFields,
itemMasterFields,
selectedFieldId,
setSelectedFieldId,
onImport,
onRefresh,
onGetUsage,
isLoading = false,
targetSectionTitle,
}: ImportFieldDialogProps) {
const [searchQuery, setSearchQuery] = useState('');
const [usageInfo, setUsageInfo] = useState<FieldUsageResponse | null>(null);
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
// 2025-11-27: 하위 호환성 - fields가 없으면 independentFields나 itemMasterFields 사용
const allFields = fields.length > 0
? fields
: (independentFields || itemMasterFields || []);
// Filter fields by search query
// Note: ItemField와 ItemMasterField 타입의 속성 차이를 처리하기 위해 타입 단언 사용
const filteredFields = allFields.filter(field => {
const f = field as ItemField & ItemMasterField; // 두 타입의 모든 속성 접근 허용
return (
f.field_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
f.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
f.category?.toLowerCase().includes(searchQuery.toLowerCase()) ||
f.placeholder?.toLowerCase().includes(searchQuery.toLowerCase())
);
});
// Load fields when dialog opens
useEffect(() => {
if (isOpen) {
onRefresh();
setSearchQuery('');
setUsageInfo(null);
}
}, [isOpen]);
// Load usage info when field is selected
useEffect(() => {
const loadUsage = async () => {
if (selectedFieldId && onGetUsage) {
setIsLoadingUsage(true);
try {
const usage = await onGetUsage(selectedFieldId);
setUsageInfo(usage);
} catch (error) {
console.error('Failed to load usage info:', error);
setUsageInfo(null);
} finally {
setIsLoadingUsage(false);
}
} else {
setUsageInfo(null);
}
};
loadUsage();
}, [selectedFieldId, onGetUsage]);
const handleClose = () => {
setIsOpen(false);
setSelectedFieldId(null);
setSearchQuery('');
setUsageInfo(null);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open) handleClose();
else setIsOpen(open);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{targetSectionTitle ? `"${targetSectionTitle}" 섹션` : '현재 섹션'} .
</DialogDescription>
</DialogHeader>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="필드 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
) : filteredFields.length === 0 ? (
<div className="text-center py-8">
<FormInput className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{searchQuery ? '검색 결과가 없습니다' : '등록된 필드가 없습니다'}
</p>
<p className="text-sm text-muted-foreground mt-2">
</p>
</div>
) : (
<div className="space-y-2 max-h-80 overflow-y-auto">
{/* 통합 필드 목록 - ItemField와 ItemMasterField 타입 호환을 위해 intersection 타입 사용 */}
{filteredFields.map((field) => {
// 두 타입의 모든 속성에 안전하게 접근하기 위한 타입 단언
const f = field as ItemField & ItemMasterField;
return (
<div
key={f.id}
onClick={() => setSelectedFieldId(f.id)}
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
selectedFieldId === f.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">
{FIELD_TYPE_ICONS[f.field_type] || <Database 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 flex-wrap">
<h4 className="font-medium">{f.field_name}</h4>
<Badge variant="secondary">
{FIELD_TYPE_LABELS[f.field_type] || f.field_type}
</Badge>
{f.category && (
<Badge variant="outline" className="text-xs">{f.category}</Badge>
)}
{(f.is_required || f.properties?.required) && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{(f.description || f.placeholder) && (
<p className="text-sm text-muted-foreground mb-2 truncate">
{f.description || f.placeholder}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{f.default_value && (
<span>: {f.default_value}</span>
)}
{f.options && f.options.length > 0 && (
<span>{f.options.length} </span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Usage Info Panel */}
{selectedFieldId && (
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border">
<div className="flex items-center gap-2 mb-2">
<Info className="h-4 w-4 text-blue-500" />
<Label className="font-medium"> </Label>
</div>
{isLoadingUsage ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span> ...</span>
</div>
) : usageInfo ? (
<div className="text-sm space-y-1">
<p> {usageInfo.total_usage_count} </p>
{usageInfo.linked_sections.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{usageInfo.linked_sections.slice(0, 5).map(section => (
<Badge key={section.id} variant="outline" className="text-xs">
{section.title}
</Badge>
))}
{usageInfo.linked_sections.length > 5 && (
<Badge variant="outline" className="text-xs">
+{usageInfo.linked_sections.length - 5}
</Badge>
)}
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
.
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}></Button>
<Button
onClick={() => {
onImport();
handleClose();
}}
disabled={!selectedFieldId || isLoading}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Package, Folder, Search, Info, Loader2 } from 'lucide-react';
import type { ItemSection } from '@/contexts/ItemMasterContext';
import type { SectionUsageResponse } from '@/types/item-master-api';
interface ImportSectionDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
independentSections: ItemSection[];
selectedSectionId: number | null;
setSelectedSectionId: (id: number | null) => void;
onImport: () => void;
onRefresh: () => Promise<void>;
onGetUsage?: (sectionId: number) => Promise<SectionUsageResponse>;
isLoading?: boolean;
}
export function ImportSectionDialog({
isOpen,
setIsOpen,
independentSections,
selectedSectionId,
setSelectedSectionId,
onImport,
onRefresh,
onGetUsage,
isLoading = false,
}: ImportSectionDialogProps) {
const [searchQuery, setSearchQuery] = useState('');
const [usageInfo, setUsageInfo] = useState<SectionUsageResponse | null>(null);
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
// Filter sections by search query
const filteredSections = independentSections.filter(section =>
section.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
section.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
// Load sections when dialog opens
useEffect(() => {
if (isOpen) {
onRefresh();
setSearchQuery('');
setUsageInfo(null);
}
}, [isOpen]);
// Load usage info when section is selected
useEffect(() => {
const loadUsage = async () => {
if (selectedSectionId && onGetUsage) {
setIsLoadingUsage(true);
try {
const usage = await onGetUsage(selectedSectionId);
setUsageInfo(usage);
} catch (error) {
console.error('Failed to load usage info:', error);
setUsageInfo(null);
} finally {
setIsLoadingUsage(false);
}
} else {
setUsageInfo(null);
}
};
loadUsage();
}, [selectedSectionId, onGetUsage]);
const handleClose = () => {
setIsOpen(false);
setSelectedSectionId(null);
setSearchQuery('');
setUsageInfo(null);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open) handleClose();
else setIsOpen(open);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="섹션 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
) : filteredSections.length === 0 ? (
<div className="text-center py-8">
<Folder className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
{searchQuery ? '검색 결과가 없습니다' : '사용 가능한 독립 섹션이 없습니다'}
</p>
<p className="text-sm text-muted-foreground mt-2">
</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{filteredSections.map((section) => (
<div
key={section.id}
onClick={() => setSelectedSectionId(section.id)}
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
selectedSectionId === section.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">
{section.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 flex-wrap">
<h4 className="font-medium">{section.title}</h4>
<Badge variant={section.section_type === 'BOM' ? 'default' : 'secondary'}>
{section.section_type}
</Badge>
{section.is_template && (
<Badge variant="outline" className="text-xs">릿</Badge>
)}
</div>
{section.description && (
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">{section.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{section.fields?.length || 0} </span>
{section.section_type === 'BOM' && (
<span>{section.bom_items?.length || 0} BOM </span>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Usage Info Panel */}
{selectedSectionId && (
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border">
<div className="flex items-center gap-2 mb-2">
<Info className="h-4 w-4 text-blue-500" />
<Label className="font-medium"> </Label>
</div>
{isLoadingUsage ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span> ...</span>
</div>
) : usageInfo ? (
<div className="text-sm space-y-1">
<p> {usageInfo.total_usage_count} </p>
{usageInfo.linked_pages.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{usageInfo.linked_pages.slice(0, 5).map(page => (
<Badge key={page.id} variant="outline" className="text-xs">
{page.page_name}
</Badge>
))}
{usageInfo.linked_pages.length > 5 && (
<Badge variant="outline" className="text-xs">
+{usageInfo.linked_pages.length - 5}
</Badge>
)}
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}></Button>
<Button
onClick={() => {
onImport();
handleClose();
}}
disabled={!selectedSectionId || isLoading}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -122,9 +122,9 @@ export function MasterFieldDialog({
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingMasterFieldId ? '마스터 항목 수정' : '마스터 항목 추가'}</DialogTitle>
<DialogTitle>{editingMasterFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
<DialogDescription>
릿
</DialogDescription>
</DialogHeader>
<div className="space-y-4">

View File

@@ -6,7 +6,6 @@ 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 } from 'lucide-react';
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
@@ -91,7 +90,66 @@ export function SectionDialog({
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 입력 모드 선택 */}
{/* 1. 섹션 유형 선택 (항상 표시) */}
<div>
<Label className="mb-3 block"> *</Label>
<div className="grid grid-cols-2 gap-3">
{/* 일반 섹션 */}
<div
onClick={() => {
setNewSectionType('fields');
// 타입 변경 시 선택된 템플릿 초기화
setSelectedTemplateId(null);
setNewSectionTitle('');
setNewSectionDescription('');
}}
className={`flex items-center gap-3 p-4 border rounded-lg cursor-pointer transition-all ${
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={() => {
setNewSectionType('bom');
// 타입 변경 시 선택된 템플릿 초기화
setSelectedTemplateId(null);
setNewSectionTitle('');
setNewSectionDescription('');
}}
className={`flex items-center gap-3 p-4 border rounded-lg cursor-pointer transition-all ${
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>
{/* 2. 입력 모드 선택 */}
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={sectionInputMode === 'custom' ? 'default' : 'ghost'}
@@ -99,6 +157,8 @@ export function SectionDialog({
onClick={() => {
setSectionInputMode('custom');
setSelectedTemplateId(null);
setNewSectionTitle('');
setNewSectionDescription('');
}}
className="flex-1"
>
@@ -107,137 +167,98 @@ export function SectionDialog({
<Button
variant={sectionInputMode === 'template' ? 'default' : 'ghost'}
size="sm"
onClick={() => setSectionInputMode('template')}
onClick={() => {
setSectionInputMode('template');
setSelectedTemplateId(null);
setNewSectionTitle('');
setNewSectionDescription('');
}}
className="flex-1"
>
릿
</Button>
</div>
{/* 템플릿 목록 */}
{/* 3. 템플릿 목록 - 선택된 섹션 타입에 따라 필터링 */}
{sectionInputMode === 'template' && (
<div className="border rounded p-3 space-y-2 max-h-[300px] overflow-y-auto">
<div className="border rounded p-3 space-y-2 max-h-[250px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> 릿 </Label>
<Label className="text-sm font-medium">
{newSectionType === 'bom' ? '모듈(BOM)' : '일반'} 릿
</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" />
{(() => {
// 선택된 타입에 맞는 템플릿만 필터링
const filteredTemplates = sectionTemplates.filter(template =>
newSectionType === 'bom'
? template.section_type === 'BOM'
: template.section_type !== 'BOM'
);
if (filteredTemplates.length === 0) {
return (
<p className="text-sm text-muted-foreground text-center py-4">
{newSectionType === 'bom' ? '모듈(BOM)' : '일반'} 릿 .<br/>
릿 .
</p>
);
}
return (
<div className="space-y-2">
{filteredTemplates.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>
</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>
)}
<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>
{selectedTemplateId === template.id && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
{selectedTemplateId === template.id && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
))}
</div>
);
})()}
</div>
)}
{/* 직접 입력 폼 또는 선택된 템플릿 정보 표시 */}
{(sectionInputMode === 'custom' || selectedTemplateId) && (
{/* 4. 직접 입력 폼 */}
{sectionInputMode === 'custom' && (
<>
{/* 섹션 유형 선택 - 템플릿 선택 시 비활성화 */}
<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'}
className={isSubmitted && isTitleEmpty && sectionInputMode === 'custom' ? 'border-red-500 focus-visible:ring-red-500' : ''}
className={isSubmitted && isTitleEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isTitleEmpty && sectionInputMode === 'custom' && (
{isSubmitted && isTitleEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
@@ -247,20 +268,10 @@ export function SectionDialog({
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' && (
{newSectionType === 'bom' && (
<div className="bg-blue-50 p-3 rounded-md">
<p className="text-sm text-blue-700">
<strong>BOM :</strong> (BOM) .
@@ -270,6 +281,16 @@ export function SectionDialog({
)}
</>
)}
{/* 5. 선택된 템플릿 정보 표시 */}
{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> "{newSectionTitle}"() .
릿 .
</p>
</div>
)}
</div>
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t flex-col sm:flex-row gap-2">

View File

@@ -44,8 +44,8 @@ interface TemplateFieldDialogProps {
setTemplateFieldColumnCount: (count: number) => void;
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: (names: string[]) => void;
handleAddTemplateField: () => void;
// 마스터 항목 관련 props
handleAddTemplateField: () => void | Promise<void>;
// 항목 관련 props
itemMasterFields?: ItemMasterField[];
templateFieldInputMode?: 'custom' | 'master';
setTemplateFieldInputMode?: (mode: 'custom' | 'master') => void;
@@ -79,7 +79,7 @@ export function TemplateFieldDialog({
templateFieldColumnNames,
setTemplateFieldColumnNames,
handleAddTemplateField,
// 마스터 항목 관련 props (optional)
// 항목 관련 props (optional)
itemMasterFields = [],
templateFieldInputMode = 'custom',
setTemplateFieldInputMode,
@@ -107,7 +107,7 @@ export function TemplateFieldDialog({
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
// 마스터 항목 관련 상태 초기화
// 항목 관련 상태 초기화
setTemplateFieldInputMode?.('custom');
setShowMasterFieldList?.(false);
setSelectedMasterFieldId?.('');
@@ -137,7 +137,7 @@ export function TemplateFieldDialog({
<DialogHeader>
<DialogTitle>{editingTemplateFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -161,16 +161,16 @@ export function TemplateFieldDialog({
}}
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>
<Label> </Label>
<Button
variant="ghost"
size="sm"
@@ -181,7 +181,7 @@ export function TemplateFieldDialog({
</div>
{itemMasterFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
<div className="space-y-2">

View File

@@ -73,10 +73,36 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
updateItemMasterField
} = useItemMaster();
// 속성 옵션 상태
const [unitOptions, setUnitOptions] = useState<MasterOption[]>([]);
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>([]);
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>([]);
// 속성 옵션 상태 (기본값 하드코딩 - TODO: 나중에 백엔드 API로 대체)
const [unitOptions, setUnitOptions] = useState<MasterOption[]>([
{ id: 'unit-1', value: 'EA', label: 'EA (개)', isActive: true },
{ id: 'unit-2', value: 'KG', label: 'KG (킬로그램)', isActive: true },
{ id: 'unit-3', value: 'M', label: 'M (미터)', isActive: true },
{ id: 'unit-4', value: 'MM', label: 'MM (밀리미터)', isActive: true },
{ id: 'unit-5', value: 'L', label: 'L (리터)', isActive: true },
{ id: 'unit-6', value: 'SET', label: 'SET (세트)', isActive: true },
{ id: 'unit-7', value: 'BOX', label: 'BOX (박스)', isActive: true },
{ id: 'unit-8', value: 'ROLL', label: 'ROLL (롤)', isActive: true },
]);
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>([
{ id: 'mat-1', value: 'SUS304', label: 'SUS304 (스테인리스)', isActive: true },
{ id: 'mat-2', value: 'SUS316', label: 'SUS316 (스테인리스)', isActive: true },
{ id: 'mat-3', value: 'AL6061', label: 'AL6061 (알루미늄)', isActive: true },
{ id: 'mat-4', value: 'AL5052', label: 'AL5052 (알루미늄)', isActive: true },
{ id: 'mat-5', value: 'SS400', label: 'SS400 (일반강)', isActive: true },
{ id: 'mat-6', value: 'S45C', label: 'S45C (탄소강)', isActive: true },
{ id: 'mat-7', value: 'POM', label: 'POM (폴리아세탈)', isActive: true },
{ id: 'mat-8', value: 'PEEK', label: 'PEEK (폴리에테르에테르케톤)', isActive: true },
]);
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>([
{ id: 'surf-1', value: 'NONE', label: '없음', isActive: true },
{ id: 'surf-2', value: 'ANODIZE', label: '아노다이징', isActive: true },
{ id: 'surf-3', value: 'PLATING', label: '도금', isActive: true },
{ id: 'surf-4', value: 'PAINTING', label: '도장', isActive: true },
{ id: 'surf-5', value: 'PASSIVATION', label: '부동태처리', isActive: true },
{ id: 'surf-6', value: 'SANDBLAST', label: '샌드블라스트', isActive: true },
{ id: 'surf-7', value: 'POLISHING', label: '폴리싱', isActive: true },
]);
const [customAttributeOptions, setCustomAttributeOptions] = useState<Record<string, MasterOption[]>>({});
// 옵션 다이얼로그 상태

View File

@@ -62,7 +62,7 @@ export interface UseFieldManagementReturn {
setTempConditionValue: (value: string) => void;
// 핸들러
handleAddField: (selectedPage: ItemPage | undefined) => void;
handleAddField: (selectedPage: ItemPage | undefined) => Promise<void>;
handleEditField: (sectionId: string, field: ItemField) => void;
handleDeleteField: (pageId: string, sectionId: string, fieldId: string) => void;
resetFieldForm: () => void;
@@ -74,7 +74,7 @@ export function useFieldManagement(): UseFieldManagementReturn {
addFieldToSection,
updateField,
deleteField,
addItemMasterField,
addItemMasterField: _addItemMasterField, // 2025-11-27: 중복 필드 생성 방지로 사용 안함
updateItemMasterField,
} = useItemMaster();
@@ -135,8 +135,8 @@ export function useFieldManagement(): UseFieldManagementReturn {
}
}, [fieldInputMode, selectedMasterFieldId, itemMasterFields]);
// 필드 추가
const handleAddField = (selectedPage: ItemPage | undefined) => {
// 필드 추가 (2025-11-27: async/await 추가 - 다른 탭 실시간 동기화)
const handleAddField = async (selectedPage: ItemPage | undefined) => {
if (!selectedPage || !selectedSectionForField || !newFieldName.trim() || !newFieldKey.trim()) {
toast.error('모든 필수 항목을 입력해주세요');
return;
@@ -187,59 +187,41 @@ export function useFieldManagement(): UseFieldManagementReturn {
updated_at: new Date().toISOString()
};
if (editingFieldId) {
console.log('Updating field:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldId: editingFieldId, fieldName: newField.field_name });
updateField(Number(editingFieldId), newField);
try {
if (editingFieldId) {
console.log('Updating field:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldId: editingFieldId, fieldName: newField.field_name });
await updateField(Number(editingFieldId), newField);
// 항목관리 탭의 마스터 항목도 업데이트 (동일한 fieldKey가 있으면)
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
if (existingMasterField) {
const updatedMasterField: Partial<ItemMasterField> = {
field_name: newField.field_name,
description: newField.placeholder ?? null,
properties: newField.properties,
updated_at: new Date().toISOString()
};
updateItemMasterField(existingMasterField.id, updatedMasterField);
}
// 항목관리 탭의 마스터 항목도 업데이트 (동일한 fieldKey가 있으면)
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
if (existingMasterField) {
const updatedMasterField: Partial<ItemMasterField> = {
field_name: newField.field_name,
description: newField.placeholder ?? null,
properties: newField.properties,
updated_at: new Date().toISOString()
};
await updateItemMasterField(existingMasterField.id, updatedMasterField);
}
toast.success('항목이 섹션에 수정되었습니다!');
} else {
console.log('Adding field to section:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldName: newField.field_name });
// 1. 섹션에 항목 추가
addFieldToSection(Number(selectedSectionForField), newField);
// 2. 마스터 항목 선택이 아닌 경우에만 새 마스터 항목 자동 생성
const isFromMasterField = masterFieldId !== null;
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
if (!isFromMasterField && !existingMasterField) {
// ItemMasterField 타입에 맞게 필수 필드 포함
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
field_name: newField.field_name,
field_type: newField.field_type,
description: newField.placeholder ?? null,
category: selectedPage.item_type,
is_common: false,
default_value: null,
options: newField.options ?? null,
validation_rules: null,
properties: newField.properties ?? null,
};
addItemMasterField(newMasterFieldData as any);
console.log('Field added to both section and master fields:', {
fieldId: newField.id,
fieldName: newMasterFieldData.field_name
});
toast.success('항목이 섹션에 추가되고 마스터 항목으로도 등록되었습니다!');
toast.success('항목이 섹션에 수정되었습니다!');
} else {
console.log('Adding field to section:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldName: newField.field_name });
// 섹션에 항목 추가 (await로 완료 대기)
// 2025-11-27: addItemMasterField 호출 제거 - 중복 필드 생성 방지
// 계층구조에서 만든 필드는 섹션에만 연결됨 (section_id = X)
// 항목탭에는 독립 필드(section_id = null)만 표시
await addFieldToSection(Number(selectedSectionForField), newField);
toast.success('항목이 섹션에 추가되었습니다!');
}
}
resetFieldForm();
resetFieldForm();
} catch (error) {
console.error('필드 처리 실패:', error);
toast.error('항목 처리에 실패했습니다');
}
};
// 필드 수정
@@ -249,9 +231,10 @@ export function useFieldManagement(): UseFieldManagementReturn {
setNewFieldName(field.field_name);
setNewFieldKey(field.id.toString());
setNewFieldInputType(field.field_type);
setNewFieldRequired(field.is_required);
// 2025-11-27: is_required와 properties.required 둘 다 체크
setNewFieldRequired(field.is_required || field.properties?.required || false);
setNewFieldOptions(field.options?.map(opt => opt.value).join(', ') || '');
setNewFieldDescription('');
setNewFieldDescription(field.placeholder || '');
// 조건부 표시 설정 로드
if (field.display_condition) {

View File

@@ -5,6 +5,12 @@ import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
/**
* @deprecated 2025-11-27: item_fields로 통합됨.
* - itemMasterFields → item_fields WHERE section_id IS NULL
* - 내부적으로 fields.* API를 사용하도록 마이그레이션 완료
* - 향후 독립 필드 관리용 훅으로 리네임 예정
*/
export interface UseMasterFieldManagementReturn {
// 다이얼로그 상태
isMasterFieldDialogOpen: boolean;
@@ -99,7 +105,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
addItemMasterField(newMasterFieldData as any);
resetMasterFieldForm();
toast.success('마스터 항목이 추가되었습니다 (저장 필요)');
toast.success('항목이 추가되었습니다');
};
// 마스터 항목 수정 시작
@@ -145,14 +151,14 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
updateItemMasterField(editingMasterFieldId, updateData);
resetMasterFieldForm();
toast.success('마스터 항목이 수정되었습니다 (저장 필요)');
toast.success('항목이 수정되었습니다');
};
// 마스터 항목 삭제
// 항목 삭제 (2025-11-27: 마스터 항목 → 항목으로 통합)
const handleDeleteMasterField = (id: number) => {
if (confirm('이 마스터 항목을 삭제하시겠습니까?')) {
if (confirm('이 항목을 삭제하시겠습니까?\n(섹션에서 사용 중인 경우 연결도 함께 해제됩니다)')) {
deleteItemMasterField(id);
toast.success('마스터 항목이 삭제되었습니다');
toast.success('항목이 삭제되었습니다');
}
};

View File

@@ -27,11 +27,12 @@ export interface UseSectionManagementReturn {
setExpandedSections: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
// 핸들러
handleAddSection: (selectedPage: ItemPage | undefined) => void;
handleLinkTemplate: (template: SectionTemplate, selectedPage: ItemPage | undefined) => void;
handleAddSection: (selectedPage: ItemPage | undefined) => Promise<void>;
handleLinkTemplate: (template: SectionTemplate, selectedPage: ItemPage | undefined) => Promise<void>;
handleEditSectionTitle: (sectionId: number, currentTitle: string) => void;
handleSaveSectionTitle: (selectedPage: ItemPage | undefined) => void;
handleDeleteSection: (pageId: number, sectionId: number) => void;
handleUnlinkSection: (pageId: number, sectionId: number) => void; // 계층구조 탭용 - 연결 해제
handleDeleteSection: (pageId: number, sectionId: number) => void; // 섹션 탭용 - 실제 삭제
toggleSection: (sectionId: string) => void;
resetSectionForm: () => void;
}
@@ -42,8 +43,8 @@ export function useSectionManagement(): UseSectionManagementReturn {
addSectionToPage,
updateSection,
deleteSection,
addSectionTemplate,
tenantId,
linkSectionToPage, // 2025-11-26: 기존 섹션을 페이지에 연결 (entity_relationships)
unlinkSectionFromPage, // 2025-11-26: EntityRelationship API 사용
} = useItemMaster();
// 상태
@@ -58,112 +59,84 @@ export function useSectionManagement(): UseSectionManagementReturn {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
// 섹션 추가
const handleAddSection = (selectedPage: ItemPage | undefined) => {
const handleAddSection = async (selectedPage: ItemPage | undefined) => {
if (!selectedPage || !newSectionTitle.trim()) {
toast.error('하위섹션 제목을 입력해주세요');
return;
}
const sectionType: 'BASIC' | 'BOM' | 'CUSTOM' = newSectionType === 'bom' ? 'BOM' : 'BASIC';
const newSection: ItemSection = {
id: Date.now(),
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
page_id: selectedPage.id,
section_name: newSectionTitle,
title: newSectionTitle,
section_type: sectionType,
description: newSectionDescription || undefined,
order_no: selectedPage.sections.length + 1,
is_template: false,
is_default: false,
is_collapsible: true,
is_default_open: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
fields: [],
bomItems: sectionType === 'BOM' ? [] : undefined
bom_items: sectionType === 'BOM' ? [] : undefined
};
console.log('Adding section to page:', {
pageId: selectedPage.id,
page_name: selectedPage.page_name,
sectionTitle: newSection.section_name,
sectionTitle: newSection.title,
sectionType: newSection.section_type,
currentSectionCount: selectedPage.sections.length,
newSection: newSection
});
// 1. 페이지에 섹션 추가
addSectionToPage(selectedPage.id, newSection);
try {
// 페이지에 섹션 추가 (API 호출)
// 2025-11-26: sectionsAsTemplates가 itemPages에서 useMemo로 파생되므로
// 별도의 addSectionTemplate 호출 불필요 (자동 동기화)
await addSectionToPage(selectedPage.id, newSection);
// 2. 섹션관리 탭에도 템플릿으로 자동 추가
const newTemplateData = {
tenant_id: tenantId ?? 0,
template_name: newSection.section_name,
section_type: newSection.section_type as 'BASIC' | 'BOM' | 'CUSTOM',
description: newSection.description ?? null,
default_fields: null,
created_by: null,
updated_by: null,
};
addSectionTemplate(newTemplateData);
console.log('Section added to page:', {
sectionTitle: newSection.title
});
console.log('Section added to both page and template:', {
sectionId: newSection.id,
templateTitle: newTemplateData.template_name
});
resetSectionForm();
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 페이지에 추가되고 템플릿으로도 등록되었습니다!`);
resetSectionForm();
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 추가되었습니다!`);
} catch (error) {
console.error('섹션 추가 실패:', error);
toast.error('섹션 추가에 실패했습니다. 다시 시도해주세요.');
}
};
// 섹션 템플릿을 페이지에 연결
const handleLinkTemplate = (template: SectionTemplate, selectedPage: ItemPage | undefined) => {
// 기존 섹션을 페이지에 연결 (entity_relationships 테이블 사용)
// 2025-11-26: 새 섹션 생성이 아닌, 기존 섹션을 연결만 함
const handleLinkTemplate = async (template: SectionTemplate, selectedPage: ItemPage | undefined) => {
if (!selectedPage) {
toast.error('페이지를 먼저 선택해주세요');
return;
}
// 템플릿을 섹션으로 변환하여 페이지에 추가
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
page_id: selectedPage.id,
section_name: template.template_name,
section_type: template.section_type,
description: template.description || undefined,
order_no: selectedPage.sections.length + 1,
is_collapsible: true,
is_default_open: true,
fields: template.fields ? template.fields.map((field, idx) => ({
id: Date.now() + idx,
section_id: 0, // 추후 업데이트됨
field_name: field.name,
field_type: field.property.inputType,
order_no: idx + 1,
is_required: field.property.required,
placeholder: field.description || null,
default_value: null,
display_condition: null,
validation_rules: null,
options: field.property.options
? field.property.options.map(opt => ({ label: opt, value: opt }))
: null,
properties: field.property.multiColumn ? {
multiColumn: true,
columnCount: field.property.columnCount,
columnNames: field.property.columnNames
} : null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})) : [],
bomItems: template.section_type === 'BOM' ? (template.bomItems || []) : undefined
};
// 이미 연결된 섹션인지 확인
const isAlreadyLinked = selectedPage.sections.some(s => s.id === template.id);
if (isAlreadyLinked) {
toast.error('이미 페이지에 연결된 섹션입니다');
return;
}
console.log('Linking template to page:', {
templateId: template.id,
templateName: template.template_name,
console.log('Linking existing section to page:', {
sectionId: template.id,
sectionName: template.template_name,
pageId: selectedPage.id,
newSection
orderNo: selectedPage.sections.length + 1,
});
addSectionToPage(selectedPage.id, newSection);
resetSectionForm();
toast.success(`"${template.template_name}" 템플릿이 페이지에 연결되었습니다!`);
try {
// 기존 섹션을 페이지에 연결 (entity_relationships에 레코드 추가)
await linkSectionToPage(selectedPage.id, template.id, selectedPage.sections.length + 1);
resetSectionForm();
toast.success(`"${template.template_name}" 섹션이 페이지에 연결되었습니다!`);
} catch (error) {
console.error('섹션 연결 실패:', error);
toast.error('섹션 연결에 실패했습니다. 다시 시도해주세요.');
}
};
// 섹션 제목 수정 시작
@@ -173,30 +146,53 @@ export function useSectionManagement(): UseSectionManagementReturn {
};
// 섹션 제목 저장
const handleSaveSectionTitle = (selectedPage: ItemPage | undefined) => {
const handleSaveSectionTitle = async (selectedPage: ItemPage | undefined) => {
if (!selectedPage || !editingSectionId || !editingSectionTitle.trim()) {
toast.error('하위섹션 제목을 입력해주세요');
return;
}
updateSection(editingSectionId, { section_name: editingSectionTitle });
setEditingSectionId(null);
setEditingSectionTitle('');
toast.success('하위섹션 제목이 수정되었습니다 (저장 필요)');
try {
await updateSection(editingSectionId, { title: editingSectionTitle });
setEditingSectionId(null);
setEditingSectionTitle('');
toast.success('섹션 제목이 수정되었습니다!');
} catch (error) {
console.error('섹션 제목 수정 실패:', error);
toast.error('섹션 제목 수정에 실패했습니다.');
}
};
// 섹션 삭제
const handleDeleteSection = (pageId: number, sectionId: number) => {
// 섹션 연결 해제 (계층구조 탭용 - 페이지에서만 분리, 섹션 데이터는 유지)
// 2025-11-26: EntityRelationship API 사용 (DELETE /pages/{pageId}/unlink-section/{sectionId})
const handleUnlinkSection = async (pageId: number, sectionId: number) => {
try {
await unlinkSectionFromPage(pageId, sectionId);
console.log('섹션 연결 해제 완료:', { pageId, sectionId });
toast.success('섹션 연결이 해제되었습니다');
} catch (error) {
console.error('섹션 연결 해제 실패:', error);
toast.error('섹션 연결 해제에 실패했습니다.');
}
};
// 섹션 삭제 (섹션 탭용 - 실제 데이터 삭제)
const handleDeleteSection = async (pageId: number, sectionId: number) => {
const page = itemPages.find(p => p.id === pageId);
const sectionToDelete = page?.sections.find(s => s.id === sectionId);
const fieldIds = sectionToDelete?.fields?.map(f => f.id) || [];
deleteSection(sectionId);
console.log('섹션 삭제 완료:', {
sectionId,
removedFields: fieldIds.length
});
try {
await deleteSection(sectionId);
console.log('섹션 삭제 완료:', {
sectionId,
removedFields: fieldIds.length
});
toast.success('섹션이 삭제되었습니다!');
} catch (error) {
console.error('섹션 삭제 실패:', error);
toast.error('섹션 삭제에 실패했습니다.');
}
};
// 섹션 확장/축소 토글
@@ -240,6 +236,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
handleLinkTemplate,
handleEditSectionTitle,
handleSaveSectionTitle,
handleUnlinkSection,
handleDeleteSection,
toggleSection,
resetSectionForm,

View File

@@ -100,8 +100,13 @@ export function useTabManagement(): UseTabManagementReturn {
]);
const [activeTab, setActiveTab] = useState('hierarchy');
// 속성 하위 탭 상태
const [attributeSubTabs, setAttributeSubTabs] = useState<AttributeSubTab[]>([]);
// 속성 하위 탭 상태 (기본 탭: 단위, 재질, 표면처리)
// TODO: 나중에 백엔드에서 기준값 로드로 대체 예정
const [attributeSubTabs, setAttributeSubTabs] = useState<AttributeSubTab[]>([
{ id: 'units', label: '단위', key: 'units', isDefault: true, order: 0 },
{ id: 'materials', label: '재질', key: 'materials', isDefault: true, order: 1 },
{ id: 'surface', label: '표면처리', key: 'surface', isDefault: true, order: 2 },
]);
const [activeAttributeTab, setActiveAttributeTab] = useState('units');
// 메인 탭 다이얼로그 상태
@@ -123,7 +128,7 @@ export function useTabManagement(): UseTabManagementReturn {
// 이전 필드 상태 추적용 ref (무한 루프 방지)
const prevFieldsRef = useRef<string>('');
// 마스터 항목이 추가/수정될 때 속성 탭 자동 생성
// 마스터 항목이 추가/수정/삭제될 때 속성 탭 자동 동기화
useEffect(() => {
// 현재 필드 상태를 문자열로 직렬화
const currentFieldsState = JSON.stringify(
@@ -136,15 +141,30 @@ export function useTabManagement(): UseTabManagementReturn {
}
prevFieldsRef.current = currentFieldsState;
// 현재 마스터 필드 ID 목록
const currentFieldIds = new Set(itemMasterFields.map(f => f.id.toString()));
setAttributeSubTabs(prev => {
const newTabs: AttributeSubTab[] = [];
const updates: { key: string; label: string }[] = [];
// 삭제된 마스터 항목에 해당하는 탭 제거 (숫자 key만 체크 - 마스터 항목 ID)
const filteredTabs = prev.filter(tab => {
// 숫자로만 이루어진 key는 마스터 항목 ID
const isNumericKey = /^\d+$/.test(tab.key);
if (isNumericKey && !currentFieldIds.has(tab.key)) {
// 삭제된 마스터 항목의 탭
return false;
}
return true;
});
// 새로운 마스터 항목 추가 또는 기존 항목 라벨 업데이트
itemMasterFields.forEach(field => {
const existingTab = prev.find(tab => tab.key === field.id.toString());
const existingTab = filteredTabs.find(tab => tab.key === field.id.toString());
if (!existingTab) {
const maxOrder = Math.max(...prev.map(t => t.order), ...newTabs.map(t => t.order), -1);
const maxOrder = Math.max(...filteredTabs.map(t => t.order), ...newTabs.map(t => t.order), -1);
newTabs.push({
id: `attr-${field.id.toString()}`,
label: field.field_name,
@@ -157,12 +177,17 @@ export function useTabManagement(): UseTabManagementReturn {
}
});
// 탭 삭제, 추가, 업데이트 여부 확인
const hasRemovals = filteredTabs.length !== prev.length;
const hasAdditions = newTabs.length > 0;
const hasUpdates = updates.length > 0;
// 변경사항 없으면 이전 상태 그대로 반환
if (newTabs.length === 0 && updates.length === 0) {
if (!hasRemovals && !hasAdditions && !hasUpdates) {
return prev;
}
let result = prev.map(tab => {
let result = filteredTabs.map(tab => {
const update = updates.find(u => u.key === tab.key);
return update ? { ...tab, label: update.label } : tab;
});
@@ -173,6 +198,12 @@ export function useTabManagement(): UseTabManagementReturn {
index === self.findIndex(t => t.key === tab.key)
);
});
// 현재 활성 탭이 삭제된 마스터 항목인 경우 기본 탭으로 전환
const isNumericKey = /^\d+$/.test(activeAttributeTab);
if (isNumericKey && !currentFieldIds.has(activeAttributeTab)) {
setActiveAttributeTab('units');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemMasterFields]);

View File

@@ -70,7 +70,7 @@ export interface UseTemplateManagementReturn {
handleUpdateSectionTemplate: () => void;
handleDeleteSectionTemplate: (id: number) => void;
handleLoadTemplate: (selectedPage: ItemPage | undefined) => void;
handleAddTemplateField: () => void;
handleAddTemplateField: () => Promise<void>;
handleEditTemplateField: (templateId: number, field: TemplateField) => void;
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
@@ -89,7 +89,20 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
addSectionToPage,
addItemMasterField,
itemMasterFields,
tenantId
tenantId,
// 2025-11-26: sectionsAsTemplates가 itemPages에서 파생되므로
// 섹션 탭에서 수정/삭제 시 실제 섹션 API를 호출해야 함
updateSection,
deleteSection,
itemPages,
// 2025-11-26: 섹션 탭에서 새 섹션 추가 시 독립 섹션으로 생성
createIndependentSection,
// 2025-11-27: entity_relationships 기반 필드 연결/해제
linkFieldToSection,
unlinkFieldFromSection,
independentFields,
// 2025-11-27: 필드 수정 API
updateField,
} = useItemMaster();
// 섹션 템플릿 다이얼로그 상태
@@ -127,28 +140,33 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
const [templateFieldShowMasterFieldList, setTemplateFieldShowMasterFieldList] = useState(false);
const [templateFieldSelectedMasterFieldId, setTemplateFieldSelectedMasterFieldId] = useState('');
// 섹션 템플릿 추가
const handleAddSectionTemplate = () => {
// 섹션 템플릿 추가 (2025-11-26: 독립 섹션으로 생성하여 sectionsAsTemplates에 반영)
const handleAddSectionTemplate = async () => {
if (!newSectionTemplateTitle.trim()) {
toast.error('섹션 제목을 입력해주세요');
return;
}
const newTemplateData = {
tenant_id: tenantId ?? 0,
template_name: newSectionTemplateTitle,
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM',
description: newSectionTemplateDescription || null,
default_fields: null,
category: newSectionTemplateCategory,
created_by: null,
updated_by: null,
// 2025-11-26: sectionsAsTemplates가 itemPages + independentSections에서 파생되므로
// 독립 섹션으로 생성해야 화면에 바로 반영됨
const newSectionData = {
title: newSectionTemplateTitle,
type: newSectionTemplateType as 'fields' | 'bom',
description: newSectionTemplateDescription || undefined,
is_template: true, // 섹션 탭에서 생성된 섹션은 템플릿으로 표시
is_default: false,
};
console.log('Adding section template:', newTemplateData);
addSectionTemplate(newTemplateData);
resetSectionTemplateForm();
toast.success('섹션 템플릿이 추가되었습니다!');
console.log('Adding independent section (from section tab):', newSectionData);
try {
await createIndependentSection(newSectionData);
resetSectionTemplateForm();
toast.success('섹션이 추가되었습니다!');
} catch (error) {
console.error('섹션 추가 실패:', error);
toast.error('섹션 추가에 실패했습니다.');
}
};
// 섹션 템플릿 수정 시작
@@ -161,31 +179,43 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
setIsSectionTemplateDialogOpen(true);
};
// 섹션 템플릿 업데이트
const handleUpdateSectionTemplate = () => {
// 섹션 템플릿 업데이트 (2025-11-26: sectionsAsTemplates 사용으로 실제 섹션 API 호출)
const handleUpdateSectionTemplate = async () => {
if (!editingSectionTemplateId || !newSectionTemplateTitle.trim()) {
toast.error('섹션 제목을 입력해주세요');
return;
}
// sectionsAsTemplates가 itemPages에서 파생되므로, 실제 섹션을 업데이트해야 함
const updateData = {
template_name: newSectionTemplateTitle,
title: newSectionTemplateTitle,
description: newSectionTemplateDescription || undefined,
category: newSectionTemplateCategory.length > 0 ? newSectionTemplateCategory : undefined,
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM'
};
console.log('Updating section template:', { id: editingSectionTemplateId, updateData });
updateSectionTemplate(editingSectionTemplateId, updateData);
resetSectionTemplateForm();
toast.success('섹션이 수정되었습니다 (저장 필요)');
console.log('Updating section (from template handler):', { id: editingSectionTemplateId, updateData });
try {
// updateSection 호출 (템플릿이 아닌 실제 섹션 API)
await updateSection(editingSectionTemplateId, updateData);
resetSectionTemplateForm();
toast.success('섹션이 수정되었습니다!');
} catch (error) {
console.error('섹션 수정 실패:', error);
toast.error('섹션 수정에 실패했습니다.');
}
};
// 섹션 템플릿 삭제
const handleDeleteSectionTemplate = (id: number) => {
// 섹션 템플릿 삭제 (2025-11-26: sectionsAsTemplates 사용으로 실제 섹션 API 호출)
const handleDeleteSectionTemplate = async (id: number) => {
if (confirm('이 섹션을 삭제하시겠습니까?')) {
deleteSectionTemplate(id);
toast.success('섹션이 삭제되었습니다');
try {
// deleteSection 호출 (템플릿이 아닌 실제 섹션 API)
await deleteSection(id);
toast.success('섹션이 삭제되었습니다!');
} catch (error) {
console.error('섹션 삭제 실패:', error);
toast.error('섹션 삭제에 실패했습니다.');
}
}
};
@@ -204,14 +234,16 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
const newSection = {
page_id: selectedPage.id,
section_name: template.template_name,
title: template.template_name,
section_type: template.section_type === 'BOM' ? 'BOM' as const : 'BASIC' as const,
description: template.description || undefined,
order_no: selectedPage.sections.length + 1,
is_template: false,
is_default: false,
is_collapsible: true,
is_default_open: true,
fields: [],
bomItems: template.section_type === 'BOM' ? [] : undefined
bom_items: template.section_type === 'BOM' ? [] : undefined
};
console.log('Loading template to section:', template.template_name, 'newSection:', newSection);
@@ -221,77 +253,57 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
toast.success('섹션이 불러와졌습니다');
};
// 템플릿 필드 추가
const handleAddTemplateField = () => {
// 템플릿 필드 추가/수정 (2025-11-27: API 사용으로 변경)
// sectionsAsTemplates가 itemPages + independentSections에서 파생되므로
// entity_relationships 기반 연결 API를 사용해야 실시간 반영됨
const handleAddTemplateField = async () => {
if (!currentTemplateId || !templateFieldName.trim() || !templateFieldKey.trim()) {
toast.error('모든 필수 항목을 입력해주세요');
return;
}
const template = sectionTemplates.find(t => t.id === currentTemplateId);
if (!template) return;
try {
// 수정 모드: 기존 필드 속성 업데이트
if (editingTemplateFieldId) {
const updateData = {
field_name: templateFieldName,
field_type: templateFieldInputType,
is_required: templateFieldRequired,
placeholder: templateFieldDescription || null,
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
: null,
properties: {
inputType: templateFieldInputType,
required: templateFieldRequired,
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined,
},
};
// 마스터 필드에 없으면 자동 추가
const existingMasterField = itemMasterFields.find(f => f.id.toString() === templateFieldKey);
if (!existingMasterField && !editingTemplateFieldId) {
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
field_name: templateFieldName,
field_type: templateFieldInputType,
category: '공통',
description: templateFieldDescription || null,
is_common: false,
default_value: null,
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
: null,
validation_rules: null,
properties: {
inputType: templateFieldInputType,
required: templateFieldRequired,
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
},
};
addItemMasterField(newMasterFieldData as any);
toast.success('항목 탭에 자동으로 추가되었습니다');
await updateField(editingTemplateFieldId, updateData);
toast.success('항목이 수정되었습니다');
resetTemplateFieldForm();
return;
}
// 추가 모드: 기존 필드를 섹션에 연결
const existingField = independentFields.find(f => f.id.toString() === templateFieldKey);
if (existingField) {
await linkFieldToSection(currentTemplateId, existingField.id);
toast.success('항목이 섹션에 연결되었습니다');
} else {
toast.error('항목 탭에서 먼저 항목을 생성해주세요');
return;
}
resetTemplateFieldForm();
} catch (error) {
console.error('항목 처리 실패:', error);
toast.error('항목 처리에 실패했습니다');
}
// TemplateField 형식으로 생성
const newField: TemplateField = {
id: String(editingTemplateFieldId || Date.now()),
name: templateFieldName,
fieldKey: templateFieldKey,
property: {
inputType: templateFieldInputType,
required: templateFieldRequired,
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
? templateFieldOptions.split(',').map(o => o.trim())
: undefined,
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
},
description: templateFieldDescription || undefined
};
let updatedFields;
const currentFields = template.default_fields
? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields)
: [];
if (editingTemplateFieldId) {
updatedFields = Array.isArray(currentFields)
? currentFields.map((f: any) => String(f.id) === String(editingTemplateFieldId) ? newField : f)
: [];
toast.success('항목이 수정되었습니다');
} else {
updatedFields = Array.isArray(currentFields) ? [...currentFields, newField] : [newField];
toast.success('항목이 추가되었습니다');
}
updateSectionTemplate(currentTemplateId, { default_fields: updatedFields });
resetTemplateFieldForm();
};
// 템플릿 필드 수정 시작
@@ -310,21 +322,20 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
setIsTemplateFieldDialogOpen(true);
};
// 템플릿 필드 삭제
const handleDeleteTemplateField = (templateId: number, fieldId: string) => {
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
// 템플릿 필드 연결 해제 (2025-11-27: entity_relationships 기반으로 변경)
// sectionId = templateId (sectionsAsTemplates에서 섹션 ID로 사용)
// fieldId = 실제 item_fields의 ID
const handleDeleteTemplateField = async (templateId: number, fieldId: string) => {
if (!confirm('이 항목의 연결을 해제하시겠습니까?\n(항목 자체는 삭제되지 않고 항목 탭에 유지됩니다)')) return;
const template = sectionTemplates.find(t => t.id === templateId);
if (!template) return;
const currentFields = template.default_fields
? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields)
: [];
const updatedFields = Array.isArray(currentFields)
? currentFields.filter((f: any) => String(f.id) !== String(fieldId))
: [];
updateSectionTemplate(templateId, { default_fields: updatedFields });
toast.success('항목이 삭제되었습니다');
try {
// entity_relationships 기반 연결 해제 API 호출
await unlinkFieldFromSection(templateId, Number(fieldId));
toast.success('항목 연결이 해제되었습니다');
} catch (error) {
console.error('항목 연결 해제 실패:', error);
toast.error('항목 연결 해제에 실패했습니다.');
}
};
// BOM 항목 추가

View File

@@ -1,9 +1,9 @@
import type { Dispatch, SetStateAction } from 'react';
import type { ItemPage, ItemSection } from '@/contexts/ItemMasterContext';
import type { ItemPage, ItemSection, BOMItem } 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 { Plus, Edit, Trash2, Link, Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { DraggableSection, DraggableField } from '../../components';
import { BOMManagementSection } from '@/components/items/BOMManagementSection';
@@ -13,6 +13,7 @@ interface HierarchyTabProps {
itemPages: ItemPage[];
selectedPage: ItemPage | undefined;
ITEM_TYPE_OPTIONS: Array<{ value: string; label: string }>;
unitOptions?: Array<{ value: string; label: string }>;
// State
editingPageId: number | null;
@@ -54,17 +55,26 @@ interface HierarchyTabProps {
handleEditSectionTitle: (sectionId: number, title: string) => void;
handleSaveSectionTitle: () => void;
moveSection: (dragIndex: number, hoverIndex: number) => void;
deleteSection: (pageId: number, sectionId: number) => void;
unlinkSection: (pageId: number, sectionId: number) => void; // 연결 해제 (삭제 아님)
updateSection: (sectionId: number, updates: Partial<ItemSection>) => Promise<void>;
deleteField: (pageId: string, sectionId: string, fieldId: string) => void;
deleteField: (pageId: string, sectionId: string, fieldId: string) => void; // 2025-11-27: 연결 해제로 변경 (삭제 아님, 항목 탭에 유지)
handleEditField: (sectionId: string, field: any) => void;
moveField: (sectionId: number, dragIndex: number, hoverIndex: number) => void;
// 2025-11-26 추가: 섹션/필드 불러오기
setIsImportSectionDialogOpen?: (open: boolean) => void;
setIsImportFieldDialogOpen?: (open: boolean) => void;
setImportFieldTargetSectionId?: (id: number | null) => void;
// 2025-11-27 추가: BOM 항목 API 함수
addBOMItem?: (sectionId: number, bomData: Omit<BOMItem, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
updateBOMItem?: (bomId: number, updates: Partial<BOMItem>) => Promise<void>;
deleteBOMItem?: (bomId: number) => Promise<void>;
}
export function HierarchyTab({
itemPages,
selectedPage,
ITEM_TYPE_OPTIONS,
unitOptions = [],
editingPageId,
setEditingPageId,
editingPageName,
@@ -95,11 +105,19 @@ export function HierarchyTab({
handleEditSectionTitle,
handleSaveSectionTitle,
moveSection,
deleteSection,
unlinkSection,
updateSection,
deleteField,
handleEditField,
moveField
moveField,
// 2025-11-26 추가: 섹션/필드 불러오기
setIsImportSectionDialogOpen,
setIsImportFieldDialogOpen,
setImportFieldTargetSectionId,
// 2025-11-27 추가: BOM 항목 API 함수
addBOMItem,
updateBOMItem,
deleteBOMItem,
}: HierarchyTabProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
@@ -326,7 +344,7 @@ export function HierarchyTab({
selectedPage.sections
.map((section, index) => (
<DraggableSection
key={section.id}
key={`section-${section.id}-${index}`}
section={section}
index={index}
moveSection={(dragIndex, hoverIndex) => {
@@ -334,8 +352,7 @@ export function HierarchyTab({
}}
onDelete={() => {
if (confirm('이 섹션을 페이지에서 연결 해제하시겠습니까?\n(섹션 데이터는 섹션 탭에 유지됩니다)')) {
deleteSection(selectedPage.id, section.id);
toast.success('섹션 연결이 해제되었습니다');
unlinkSection(selectedPage.id, section.id);
}
}}
onEditTitle={handleEditSectionTitle}
@@ -350,31 +367,69 @@ export function HierarchyTab({
<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 항목이 추가되었습니다');
bomItems={section.bom_items || []}
onAddItem={async (item) => {
// 2025-11-27: API 함수로 BOM 항목 추가
console.log('[HierarchyTab] BOM 추가 시작:', { sectionId: section.id, item, addBOMItemExists: !!addBOMItem });
if (addBOMItem) {
try {
await addBOMItem(section.id, {
section_id: section.id,
item_name: item.item_name,
item_code: item.item_code,
quantity: item.quantity,
unit: item.unit,
spec: item.spec,
});
console.log('[HierarchyTab] BOM 추가 성공');
toast.success('BOM 항목이 추가되었습니다');
} catch (error) {
console.error('[HierarchyTab] BOM 추가 실패:', error);
toast.error('BOM 항목 추가에 실패했습니다. 백엔드 API를 확인하세요.');
}
} else {
// Fallback: 로컬 상태만 업데이트 (API 함수 없을 경우)
console.warn('[HierarchyTab] addBOMItem 함수가 없어 로컬 저장만 수행합니다');
const now = new Date().toISOString();
const newBomItems = [...(section.bom_items || []), {
...item,
id: Date.now(),
section_id: section.id,
created_at: now,
updated_at: now
}];
updateSection(section.id, { bom_items: 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 항목이 수정되었습니다');
onUpdateItem={async (id, updatedItem) => {
// 2025-11-27: API 함수로 BOM 항목 수정
if (updateBOMItem) {
await updateBOMItem(id, updatedItem);
toast.success('BOM 항목이 수정되었습니다');
} else {
// Fallback: 로컬 상태만 업데이트
const newBomItems = (section.bom_items || []).map(item =>
item.id === id ? { ...item, ...updatedItem } : item
);
updateSection(section.id, { bom_items: newBomItems });
toast.success('BOM 항목이 수정되었습니다 (로컬)');
}
}}
onDeleteItem={(itemId) => {
const newBomItems = (section.bomItems || []).filter(item => item.id !== itemId);
updateSection(section.id, { bomItems: newBomItems });
toast.success('BOM 항목이 삭제되었습니다');
onDeleteItem={async (itemId) => {
// 2025-11-27: API 함수로 BOM 항목 삭제
if (deleteBOMItem) {
await deleteBOMItem(itemId);
toast.success('BOM 항목이 삭제되었습니다');
} else {
// Fallback: 로컬 상태만 업데이트
const newBomItems = (section.bom_items || []).filter(item => item.id !== itemId);
updateSection(section.id, { bom_items: newBomItems });
toast.success('BOM 항목이 삭제되었습니다 (로컬)');
}
}}
unitOptions={unitOptions}
itemTypeOptions={ITEM_TYPE_OPTIONS}
/>
) : (
/* 일반 필드 타입 섹션 */
@@ -386,31 +441,44 @@ export function HierarchyTab({
.sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0))
.map((field, fieldIndex) => (
<DraggableField
key={field.id}
key={`field-${field.id}-${fieldIndex}`}
field={field}
index={fieldIndex}
moveField={(dragIndex, hoverIndex) => moveField(section.id, dragIndex, hoverIndex)}
onDelete={() => {
if (confirm('이 항목을 삭제하시겠습니까?')) {
if (confirm('이 항목을 섹션에서 연결 해제하시겠습니까?\n(항목 데이터는 항목 탭에 유지됩니다)')) {
deleteField(String(selectedPage.id), String(section.id), String(field.id));
toast.success('항목이 제되었습니다');
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>
<div className="flex gap-2 mt-3">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
setImportFieldTargetSectionId?.(section.id);
setIsImportFieldDialogOpen?.(true);
}}
>
<Download className="h-3 w-3 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
setSelectedSectionForField(section.id);
setIsFieldDialogOpen(true);
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</>
)}
</DraggableSection>

View File

@@ -1,3 +1,11 @@
/**
* 항목 탭 컴포넌트
*
* @deprecated 2025-11-27: "마스터 항목" → "항목"으로 명칭 변경
* - item_master_fields 테이블 삭제됨
* - item_fields WHERE section_id IS NULL로 통합
* - 향후 FieldTab으로 리네임 예정
*/
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -47,8 +55,8 @@ export function MasterFieldTab({
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<CardTitle> </CardTitle>
<CardDescription> 릿 </CardDescription>
<CardTitle> </CardTitle>
<CardDescription> . .</CardDescription>
</div>
{/* 변경사항 배지 - 나중에 사용 예정으로 임시 숨김 */}
{false && hasUnsavedChanges && pendingChanges.masterFields.length > 0 && (
@@ -65,7 +73,7 @@ export function MasterFieldTab({
<CardContent>
{itemMasterFields.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2"> </p>
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground">
.
</p>
@@ -80,7 +88,7 @@ export function MasterFieldTab({
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label}
</Badge>
{field.properties?.required && (
{(field.is_required || field.properties?.required) && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
{field.category && (

View File

@@ -1,10 +1,11 @@
'use client';
import { useEffect } from 'react';
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 { Plus, Edit, Trash2, Folder, Package, FileText, GripVertical, Copy, Download, Unlink } from 'lucide-react';
import type { SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
import { BOMManagementSection } from '../../BOMManagementSection';
@@ -33,12 +34,18 @@ interface SectionsTabProps {
// 옵션
ITEM_TYPE_OPTIONS: Array<{ value: string; label: string }>;
INPUT_TYPE_OPTIONS: Array<{ value: string; label: string }>;
unitOptions?: Array<{ value: string; label: string }>;
// 변경사항 추적 (나중에 사용 예정)
hasUnsavedChanges?: boolean;
pendingChanges?: {
sectionTemplates: any[];
};
// 2025-11-26 추가: 복제 및 필드 불러오기
onCloneSection?: (sectionId: number) => Promise<void>;
setIsImportFieldDialogOpen?: (open: boolean) => void;
setImportFieldTargetSectionId?: (id: number | null) => void;
}
export function SectionsTab({
@@ -55,9 +62,25 @@ export function SectionsTab({
handleDeleteBOMItemFromTemplate,
ITEM_TYPE_OPTIONS,
INPUT_TYPE_OPTIONS,
unitOptions = [],
hasUnsavedChanges = false,
pendingChanges = { sectionTemplates: [] },
onCloneSection,
setIsImportFieldDialogOpen,
setImportFieldTargetSectionId,
}: SectionsTabProps) {
// 2025-11-27: prop 변경 추적 (디버깅용)
useEffect(() => {
console.log('[SectionsTab] 📥 sectionTemplates prop changed:', {
count: sectionTemplates.length,
sections: sectionTemplates.map(s => ({
id: s.id,
name: s.template_name,
fieldsCount: s.fields?.length || 0
}))
});
}, [sectionTemplates]);
return (
<Card>
<CardHeader>
@@ -95,10 +118,15 @@ export function SectionsTab({
{/* 일반 섹션 탭 */}
<TabsContent value="general">
{(() => {
console.log('Rendering section templates:', {
console.log('[SectionsTab] 🔄 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 }))
templates: sectionTemplates.map(t => ({
id: t.id,
template_name: t.template_name,
section_type: t.section_type,
fieldsCount: t.fields?.length || 0 // 필드 개수 추가
}))
});
return null;
})()}
@@ -139,13 +167,25 @@ export function SectionsTab({
size="sm"
variant="ghost"
onClick={() => handleEditSectionTemplate(template)}
title="수정"
>
<Edit className="h-4 w-4" />
</Button>
{onCloneSection && (
<Button
size="sm"
variant="ghost"
onClick={() => onCloneSection(template.id)}
title="복제"
>
<Copy className="h-4 w-4 text-green-500" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSectionTemplate(template.id)}
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
@@ -157,16 +197,31 @@ export function SectionsTab({
<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 className="flex gap-2">
{setIsImportFieldDialogOpen && setImportFieldTargetSectionId && (
<Button
size="sm"
variant="outline"
onClick={() => {
setImportFieldTargetSectionId(template.id);
setIsImportFieldDialogOpen(true);
}}
>
<Download className="h-4 w-4 mr-2" />
</Button>
)}
<Button
size="sm"
onClick={() => {
setCurrentTemplateId(template.id);
setIsTemplateFieldDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
{(!template.fields || template.fields.length === 0) ? (
@@ -213,6 +268,7 @@ export function SectionsTab({
size="sm"
variant="ghost"
onClick={() => handleEditTemplateField(template.id, field)}
title="수정"
>
<Edit className="h-4 w-4 text-blue-500" />
</Button>
@@ -220,8 +276,9 @@ export function SectionsTab({
size="sm"
variant="ghost"
onClick={() => handleDeleteTemplateField(template.id, field.id)}
title="연결 해제"
>
<Trash2 className="h-4 w-4 text-red-500" />
<Unlink className="h-4 w-4 text-orange-500" />
</Button>
</div>
</div>
@@ -274,13 +331,25 @@ export function SectionsTab({
size="sm"
variant="ghost"
onClick={() => handleEditSectionTemplate(template)}
title="수정"
>
<Edit className="h-4 w-4" />
</Button>
{onCloneSection && (
<Button
size="sm"
variant="ghost"
onClick={() => onCloneSection(template.id)}
title="복제"
>
<Copy className="h-4 w-4 text-green-500" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSectionTemplate(template.id)}
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
@@ -295,6 +364,8 @@ export function SectionsTab({
onAddItem={(item) => handleAddBOMItemToTemplate(template.id, item)}
onUpdateItem={(itemId, item) => handleUpdateBOMItemInTemplate(template.id, itemId, item)}
onDeleteItem={(itemId) => handleDeleteBOMItemFromTemplate(template.id, itemId)}
unitOptions={unitOptions}
itemTypeOptions={ITEM_TYPE_OPTIONS}
/>
</CardContent>
</Card>