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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user