feat: API 프록시 추가 및 품목기준관리 기능 개선

- HttpOnly 쿠키 기반 API 프록시 라우트 추가 (/api/proxy/[...path])
- 품목기준관리 컴포넌트 개선 (섹션, 필드, 다이얼로그)
- ItemMasterContext API 연동 강화
- mock-data 제거 및 실제 API 연동
- 문서 명명규칙 정리 ([TYPE-DATE] 형식)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-25 21:07:10 +09:00
parent 5b2f8adc87
commit 593644922a
37 changed files with 5897 additions and 3267 deletions

View File

@@ -13,6 +13,9 @@ import { toast } from 'sonner';
import type { ItemPage, ItemSection, ItemMasterField } from '@/contexts/ItemMasterContext';
import { ConditionalDisplayUI, type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
// 입력 타입 정의
export type InputType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
// 텍스트박스 칼럼 타입 (단순 구조)
interface OptionColumn {
id: string;
@@ -20,7 +23,7 @@ interface OptionColumn {
key: string;
}
const INPUT_TYPE_OPTIONS = [
const INPUT_TYPE_OPTIONS: Array<{ value: InputType; label: string }> = [
{ value: 'textbox', label: '텍스트박스' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
@@ -56,8 +59,8 @@ interface FieldDialogProps {
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section';
setNewFieldInputType: (type: any) => void;
newFieldInputType: InputType;
setNewFieldInputType: (type: InputType) => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldDescription: string;
@@ -198,21 +201,22 @@ export function FieldDialog({
<div
key={field.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedMasterFieldId === field.id
selectedMasterFieldId === String(field.id)
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => {
setSelectedMasterFieldId(field.id);
setNewFieldName(field.name);
setNewFieldKey(field.fieldKey);
setNewFieldInputType(field.property.inputType);
setNewFieldRequired(field.property.required);
setSelectedMasterFieldId(String(field.id));
setNewFieldName(field.field_name);
setNewFieldKey(field.id.toString());
setNewFieldInputType(field.field_type);
setNewFieldRequired(field.properties?.required || false);
setNewFieldDescription(field.description || '');
setNewFieldOptions(field.property.options?.join(', ') || '');
if (field.property.multiColumn && field.property.columnNames) {
// options는 {label, value}[] 배열이므로 label만 추출
setNewFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
if (field.properties?.multiColumn && field.properties?.columnNames) {
setTextboxColumns(
field.property.columnNames.map((name, idx) => ({
field.properties.columnNames.map((name: string, idx: number) => ({
id: `col-${idx}`,
name,
key: `column${idx + 1}`
@@ -224,28 +228,26 @@ export function FieldDialog({
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{field.name}</span>
<span className="font-medium">{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
</Badge>
{field.property.required && (
{field.properties?.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{field.description && (
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
)}
{Array.isArray(field.category) && field.category.length > 0 && (
{field.category && (
<div className="flex gap-1 mt-1">
{field.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
<Badge variant="secondary" className="text-xs">
{field.category}
</Badge>
</div>
)}
</div>
{selectedMasterFieldId === field.id && (
{selectedMasterFieldId === String(field.id) && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
@@ -280,7 +282,7 @@ export function FieldDialog({
<div>
<Label> *</Label>
<Select value={newFieldInputType} onValueChange={(v: any) => setNewFieldInputType(v)}>
<Select value={newFieldInputType} onValueChange={(v) => setNewFieldInputType(v as InputType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>

View File

@@ -7,10 +7,11 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const ITEM_TYPE_OPTIONS = [
{ value: 'product', label: '제품' },
{ value: 'part', label: '부품' },
{ value: 'material', label: '자재' },
{ value: 'assembly', label: '조립품' },
{ value: 'FG', label: '제품 (FG)' },
{ value: 'PT', label: '부품 (PT)' },
{ value: 'SM', label: '반제품 (SM)' },
{ value: 'RM', label: '원자재 (RM)' },
{ value: 'CS', label: '소모품 (CS)' },
];
interface PageDialogProps {

View File

@@ -5,6 +5,9 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { FileText, Package, Check, X } from 'lucide-react';
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
interface SectionDialogProps {
isSectionDialogOpen: boolean;
@@ -16,6 +19,13 @@ interface SectionDialogProps {
newSectionDescription: string;
setNewSectionDescription: (description: string) => void;
handleAddSection: () => void;
// 템플릿 선택 관련 props
sectionInputMode: 'custom' | 'template';
setSectionInputMode: (mode: 'custom' | 'template') => void;
sectionTemplates: SectionTemplate[];
selectedTemplateId: number | null;
setSelectedTemplateId: (id: number | null) => void;
handleLinkTemplate: (template: SectionTemplate) => void;
}
export function SectionDialog({
@@ -28,59 +38,246 @@ export function SectionDialog({
newSectionDescription,
setNewSectionDescription,
handleAddSection,
sectionInputMode,
setSectionInputMode,
sectionTemplates,
selectedTemplateId,
setSelectedTemplateId,
handleLinkTemplate,
}: SectionDialogProps) {
const handleClose = () => {
setIsSectionDialogOpen(false);
setNewSectionType('fields');
setNewSectionTitle('');
setNewSectionDescription('');
setSectionInputMode('custom');
setSelectedTemplateId(null);
};
// 템플릿 선택 시 폼에 값 채우기
const handleSelectTemplate = (template: SectionTemplate) => {
setSelectedTemplateId(template.id);
setNewSectionTitle(template.template_name);
setNewSectionDescription(template.description || '');
setNewSectionType(template.section_type === 'BOM' ? 'bom' : 'fields');
};
return (
<Dialog open={isSectionDialogOpen} onOpenChange={(open) => {
setIsSectionDialogOpen(open);
if (!open) {
setNewSectionType('fields');
setNewSectionTitle('');
setNewSectionDescription('');
}
if (!open) handleClose();
else setIsSectionDialogOpen(open);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{newSectionType === 'bom' ? 'BOM 섹션' : '일반 섹션'} </DialogTitle>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
<DialogTitle> </DialogTitle>
<DialogDescription>
{newSectionType === 'bom'
? '새로운 BOM(자재명세서) 섹션을 추가합니다'
: '새로운 일반 섹션을 추가합니다'}
릿 .
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newSectionTitle}
onChange={(e) => setNewSectionTitle(e.target.value)}
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 입력 모드 선택 */}
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={sectionInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setSectionInputMode('custom');
setSelectedTemplateId(null);
}}
className="flex-1"
>
</Button>
<Button
variant={sectionInputMode === 'template' ? 'default' : 'ghost'}
size="sm"
onClick={() => setSectionInputMode('template')}
className="flex-1"
>
릿
</Button>
</div>
<div>
<Label> ()</Label>
<Textarea
value={newSectionDescription}
onChange={(e) => setNewSectionDescription(e.target.value)}
placeholder="섹션에 대한 설명"
/>
</div>
{newSectionType === 'bom' && (
<div className="bg-blue-50 p-3 rounded-md">
<p className="text-sm text-blue-700">
<strong>BOM :</strong> (BOM) .
, , .
</p>
{/* 템플릿 목록 */}
{sectionInputMode === 'template' && (
<div className="border rounded p-3 space-y-2 max-h-[300px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> 릿 </Label>
</div>
{sectionTemplates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
릿 .<br/>
릿 .
</p>
) : (
<div className="space-y-2">
{sectionTemplates.map(template => (
<div
key={template.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedTemplateId === template.id
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => handleSelectTemplate(template)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
{template.section_type === 'BOM' ? (
<Package className="h-4 w-4 text-orange-500" />
) : (
<FileText className="h-4 w-4 text-blue-500" />
)}
<span className="font-medium">{template.template_name}</span>
<Badge variant="outline" className="text-xs">
{template.section_type === 'BOM' ? '모듈(BOM)' : '일반'}
</Badge>
</div>
{template.description && (
<p className="text-xs text-muted-foreground mt-1">{template.description}</p>
)}
{template.fields && template.fields.length > 0 && (
<p className="text-xs text-blue-600 mt-1">
{template.fields.length}
</p>
)}
</div>
{selectedTemplateId === template.id && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 또는 선택된 템플릿 정보 표시 */}
{(sectionInputMode === 'custom' || selectedTemplateId) && (
<>
{/* 섹션 유형 선택 - 템플릿 선택 시 비활성화 */}
<div>
<Label className="mb-3 block"> *</Label>
<div className="grid grid-cols-2 gap-3">
{/* 일반 섹션 */}
<div
onClick={() => {
if (sectionInputMode === 'custom') setNewSectionType('fields');
}}
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
} ${
newSectionType === 'fields'
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<FileText className={`h-5 w-5 ${newSectionType === 'fields' ? 'text-blue-600' : 'text-gray-400'}`} />
<div className="flex-1">
<div className={`font-medium ${newSectionType === 'fields' ? 'text-blue-900' : 'text-gray-900'}`}>
</div>
<div className="text-xs text-gray-500"> </div>
</div>
{newSectionType === 'fields' && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
{/* BOM 섹션 */}
<div
onClick={() => {
if (sectionInputMode === 'custom') setNewSectionType('bom');
}}
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
} ${
newSectionType === 'bom'
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Package className={`h-5 w-5 ${newSectionType === 'bom' ? 'text-blue-600' : 'text-gray-400'}`} />
<div className="flex-1">
<div className={`font-medium ${newSectionType === 'bom' ? 'text-blue-900' : 'text-gray-900'}`}>
(BOM)
</div>
<div className="text-xs text-gray-500"> </div>
</div>
{newSectionType === 'bom' && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
</div>
<div>
<Label> *</Label>
<Input
value={newSectionTitle}
onChange={(e) => setNewSectionTitle(e.target.value)}
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
disabled={sectionInputMode === 'template'}
/>
</div>
<div>
<Label> ()</Label>
<Textarea
value={newSectionDescription}
onChange={(e) => setNewSectionDescription(e.target.value)}
placeholder="섹션에 대한 설명"
disabled={sectionInputMode === 'template'}
/>
</div>
{sectionInputMode === 'template' && selectedTemplateId && (
<div className="bg-green-50 p-3 rounded-md border border-green-200">
<p className="text-sm text-green-700">
<strong>릿 :</strong> 릿 .
릿 .
</p>
</div>
)}
{newSectionType === 'bom' && sectionInputMode === 'custom' && (
<div className="bg-blue-50 p-3 rounded-md">
<p className="text-sm text-blue-700">
<strong>BOM :</strong> (BOM) .
, , .
</p>
</div>
)}
</>
)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => {
setIsSectionDialogOpen(false);
setNewSectionType('fields');
}} className="w-full sm:w-auto"></Button>
<Button onClick={handleAddSection} className="w-full sm:w-auto"></Button>
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={handleClose} className="w-full sm:w-auto">
</Button>
{sectionInputMode === 'template' && selectedTemplateId ? (
<Button
onClick={() => {
const template = sectionTemplates.find(t => t.id === selectedTemplateId);
if (template) handleLinkTemplate(template);
}}
className="w-full sm:w-auto"
>
릿
</Button>
) : (
<Button
onClick={handleAddSection}
className="w-full sm:w-auto"
disabled={sectionInputMode === 'template' && !selectedTemplateId}
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -7,6 +7,9 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Check, X } from 'lucide-react';
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트 입력' },
@@ -41,6 +44,14 @@ interface TemplateFieldDialogProps {
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: (names: string[]) => void;
handleAddTemplateField: () => void;
// 마스터 항목 관련 props
itemMasterFields?: ItemMasterField[];
templateFieldInputMode?: 'custom' | 'master';
setTemplateFieldInputMode?: (mode: 'custom' | 'master') => void;
showMasterFieldList?: boolean;
setShowMasterFieldList?: (show: boolean) => void;
selectedMasterFieldId?: string;
setSelectedMasterFieldId?: (id: string) => void;
}
export function TemplateFieldDialog({
@@ -67,31 +78,151 @@ export function TemplateFieldDialog({
templateFieldColumnNames,
setTemplateFieldColumnNames,
handleAddTemplateField,
// 마스터 항목 관련 props (optional)
itemMasterFields = [],
templateFieldInputMode = 'custom',
setTemplateFieldInputMode,
showMasterFieldList = false,
setShowMasterFieldList,
selectedMasterFieldId = '',
setSelectedMasterFieldId,
}: TemplateFieldDialogProps) {
const handleClose = () => {
setIsTemplateFieldDialogOpen(false);
setEditingTemplateFieldId(null);
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
setTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
// 마스터 항목 관련 상태 초기화
setTemplateFieldInputMode?.('custom');
setShowMasterFieldList?.(false);
setSelectedMasterFieldId?.('');
};
const handleSelectMasterField = (field: ItemMasterField) => {
setSelectedMasterFieldId?.(String(field.id));
setTemplateFieldName(field.field_name);
setTemplateFieldKey(field.id.toString());
setTemplateFieldInputType(field.field_type);
setTemplateFieldRequired(field.properties?.required || false);
setTemplateFieldDescription(field.description || '');
// options는 {label, value}[] 배열이므로 label만 추출
setTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
if (field.properties?.multiColumn && field.properties?.columnNames) {
setTemplateFieldMultiColumn(true);
setTemplateFieldColumnCount(field.properties.columnNames.length);
setTemplateFieldColumnNames(field.properties.columnNames);
} else {
setTemplateFieldMultiColumn(false);
}
};
return (
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={(open) => {
setIsTemplateFieldDialogOpen(open);
if (!open) {
setEditingTemplateFieldId(null);
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
setTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
}
}}>
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingTemplateFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
{!editingTemplateFieldId && setTemplateFieldInputMode && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={templateFieldInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => setTemplateFieldInputMode('custom')}
className="flex-1"
>
</Button>
<Button
variant={templateFieldInputMode === 'master' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setTemplateFieldInputMode('master');
setShowMasterFieldList?.(true);
}}
className="flex-1"
>
</Button>
</div>
)}
{/* 마스터 항목 목록 */}
{templateFieldInputMode === 'master' && !editingTemplateFieldId && showMasterFieldList && (
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> </Label>
<Button
variant="ghost"
size="sm"
onClick={() => setShowMasterFieldList?.(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{itemMasterFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
<div className="space-y-2">
{itemMasterFields.map(field => (
<div
key={field.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedMasterFieldId === String(field.id)
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => handleSelectMasterField(field)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
</Badge>
{field.properties?.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{field.description && (
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
)}
{field.category && (
<div className="flex gap-1 mt-1">
<Badge variant="secondary" className="text-xs">
{field.category}
</Badge>
</div>
)}
</div>
{selectedMasterFieldId === String(field.id) && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 */}
{(templateFieldInputMode === 'custom' || editingTemplateFieldId || !setTemplateFieldInputMode) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
@@ -199,6 +330,8 @@ export function TemplateFieldDialog({
<Switch checked={templateFieldRequired} onCheckedChange={setTemplateFieldRequired} />
<Label> </Label>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTemplateFieldDialogOpen(false)}></Button>