feat: 품목관리 동적 렌더링 시스템 구현

- DynamicItemForm 컴포넌트 구조 생성
  - DynamicField: 필드 타입별 렌더링
  - DynamicSection: 섹션 단위 렌더링
  - DynamicFormRenderer: 페이지 전체 렌더링
- 필드 타입별 컴포넌트 (TextField, NumberField, DropdownField, CheckboxField, DateField, FileField, CustomField)
- 커스텀 훅 (useDynamicFormState, useFormStructure, useConditionalFields)
- DataTable 공통 컴포넌트 (테이블, 페이지네이션, 검색, 탭필터, 통계카드)
- ItemFormWrapper: Feature Flag 기반 폼 선택
- 타입 정의 및 문서화

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-28 20:14:43 +09:00
parent 8fd9cf2d40
commit 6ed5d4ffb3
28 changed files with 5359 additions and 2 deletions

View File

@@ -0,0 +1,63 @@
/**
* DynamicField Component
*
* 필드 타입에 따라 적절한 필드 컴포넌트를 렌더링
*/
'use client';
import type { DynamicFieldProps, FieldType } from './types';
import {
TextField,
DropdownField,
NumberField,
DateField,
CheckboxField,
FileField,
CustomField,
} from './fields';
/**
* 필드 타입 → 컴포넌트 매핑
*/
const FIELD_COMPONENTS: Record<
FieldType,
React.ComponentType<DynamicFieldProps>
> = {
textbox: TextField,
textarea: TextField,
dropdown: DropdownField,
'searchable-dropdown': DropdownField,
number: NumberField,
currency: NumberField,
date: DateField,
'date-range': DateField,
checkbox: CheckboxField,
switch: CheckboxField,
file: FileField,
'custom:drawing-canvas': CustomField,
'custom:bending-detail-table': CustomField,
'custom:bom-table': CustomField,
};
export function DynamicField(props: DynamicFieldProps) {
const { field } = props;
// 필드 타입에 맞는 컴포넌트 선택
const FieldComponent = FIELD_COMPONENTS[field.field_type];
if (!FieldComponent) {
console.warn(`[DynamicField] Unknown field type: ${field.field_type}`);
return (
<div className="p-4 border border-red-200 bg-red-50 rounded-md">
<p className="text-sm text-red-600">
: {field.field_type}
</p>
</div>
);
}
return <FieldComponent {...props} />;
}
export default DynamicField;

View File

@@ -0,0 +1,75 @@
/**
* DynamicFormRenderer Component
*
* 전체 폼 구조를 렌더링하는 메인 렌더러
* - 섹션 순서대로 렌더링
* - 조건부 섹션/필드 처리
*/
'use client';
import { DynamicSection } from './DynamicSection';
import { useConditionalFields } from './hooks/useConditionalFields';
import type { DynamicFormRendererProps, DynamicSection as DynamicSectionType } from './types';
export function DynamicFormRenderer({
sections,
conditionalSections,
conditionalFields,
values,
errors,
onChange,
onBlur,
disabled,
}: DynamicFormRendererProps) {
// 조건부 표시 로직
const { isSectionVisible, isFieldVisible } = useConditionalFields({
sections,
conditionalSections,
conditionalFields,
values,
});
// 섹션 순서대로 정렬
const sortedSections = [...sections].sort((a, b) => a.order_no - b.order_no);
// 표시할 섹션만 필터링
const visibleSections = sortedSections.filter((section) =>
isSectionVisible(section.id)
);
// 각 섹션의 표시할 필드만 필터링
const sectionsWithVisibleFields: DynamicSectionType[] = visibleSections.map((section) => ({
...section,
fields: section.fields.filter((field) =>
isFieldVisible(section.id, field.id)
),
}));
if (sectionsWithVisibleFields.length === 0) {
return (
<div className="p-8 text-center text-gray-500 border-2 border-dashed rounded-lg">
<p> .</p>
<p className="text-sm mt-1"> .</p>
</div>
);
}
return (
<div className="space-y-4">
{sectionsWithVisibleFields.map((section) => (
<DynamicSection
key={section.id}
section={section}
values={values}
errors={errors}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
/>
))}
</div>
);
}
export default DynamicFormRenderer;

View File

@@ -0,0 +1,162 @@
/**
* DynamicSection Component
*
* 동적 섹션 렌더링 (Card + 접기/펼치기 + 필드 그리드)
*/
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { DynamicField } from './DynamicField';
import type { DynamicSectionProps, DynamicField as DynamicFieldType } from './types';
import { cn } from '@/lib/utils';
/**
* 필드를 그리드 레이아웃으로 정렬
*/
function organizeFieldsIntoGrid(fields: DynamicFieldType[]): DynamicFieldType[][] {
if (fields.length === 0) return [];
// 정렬: order_no → grid_row → grid_col
const sortedFields = [...fields].sort((a, b) => {
if (a.order_no !== b.order_no) return a.order_no - b.order_no;
if ((a.grid_row || 1) !== (b.grid_row || 1)) return (a.grid_row || 1) - (b.grid_row || 1);
return (a.grid_col || 1) - (b.grid_col || 1);
});
// 행별로 그룹화
const rows: Map<number, DynamicFieldType[]> = new Map();
for (const field of sortedFields) {
const row = field.grid_row || 1;
if (!rows.has(row)) {
rows.set(row, []);
}
rows.get(row)!.push(field);
}
// 배열로 변환 (행 번호 순서대로)
return Array.from(rows.entries())
.sort(([a], [b]) => a - b)
.map(([, fields]) => fields);
}
/**
* grid_span을 Tailwind 클래스로 변환
*/
function getGridSpanClass(span: number = 1): string {
const spanClasses: Record<number, string> = {
1: 'col-span-1',
2: 'col-span-2',
3: 'col-span-3',
4: 'col-span-4',
};
return spanClasses[span] || 'col-span-1';
}
export function DynamicSection({
section,
values,
errors,
onChange,
onBlur,
disabled,
}: DynamicSectionProps) {
const [isOpen, setIsOpen] = useState(section.is_default_open);
// BOM 섹션은 별도 처리
const isBomSection = section.section_type === 'BOM';
// 필드를 그리드로 정렬
const fieldRows = organizeFieldsIntoGrid(section.fields);
const handleToggle = () => {
if (section.is_collapsible) {
setIsOpen(!isOpen);
}
};
return (
<Card className="mb-4">
<CardHeader
className={cn(
'py-4',
section.is_collapsible && 'cursor-pointer hover:bg-gray-50 transition-colors'
)}
onClick={handleToggle}
>
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
{section.is_collapsible && (
<span className="text-gray-400">
{isOpen ? (
<ChevronDown className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
)}
</span>
)}
{section.title}
</CardTitle>
{section.description && (
<p className="text-sm text-muted-foreground">{section.description}</p>
)}
</div>
</CardHeader>
{(isOpen || !section.is_collapsible) && (
<CardContent className="pt-0">
{isBomSection ? (
// BOM 섹션: 별도 컴포넌트로 처리
<div className="p-4 border-2 border-dashed border-gray-300 rounded-lg text-center bg-gray-50">
<p className="text-gray-500 font-medium"> (BOM)</p>
<p className="text-sm text-gray-400 mt-1">
BOMSection
</p>
</div>
) : (
// 일반 섹션: 필드 그리드 렌더링
<div className="space-y-4">
{fieldRows.map((rowFields, rowIndex) => (
<div
key={`row-${rowIndex}`}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
>
{rowFields.map((field) => (
<div
key={field.id}
className={cn(
getGridSpanClass(field.grid_span),
// 모바일에서는 항상 전체 너비
'col-span-1 md:col-span-1',
// md 이상에서 grid_span 적용
field.grid_span && field.grid_span >= 2 && 'md:col-span-2',
field.grid_span && field.grid_span >= 3 && 'lg:col-span-3',
field.grid_span && field.grid_span >= 4 && 'lg:col-span-4'
)}
>
<DynamicField
field={field}
value={values[field.field_key]}
error={errors[field.field_key]}
onChange={(value) => onChange(field.field_key, value)}
onBlur={() => onBlur(field.field_key)}
disabled={disabled}
/>
</div>
))}
</div>
))}
</div>
)}
</CardContent>
)}
</Card>
);
}
export default DynamicSection;

View File

@@ -0,0 +1,91 @@
/**
* CheckboxField Component
*
* 체크박스/스위치 필드 (checkbox, switch)
*/
'use client';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
export function CheckboxField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const isSwitch = field.field_type === 'switch';
const checked = value === true || value === 'true' || value === 1;
const handleChange = (newChecked: boolean) => {
onChange(newChecked);
onBlur();
};
if (isSwitch) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
<Switch
id={field.field_key}
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || field.is_readonly}
/>
</div>
{field.help_text && !error && (
<p className="text-xs text-muted-foreground">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={field.field_key}
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || field.is_readonly}
/>
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium cursor-pointer',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500",
(disabled || field.is_readonly) && 'cursor-not-allowed opacity-50'
)}
>
{field.field_name}
</Label>
</div>
{field.help_text && !error && (
<p className="text-xs text-muted-foreground ml-6">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500 ml-6">{error}</p>}
</div>
);
}
export default CheckboxField;

View File

@@ -0,0 +1,479 @@
/**
* CustomField Component
*
* 특수 필드 컴포넌트 래퍼 (전개도, BOM 등)
* - custom:drawing-canvas → DrawingCanvas
* - custom:bending-detail-table → BendingDetailTable (전개도 상세 테이블)
* - custom:bom-table → BOMSection
*
* 기존 ItemForm의 특수 컴포넌트를 재사용하면서 동적 폼과 통합
*/
'use client';
import { useState, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FileImage, Plus, Trash2 } from 'lucide-react';
import type { DynamicFieldProps, FormValue } from '../types';
import type { BendingDetail, BOMLine } from '@/types/item';
import { cn } from '@/lib/utils';
// ===== BOM 테이블 컴포넌트 =====
interface BOMTableProps {
value: BOMLine[];
onChange: (lines: BOMLine[]) => void;
disabled?: boolean;
}
function BOMTable({ value, onChange, disabled }: BOMTableProps) {
const bomLines = Array.isArray(value) ? value : [];
const addLine = () => {
const newLine: BOMLine = {
id: `bom-${Date.now()}`,
childItemCode: '',
childItemName: '',
quantity: 1,
unit: 'EA',
};
onChange([...bomLines, newLine]);
};
const updateLine = (index: number, field: keyof BOMLine, fieldValue: string | number) => {
const updated = [...bomLines];
updated[index] = { ...updated[index], [field]: fieldValue };
onChange(updated);
};
const removeLine = (index: number) => {
onChange(bomLines.filter((_, i) => i !== index));
};
return (
<Card>
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm"> (BOM)</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={addLine}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
{bomLines.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
<p> BOM .</p>
<p className="text-sm mt-1"> .</p>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-2">
<div className="col-span-3"></div>
<div className="col-span-4"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
</div>
{bomLines.map((line, index) => (
<div key={line.id} className="grid grid-cols-12 gap-2 items-center">
<Input
className="col-span-3 h-8 text-sm"
placeholder="품목코드"
value={line.childItemCode}
onChange={(e) => updateLine(index, 'childItemCode', e.target.value)}
disabled={disabled}
/>
<Input
className="col-span-4 h-8 text-sm"
placeholder="품목명"
value={line.childItemName}
onChange={(e) => updateLine(index, 'childItemName', e.target.value)}
disabled={disabled}
/>
<Input
className="col-span-2 h-8 text-sm text-right"
type="number"
min={1}
value={line.quantity}
onChange={(e) => updateLine(index, 'quantity', parseInt(e.target.value) || 1)}
disabled={disabled}
/>
<Input
className="col-span-2 h-8 text-sm"
value={line.unit}
onChange={(e) => updateLine(index, 'unit', e.target.value)}
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="col-span-1 h-8 w-8 p-0"
onClick={() => removeLine(index)}
disabled={disabled}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ===== 전개도 상세 테이블 컴포넌트 =====
interface BendingDetailTableProps {
value: BendingDetail[];
onChange: (details: BendingDetail[]) => void;
disabled?: boolean;
}
function BendingDetailTable({ value, onChange, disabled }: BendingDetailTableProps) {
const details = Array.isArray(value) ? value : [];
// 폭 합계 계산
const totalWidth = details.reduce((acc, d) => acc + d.input + d.elongation, 0);
const addRow = () => {
const newRow: BendingDetail = {
id: `bend-${Date.now()}`,
no: details.length + 1,
input: 0,
elongation: -1,
calculated: 0,
sum: 0,
shaded: false,
};
onChange([...details, newRow]);
};
const updateRow = (index: number, field: keyof BendingDetail, fieldValue: number | boolean) => {
const updated = [...details];
updated[index] = { ...updated[index], [field]: fieldValue };
// calculated와 sum 자동 계산
if (field === 'input' || field === 'elongation') {
const input = field === 'input' ? (fieldValue as number) : updated[index].input;
const elongation = field === 'elongation' ? (fieldValue as number) : updated[index].elongation;
updated[index].calculated = input + elongation;
// 누적 합계 재계산
let runningSum = 0;
for (let i = 0; i <= index; i++) {
runningSum += updated[i].input + updated[i].elongation;
updated[i].sum = runningSum;
}
}
onChange(updated);
};
const removeRow = (index: number) => {
const updated = details.filter((_, i) => i !== index);
// 번호 재정렬
updated.forEach((row, i) => {
row.no = i + 1;
});
onChange(updated);
};
return (
<Card>
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<FileImage className="h-4 w-4" />
</CardTitle>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
: <strong className="text-foreground">{totalWidth.toFixed(1)} mm</strong>
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{details.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
<p> .</p>
<p className="text-sm mt-1"> .</p>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-2">
<div className="col-span-1">No</div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
</div>
{details.map((row, index) => (
<div key={row.id} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-1 text-sm text-center text-muted-foreground">
{row.no}
</div>
<Input
className="col-span-2 h-8 text-sm text-right"
type="number"
step="0.1"
value={row.input}
onChange={(e) => updateRow(index, 'input', parseFloat(e.target.value) || 0)}
disabled={disabled}
/>
<Input
className="col-span-2 h-8 text-sm text-right"
type="number"
step="0.1"
value={row.elongation}
onChange={(e) => updateRow(index, 'elongation', parseFloat(e.target.value) || 0)}
disabled={disabled}
/>
<div className="col-span-2 text-sm text-right pr-3 text-muted-foreground">
{(row.input + row.elongation).toFixed(1)}
</div>
<div className="col-span-2 text-sm text-right pr-3 font-medium">
{row.sum.toFixed(1)}
</div>
<div className="col-span-2 flex justify-center">
<input
type="checkbox"
checked={row.shaded}
onChange={(e) => updateRow(index, 'shaded', e.target.checked)}
disabled={disabled}
className="h-4 w-4"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="col-span-1 h-8 w-8 p-0"
onClick={() => removeRow(index)}
disabled={disabled}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// ===== 전개도 캔버스 (간단 버전) =====
interface DrawingCanvasProps {
value: string | null;
onChange: (dataUrl: string | null) => void;
disabled?: boolean;
}
function DrawingCanvasSimple({ value, onChange, disabled }: DrawingCanvasProps) {
const [inputMethod, setInputMethod] = useState<'file' | 'drawing'>('file');
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
onChange(event.target?.result as string);
};
reader.readAsDataURL(file);
};
const handleClear = () => {
onChange(null);
};
return (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2">
<FileImage className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 입력 방식 선택 */}
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="drawingMethod"
checked={inputMethod === 'file'}
onChange={() => setInputMethod('file')}
disabled={disabled}
/>
<span className="text-sm"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="drawingMethod"
checked={inputMethod === 'drawing'}
onChange={() => setInputMethod('drawing')}
disabled={disabled}
/>
<span className="text-sm"> ( )</span>
</label>
</div>
{/* 파일 업로드 */}
{inputMethod === 'file' && (
<div>
{value ? (
<div className="relative">
<img
src={value}
alt="전개도"
className="max-w-full h-auto border rounded-lg"
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2"
onClick={handleClear}
disabled={disabled}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={disabled}
className="hidden"
id="drawing-file-input"
/>
<label
htmlFor="drawing-file-input"
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<FileImage className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p> </p>
<p className="text-xs mt-1">PNG, JPG, GIF </p>
</label>
</div>
)}
</div>
)}
{/* 직접 그리기 (플레이스홀더) */}
{inputMethod === 'drawing' && (
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
<p> .</p>
<p className="text-sm mt-1"> DrawingCanvas </p>
</div>
)}
</CardContent>
</Card>
);
}
// ===== 메인 CustomField 컴포넌트 =====
export function CustomField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const renderCustomComponent = () => {
switch (field.field_type) {
case 'custom:drawing-canvas':
return (
<DrawingCanvasSimple
value={value as string | null}
onChange={(dataUrl) => {
onChange(dataUrl);
onBlur();
}}
disabled={disabled}
/>
);
case 'custom:bending-detail-table':
return (
<BendingDetailTable
value={value as BendingDetail[] || []}
onChange={(details) => {
onChange(details as unknown as FormValue);
onBlur();
}}
disabled={disabled}
/>
);
case 'custom:bom-table':
return (
<BOMTable
value={value as BOMLine[] || []}
onChange={(lines) => {
onChange(lines as unknown as FormValue);
onBlur();
}}
disabled={disabled}
/>
);
default:
return (
<div className="p-4 border border-orange-200 bg-orange-50 rounded-md">
<p className="text-sm text-orange-600">
: {field.field_type}
</p>
</div>
);
}
};
// 커스텀 필드는 자체 레이블이 있으므로 별도 레이블 불필요
return (
<div className="space-y-2">
{renderCustomComponent()}
{field.help_text && !error && (
<p className="text-xs text-muted-foreground">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
export default CustomField;

View File

@@ -0,0 +1,100 @@
/**
* DateField Component
*
* 날짜 선택 필드 (date, date-range)
*/
'use client';
import { useState } from 'react';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
export function DateField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const [open, setOpen] = useState(false);
// 값을 Date 객체로 변환
const dateValue = value ? new Date(value as string) : undefined;
const isValidDate = dateValue && !isNaN(dateValue.getTime());
const handleSelect = (date: Date | undefined) => {
if (date) {
// ISO 문자열로 변환 (YYYY-MM-DD)
onChange(format(date, 'yyyy-MM-dd'));
} else {
onChange(null);
}
setOpen(false);
onBlur();
};
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={field.field_key}
variant="outline"
disabled={disabled || field.is_readonly}
className={cn(
'w-full justify-start text-left font-normal',
!isValidDate && 'text-muted-foreground',
error && 'border-red-500 focus:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{isValidDate
? format(dateValue, 'yyyy년 MM월 dd일', { locale: ko })
: field.placeholder || '날짜를 선택하세요'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={isValidDate ? dateValue : undefined}
onSelect={handleSelect}
locale={ko}
initialFocus
/>
</PopoverContent>
</Popover>
{field.help_text && !error && (
<p className="text-xs text-muted-foreground">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
export default DateField;

View File

@@ -0,0 +1,118 @@
/**
* DropdownField Component
*
* 드롭다운/선택 필드 (dropdown, searchable-dropdown)
*/
'use client';
import { useState, useEffect } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps, DropdownOption } from '../types';
import { cn } from '@/lib/utils';
export function DropdownField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const [options, setOptions] = useState<DropdownOption[]>(
field.dropdown_config?.options || []
);
const [isLoading, setIsLoading] = useState(false);
// API에서 옵션 로드 (options_endpoint가 있는 경우)
useEffect(() => {
if (field.dropdown_config?.options_endpoint) {
setIsLoading(true);
fetch(field.dropdown_config.options_endpoint)
.then((res) => res.json())
.then((data) => {
if (data.success && Array.isArray(data.data)) {
setOptions(data.data);
}
})
.catch((err) => {
console.error('[DropdownField] Failed to load options:', err);
})
.finally(() => {
setIsLoading(false);
});
}
}, [field.dropdown_config?.options_endpoint]);
const displayValue = value === null || value === undefined ? '' : String(value);
const handleValueChange = (newValue: string) => {
onChange(newValue);
onBlur();
};
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
<Select
value={displayValue}
onValueChange={handleValueChange}
disabled={disabled || field.is_readonly || isLoading}
>
<SelectTrigger
id={field.field_key}
className={cn(
error && 'border-red-500 focus:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500'
)}
>
<SelectValue
placeholder={
isLoading
? '로딩 중...'
: field.dropdown_config?.placeholder || '선택하세요'
}
/>
</SelectTrigger>
<SelectContent>
{field.dropdown_config?.allow_empty && (
<SelectItem value=""> </SelectItem>
)}
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{field.help_text && !error && (
<p className="text-xs text-muted-foreground">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
export default DropdownField;

View File

@@ -0,0 +1,203 @@
/**
* FileField Component
*
* 파일 업로드 필드
*/
'use client';
import { useRef, useState } from 'react';
import { Upload, X, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
/**
* 파일 크기 포맷팅
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export function FileField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
const fileConfig = field.file_config || {};
const accept = fileConfig.accept || '*';
const maxSize = fileConfig.max_size || 10 * 1024 * 1024; // 기본 10MB
const multiple = fileConfig.multiple || false;
// 현재 파일(들)
const files: File[] = Array.isArray(value)
? value
: value instanceof File
? [value]
: [];
const handleFileSelect = (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) return;
const validFiles: File[] = [];
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
// 파일 크기 검사
if (file.size > maxSize) {
alert(`"${file.name}" 파일이 너무 큽니다. 최대 ${formatFileSize(maxSize)}까지 업로드 가능합니다.`);
continue;
}
validFiles.push(file);
}
if (validFiles.length === 0) return;
if (multiple) {
onChange([...files, ...validFiles]);
} else {
onChange(validFiles[0]);
}
onBlur();
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleFileSelect(e.target.files);
// 같은 파일 재선택 허용을 위해 리셋
e.target.value = '';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (disabled || field.is_readonly) return;
handleFileSelect(e.dataTransfer.files);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (disabled || field.is_readonly) return;
setDragOver(true);
};
const handleDragLeave = () => {
setDragOver(false);
};
const handleRemoveFile = (index: number) => {
if (multiple) {
const newFiles = files.filter((_, i) => i !== index);
onChange(newFiles.length > 0 ? newFiles : null);
} else {
onChange(null);
}
onBlur();
};
const handleClick = () => {
if (disabled || field.is_readonly) return;
inputRef.current?.click();
};
return (
<div className="space-y-2">
<Label
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
{/* 드래그 앤 드롭 영역 */}
<div
onClick={handleClick}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
'border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors',
dragOver && 'border-primary bg-primary/5',
error && 'border-red-500',
(disabled || field.is_readonly) && 'opacity-50 cursor-not-allowed bg-gray-50',
!dragOver && !error && 'border-gray-300 hover:border-gray-400'
)}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleInputChange}
disabled={disabled || field.is_readonly}
className="hidden"
/>
<Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-600">
</p>
<p className="text-xs text-gray-400 mt-1">
{accept !== '*' ? `허용 형식: ${accept}` : '모든 형식 허용'} |
{formatFileSize(maxSize)}
</p>
</div>
{/* 선택된 파일 목록 */}
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="h-4 w-4 text-gray-500 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
</div>
</div>
{!disabled && !field.is_readonly && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
{field.help_text && !error && (
<p className="text-xs text-muted-foreground">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
export default FileField;

View File

@@ -0,0 +1,122 @@
/**
* NumberField Component
*
* 숫자 입력 필드 (number, currency)
*/
'use client';
import { useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
/**
* 숫자 포맷팅 (천단위 콤마)
*/
function formatNumber(value: number | string, isCurrency: boolean): string {
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return '';
if (isCurrency) {
return num.toLocaleString('ko-KR');
}
return num.toString();
}
/**
* 포맷팅된 문자열을 숫자로 변환
*/
function parseFormattedNumber(value: string): number {
// 콤마 제거 후 숫자로 변환
const cleaned = value.replace(/,/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? 0 : num;
}
export function NumberField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const isCurrency = field.field_type === 'currency';
const numValue = typeof value === 'number' ? value : parseFormattedNumber(String(value || '0'));
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// 빈 값 허용
if (inputValue === '' || inputValue === '-') {
onChange(0);
return;
}
// 숫자와 콤마, 소수점, 마이너스만 허용
const cleaned = inputValue.replace(/[^\d.,-]/g, '');
const num = parseFormattedNumber(cleaned);
// 유효성 검사
const { min, max } = field.validation_rules || {};
if (min !== undefined && num < min) return;
if (max !== undefined && num > max) return;
onChange(num);
},
[onChange, field.validation_rules]
);
// 표시용 값 (통화면 포맷팅)
const displayValue = formatNumber(numValue, isCurrency);
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
<div className="relative">
{isCurrency && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
</span>
)}
<Input
id={field.field_key}
name={field.field_key}
type="text"
inputMode="numeric"
value={displayValue}
onChange={handleChange}
onBlur={onBlur}
placeholder={field.placeholder || '0'}
disabled={disabled || field.is_readonly}
className={cn(
isCurrency && 'pl-8',
error && 'border-red-500 focus-visible:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500',
'text-right'
)}
/>
</div>
{field.help_text && !error && (
<p className="text-xs text-muted-foreground">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
export default NumberField;

View File

@@ -0,0 +1,83 @@
/**
* TextField Component
*
* 텍스트 입력 필드 (textbox, textarea)
*/
'use client';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps } from '../types';
import { cn } from '@/lib/utils';
export function TextField({
field,
value,
error,
onChange,
onBlur,
disabled,
}: DynamicFieldProps) {
const isTextarea = field.field_type === 'textarea';
const displayValue = value === null || value === undefined ? '' : String(value);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
onChange(e.target.value);
};
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
{field.field_name}
</Label>
{isTextarea ? (
<Textarea
id={field.field_key}
name={field.field_key}
value={displayValue}
onChange={handleChange}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={disabled || field.is_readonly}
className={cn(
'min-h-[100px]',
error && 'border-red-500 focus-visible:ring-red-500'
)}
/>
) : (
<Input
id={field.field_key}
name={field.field_key}
type="text"
value={displayValue}
onChange={handleChange}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={disabled || field.is_readonly}
maxLength={field.validation_rules?.maxLength}
className={cn(
error && 'border-red-500 focus-visible:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500'
)}
/>
)}
{field.help_text && !error && (
<p className="text-xs text-muted-foreground">{field.help_text}</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
export default TextField;

View File

@@ -0,0 +1,11 @@
/**
* 동적 필드 컴포넌트 인덱스
*/
export { TextField } from './TextField';
export { DropdownField } from './DropdownField';
export { NumberField } from './NumberField';
export { DateField } from './DateField';
export { CheckboxField } from './CheckboxField';
export { FileField } from './FileField';
export { CustomField } from './CustomField';

View File

@@ -0,0 +1,7 @@
/**
* 동적 폼 훅 인덱스
*/
export { useFormStructure, clearFormStructureCache, invalidateFormStructureCache } from './useFormStructure';
export { useConditionalFields } from './useConditionalFields';
export { useDynamicFormState } from './useDynamicFormState';

View File

@@ -0,0 +1,256 @@
/**
* useConditionalFields Hook
*
* 조건부 섹션/필드 표시 로직을 처리하는 훅
* - 필드 값에 따른 섹션 표시/숨김
* - 필드 값에 따른 필드 표시/숨김
* - 조건 평가 (equals, in, not_equals 등)
*/
'use client';
import { useMemo } from 'react';
import type {
ConditionalSection,
ConditionalField,
DynamicSection,
Condition,
FormData,
UseConditionalFieldsReturn,
} from '../types';
/**
* 단일 조건 평가
*/
function evaluateCondition(condition: Condition, values: FormData): boolean {
const fieldValue = values[condition.field_key];
switch (condition.operator) {
case 'equals':
return fieldValue === condition.value;
case 'not_equals':
return fieldValue !== condition.value;
case 'in':
if (Array.isArray(condition.value)) {
return condition.value.includes(fieldValue as string);
}
return false;
case 'not_in':
if (Array.isArray(condition.value)) {
return !condition.value.includes(fieldValue as string);
}
return true;
case 'contains':
if (typeof fieldValue === 'string' && typeof condition.value === 'string') {
return fieldValue.includes(condition.value);
}
return false;
case 'greater_than':
if (typeof fieldValue === 'number' && typeof condition.value === 'number') {
return fieldValue > condition.value;
}
return false;
case 'less_than':
if (typeof fieldValue === 'number' && typeof condition.value === 'number') {
return fieldValue < condition.value;
}
return false;
default:
return true;
}
}
/**
* 섹션의 display_condition 평가
*/
function isSectionConditionMet(section: DynamicSection, values: FormData): boolean {
// display_condition이 없으면 항상 표시
if (!section.display_condition) {
return true;
}
return evaluateCondition(section.display_condition, values);
}
/**
* 조건부 섹션 규칙에 따른 표시 여부 결정
*/
function evaluateConditionalSections(
sections: DynamicSection[],
conditionalSections: ConditionalSection[],
values: FormData
): Set<number> {
const visibleSectionIds = new Set<number>();
// 1. 기본적으로 모든 섹션의 display_condition 평가
for (const section of sections) {
if (isSectionConditionMet(section, values)) {
visibleSectionIds.add(section.id);
}
}
// 2. conditionalSections 규칙 적용
for (const rule of conditionalSections) {
const conditionMet = evaluateCondition(rule.condition, values);
if (conditionMet) {
// 조건 충족 시 show_sections 표시
for (const sectionId of rule.show_sections) {
visibleSectionIds.add(sectionId);
}
// hide_sections가 있으면 숨김
if (rule.hide_sections) {
for (const sectionId of rule.hide_sections) {
visibleSectionIds.delete(sectionId);
}
}
} else {
// 조건 미충족 시 show_sections 숨김
for (const sectionId of rule.show_sections) {
visibleSectionIds.delete(sectionId);
}
}
}
return visibleSectionIds;
}
/**
* 조건부 필드 규칙에 따른 표시 여부 결정
*/
function evaluateConditionalFields(
sections: DynamicSection[],
conditionalFields: ConditionalField[],
values: FormData
): Map<number, Set<number>> {
const visibleFieldsMap = new Map<number, Set<number>>();
// 1. 기본적으로 모든 필드 표시 (각 섹션별로)
for (const section of sections) {
const fieldIds = new Set<number>();
for (const field of section.fields) {
// 필드의 display_condition 평가
if (field.display_condition) {
if (evaluateCondition(field.display_condition, values)) {
fieldIds.add(field.id);
}
} else {
fieldIds.add(field.id);
}
}
visibleFieldsMap.set(section.id, fieldIds);
}
// 2. conditionalFields 규칙 적용
for (const rule of conditionalFields) {
const conditionMet = evaluateCondition(rule.condition, values);
// 모든 섹션에서 해당 필드 ID 찾기
for (const [sectionId, fieldIds] of visibleFieldsMap) {
if (conditionMet) {
// 조건 충족 시 show_fields 표시
for (const fieldId of rule.show_fields) {
fieldIds.add(fieldId);
}
// hide_fields가 있으면 숨김
if (rule.hide_fields) {
for (const fieldId of rule.hide_fields) {
fieldIds.delete(fieldId);
}
}
} else {
// 조건 미충족 시 show_fields 숨김
for (const fieldId of rule.show_fields) {
fieldIds.delete(fieldId);
}
}
}
}
return visibleFieldsMap;
}
interface UseConditionalFieldsOptions {
sections: DynamicSection[];
conditionalSections: ConditionalSection[];
conditionalFields: ConditionalField[];
values: FormData;
}
/**
* useConditionalFields Hook
*
* @param options - 훅 옵션
* @returns 조건부 표시 상태 및 헬퍼 함수
*
* @example
* const { visibleSections, isFieldVisible, isSectionVisible } = useConditionalFields({
* sections: formStructure.sections,
* conditionalSections: formStructure.conditionalSections,
* conditionalFields: formStructure.conditionalFields,
* values: formValues,
* });
*/
export function useConditionalFields(
options: UseConditionalFieldsOptions
): UseConditionalFieldsReturn {
const { sections, conditionalSections, conditionalFields, values } = options;
// 표시할 섹션 ID 목록
const visibleSectionIds = useMemo(() => {
return evaluateConditionalSections(sections, conditionalSections, values);
}, [sections, conditionalSections, values]);
// 섹션별 표시할 필드 ID 맵
const visibleFieldsMap = useMemo(() => {
return evaluateConditionalFields(sections, conditionalFields, values);
}, [sections, conditionalFields, values]);
// 배열로 변환
const visibleSections = useMemo(() => {
return Array.from(visibleSectionIds);
}, [visibleSectionIds]);
// Map을 Record로 변환
const visibleFields = useMemo(() => {
const result: Record<number, number[]> = {};
for (const [sectionId, fieldIds] of visibleFieldsMap) {
result[sectionId] = Array.from(fieldIds);
}
return result;
}, [visibleFieldsMap]);
/**
* 특정 섹션이 표시되는지 확인
*/
const isSectionVisible = (sectionId: number): boolean => {
return visibleSectionIds.has(sectionId);
};
/**
* 특정 필드가 표시되는지 확인
*/
const isFieldVisible = (sectionId: number, fieldId: number): boolean => {
const fieldIds = visibleFieldsMap.get(sectionId);
if (!fieldIds) return false;
return fieldIds.has(fieldId);
};
return {
visibleSections,
visibleFields,
isSectionVisible,
isFieldVisible,
};
}
export default useConditionalFields;

View File

@@ -0,0 +1,341 @@
/**
* useDynamicFormState Hook
*
* 동적 폼의 상태 관리 훅
* - 필드 값 관리
* - 유효성 검증
* - 에러 상태 관리
* - 폼 제출 처리
*/
'use client';
import { useState, useCallback, useMemo } from 'react';
import type {
FormState,
FormData,
FormValue,
DynamicSection,
DynamicField,
UseDynamicFormStateReturn,
ValidationRules,
FIELD_TYPE_DEFAULTS,
} from '../types';
/**
* 폼 구조에서 초기 값 생성
*/
function buildInitialValues(
sections: DynamicSection[],
existingValues?: FormData
): FormData {
const values: FormData = {};
for (const section of sections) {
for (const field of section.fields) {
// 기존 값이 있으면 사용, 없으면 기본값
if (existingValues && existingValues[field.field_key] !== undefined) {
values[field.field_key] = existingValues[field.field_key];
} else if (field.default_value !== undefined) {
values[field.field_key] = field.default_value;
} else {
// 필드 타입에 따른 기본값
values[field.field_key] = getDefaultValueForType(field.field_type);
}
}
}
return values;
}
/**
* 필드 타입에 따른 기본값
*/
function getDefaultValueForType(fieldType: string): FormValue {
switch (fieldType) {
case 'textbox':
case 'textarea':
case 'dropdown':
case 'searchable-dropdown':
return '';
case 'number':
case 'currency':
return 0;
case 'checkbox':
case 'switch':
return false;
case 'date':
case 'date-range':
case 'file':
case 'custom:drawing-canvas':
case 'custom:bending-detail-table':
case 'custom:bom-table':
return null;
default:
return '';
}
}
/**
* 단일 필드 유효성 검증
*/
function validateField(
field: DynamicField,
value: FormValue
): string | undefined {
// 필수 필드 검사
if (field.is_required) {
if (value === null || value === undefined || value === '') {
return `${field.field_name}은(는) 필수 입력 항목입니다.`;
}
}
// 값이 없으면 추가 검증 스킵
if (value === null || value === undefined || value === '') {
return undefined;
}
const rules = field.validation_rules;
if (!rules) return undefined;
// 문자열 검증
if (typeof value === 'string') {
if (rules.minLength && value.length < rules.minLength) {
return `최소 ${rules.minLength}자 이상 입력해주세요.`;
}
if (rules.maxLength && value.length > rules.maxLength) {
return `최대 ${rules.maxLength}자까지 입력 가능합니다.`;
}
if (rules.pattern) {
const regex = new RegExp(rules.pattern);
if (!regex.test(value)) {
return rules.patternMessage || '입력 형식이 올바르지 않습니다.';
}
}
}
// 숫자 검증
if (typeof value === 'number') {
if (rules.min !== undefined && value < rules.min) {
return `최소 ${rules.min} 이상이어야 합니다.`;
}
if (rules.max !== undefined && value > rules.max) {
return `최대 ${rules.max} 이하여야 합니다.`;
}
}
return undefined;
}
/**
* 전체 폼 유효성 검증
*/
function validateForm(
sections: DynamicSection[],
values: FormData
): Record<string, string> {
const errors: Record<string, string> = {};
for (const section of sections) {
for (const field of section.fields) {
const error = validateField(field, values[field.field_key]);
if (error) {
errors[field.field_key] = error;
}
}
}
return errors;
}
interface UseDynamicFormStateOptions {
sections: DynamicSection[];
initialValues?: FormData;
onSubmit?: (data: FormData) => Promise<void>;
}
/**
* useDynamicFormState Hook
*
* @param options - 훅 옵션
* @returns 폼 상태 및 조작 함수
*
* @example
* const { state, setValue, handleSubmit, validate } = useDynamicFormState({
* sections: formStructure.sections,
* initialValues: existingItem,
* onSubmit: async (data) => {
* await saveItem(data);
* },
* });
*/
export function useDynamicFormState(
options: UseDynamicFormStateOptions
): UseDynamicFormStateReturn {
const { sections, initialValues, onSubmit } = options;
// 초기 상태 생성
const [state, setState] = useState<FormState>(() => ({
values: buildInitialValues(sections, initialValues),
errors: {},
touched: {},
isSubmitting: false,
isValid: true,
}));
/**
* 단일 필드 값 설정
*/
const setValue = useCallback((fieldKey: string, value: FormValue) => {
setState((prev) => ({
...prev,
values: {
...prev.values,
[fieldKey]: value,
},
// 값 변경 시 해당 필드 에러 클리어
errors: {
...prev.errors,
[fieldKey]: undefined as unknown as string,
},
}));
}, []);
/**
* 여러 필드 값 일괄 설정
*/
const setValues = useCallback((values: FormData) => {
setState((prev) => ({
...prev,
values: {
...prev.values,
...values,
},
}));
}, []);
/**
* 필드 에러 설정
*/
const setError = useCallback((fieldKey: string, message: string) => {
setState((prev) => ({
...prev,
errors: {
...prev.errors,
[fieldKey]: message,
},
isValid: false,
}));
}, []);
/**
* 필드 에러 클리어
*/
const clearError = useCallback((fieldKey: string) => {
setState((prev) => {
const newErrors = { ...prev.errors };
delete newErrors[fieldKey];
return {
...prev,
errors: newErrors,
isValid: Object.keys(newErrors).length === 0,
};
});
}, []);
/**
* 필드 touched 상태 설정
*/
const setTouched = useCallback((fieldKey: string) => {
setState((prev) => ({
...prev,
touched: {
...prev.touched,
[fieldKey]: true,
},
}));
}, []);
/**
* 전체 폼 유효성 검증
*/
const validate = useCallback((): boolean => {
const errors = validateForm(sections, state.values);
const isValid = Object.keys(errors).length === 0;
setState((prev) => ({
...prev,
errors,
isValid,
}));
return isValid;
}, [sections, state.values]);
/**
* 폼 리셋
*/
const reset = useCallback(
(resetValues?: FormData) => {
setState({
values: buildInitialValues(sections, resetValues || initialValues),
errors: {},
touched: {},
isSubmitting: false,
isValid: true,
});
},
[sections, initialValues]
);
/**
* 폼 제출 핸들러 생성
*/
const handleSubmit = useCallback(
(submitFn: (data: FormData) => Promise<void>) => {
return async (e: React.FormEvent) => {
e.preventDefault();
// 유효성 검증
const isValid = validate();
if (!isValid) {
console.warn('[useDynamicFormState] Form validation failed');
return;
}
// 제출 시작
setState((prev) => ({
...prev,
isSubmitting: true,
}));
try {
await submitFn(state.values);
} catch (error) {
console.error('[useDynamicFormState] Submit error:', error);
throw error;
} finally {
setState((prev) => ({
...prev,
isSubmitting: false,
}));
}
};
},
[validate, state.values]
);
return {
state,
setValue,
setValues,
setError,
clearError,
setTouched,
validate,
reset,
handleSubmit,
};
}
export default useDynamicFormState;

View File

@@ -0,0 +1,995 @@
/**
* useFormStructure Hook
*
* API에서 품목 유형별 폼 구조를 로드하는 훅
* - 캐싱 지원 (5분 TTL)
* - 에러 처리 및 재시도
* - Mock 데이터 폴백 (API 미구현 시)
*/
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import type { ItemType, PartType } from '@/types/item';
import type {
FormStructure,
FormStructureResponse,
UseFormStructureReturn,
DynamicSection,
DynamicField,
ConditionalSection,
} from '../types';
// ===== 캐시 설정 =====
const CACHE_TTL = 5 * 60 * 1000; // 5분
const formStructureCache = new Map<string, { data: FormStructure; timestamp: number }>();
/**
* 캐시 키 생성
*/
function getCacheKey(itemType: ItemType, partType?: PartType): string {
return partType ? `${itemType}_${partType}` : itemType;
}
/**
* 캐시에서 데이터 가져오기
*/
function getFromCache(key: string): FormStructure | null {
const cached = formStructureCache.get(key);
if (!cached) return null;
const isExpired = Date.now() - cached.timestamp > CACHE_TTL;
if (isExpired) {
formStructureCache.delete(key);
return null;
}
return cached.data;
}
/**
* 캐시에 데이터 저장
*/
function setToCache(key: string, data: FormStructure): void {
formStructureCache.set(key, { data, timestamp: Date.now() });
}
// ===== Mock 데이터 (API 미구현 시 사용) =====
/**
* 제품(FG) Mock 폼 구조
*/
function getMockFGFormStructure(): FormStructure {
return {
page: {
id: 1,
page_name: '제품 등록',
item_type: 'FG',
is_active: true,
},
sections: [
{
id: 101,
title: '기본 정보',
section_type: 'BASIC',
order_no: 1,
is_collapsible: false,
is_default_open: true,
fields: [
{
id: 1001,
field_name: '품목코드',
field_key: 'item_code',
field_type: 'textbox',
order_no: 1,
is_required: true,
is_readonly: true,
placeholder: '자동 생성',
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 1002,
field_name: '품목명',
field_key: 'item_name',
field_type: 'textbox',
order_no: 2,
is_required: true,
placeholder: '품목명을 입력하세요',
validation_rules: { maxLength: 100 },
grid_row: 1,
grid_col: 2,
grid_span: 2,
},
{
id: 1003,
field_name: '제품 카테고리',
field_key: 'product_category',
field_type: 'dropdown',
order_no: 3,
is_required: true,
dropdown_config: {
options: [
{ value: 'SCREEN', label: '스크린' },
{ value: 'STEEL', label: '철재' },
],
placeholder: '카테고리 선택',
},
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
{
id: 1004,
field_name: '단위',
field_key: 'unit',
field_type: 'dropdown',
order_no: 4,
is_required: true,
dropdown_config: {
options: [
{ value: 'EA', label: 'EA (개)' },
{ value: 'SET', label: 'SET (세트)' },
],
placeholder: '단위 선택',
},
grid_row: 2,
grid_col: 1,
grid_span: 1,
},
{
id: 1005,
field_name: '규격',
field_key: 'specification',
field_type: 'textbox',
order_no: 5,
is_required: false,
placeholder: '규격을 입력하세요',
grid_row: 2,
grid_col: 2,
grid_span: 2,
},
{
id: 1006,
field_name: '활성 상태',
field_key: 'is_active',
field_type: 'switch',
order_no: 6,
is_required: false,
default_value: true,
grid_row: 2,
grid_col: 4,
grid_span: 1,
},
],
},
{
id: 102,
title: '가격 정보',
section_type: 'DETAIL',
order_no: 2,
is_collapsible: true,
is_default_open: true,
fields: [
{
id: 1010,
field_name: '판매 단가',
field_key: 'sales_price',
field_type: 'currency',
order_no: 1,
is_required: false,
placeholder: '0',
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 1011,
field_name: '구매 단가',
field_key: 'purchase_price',
field_type: 'currency',
order_no: 2,
is_required: false,
placeholder: '0',
grid_row: 1,
grid_col: 2,
grid_span: 1,
},
{
id: 1012,
field_name: '마진율 (%)',
field_key: 'margin_rate',
field_type: 'number',
order_no: 3,
is_required: false,
placeholder: '0',
validation_rules: { min: 0, max: 100 },
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
],
},
{
id: 103,
title: '부품 구성 (BOM)',
section_type: 'BOM',
order_no: 3,
is_collapsible: true,
is_default_open: true,
fields: [],
bom_config: {
columns: [
{ key: 'child_item_code', label: '품목코드', width: 150 },
{ key: 'child_item_name', label: '품목명', width: 200 },
{ key: 'specification', label: '규격', width: 150 },
{ key: 'quantity', label: '수량', width: 80, type: 'number', editable: true },
{ key: 'unit', label: '단위', width: 80 },
{ key: 'note', label: '비고', width: 150, type: 'text', editable: true },
],
allow_search: true,
search_endpoint: '/api/proxy/items/search',
allow_add: true,
allow_delete: true,
allow_reorder: true,
},
},
{
id: 104,
title: '인정 정보',
section_type: 'CERTIFICATION',
order_no: 4,
is_collapsible: true,
is_default_open: false,
fields: [
{
id: 1020,
field_name: '인정번호',
field_key: 'certification_number',
field_type: 'textbox',
order_no: 1,
is_required: false,
placeholder: '인정번호를 입력하세요',
grid_row: 1,
grid_col: 1,
grid_span: 2,
},
{
id: 1021,
field_name: '인정 시작일',
field_key: 'certification_start_date',
field_type: 'date',
order_no: 2,
is_required: false,
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
{
id: 1022,
field_name: '인정 종료일',
field_key: 'certification_end_date',
field_type: 'date',
order_no: 3,
is_required: false,
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
{
id: 1023,
field_name: '시방서',
field_key: 'specification_file',
field_type: 'file',
order_no: 4,
is_required: false,
file_config: {
accept: '.pdf,.doc,.docx',
max_size: 10 * 1024 * 1024, // 10MB
},
grid_row: 2,
grid_col: 1,
grid_span: 2,
},
{
id: 1024,
field_name: '인정서',
field_key: 'certification_file',
field_type: 'file',
order_no: 5,
is_required: false,
file_config: {
accept: '.pdf,.doc,.docx',
max_size: 10 * 1024 * 1024,
},
grid_row: 2,
grid_col: 3,
grid_span: 2,
},
],
},
],
conditionalSections: [],
conditionalFields: [],
};
}
/**
* 부품(PT) Mock 폼 구조
*/
function getMockPTFormStructure(partType?: PartType): FormStructure {
const baseFields: DynamicField[] = [
{
id: 2001,
field_name: '품목코드',
field_key: 'item_code',
field_type: 'textbox',
order_no: 1,
is_required: true,
is_readonly: true,
placeholder: '자동 생성',
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 2002,
field_name: '품목명',
field_key: 'item_name',
field_type: 'textbox',
order_no: 2,
is_required: true,
placeholder: '품목명을 입력하세요',
grid_row: 1,
grid_col: 2,
grid_span: 2,
},
{
id: 2003,
field_name: '부품 유형',
field_key: 'part_type',
field_type: 'dropdown',
order_no: 3,
is_required: true,
dropdown_config: {
options: [
{ value: 'ASSEMBLY', label: '조립 부품' },
{ value: 'BENDING', label: '절곡 부품' },
{ value: 'PURCHASED', label: '구매 부품' },
],
placeholder: '부품 유형 선택',
},
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
{
id: 2004,
field_name: '단위',
field_key: 'unit',
field_type: 'dropdown',
order_no: 4,
is_required: true,
dropdown_config: {
options: [
{ value: 'EA', label: 'EA (개)' },
{ value: 'SET', label: 'SET (세트)' },
{ value: 'M', label: 'M (미터)' },
],
},
grid_row: 2,
grid_col: 1,
grid_span: 1,
},
];
const sections: DynamicSection[] = [
{
id: 201,
title: '기본 정보',
section_type: 'BASIC',
order_no: 1,
is_collapsible: false,
is_default_open: true,
fields: baseFields,
},
];
// 조립 부품 전용 섹션
const assemblySection: DynamicSection = {
id: 202,
title: '조립 부품 상세',
section_type: 'DETAIL',
order_no: 2,
is_collapsible: true,
is_default_open: true,
display_condition: {
field_key: 'part_type',
operator: 'equals',
value: 'ASSEMBLY',
},
fields: [
{
id: 2010,
field_name: '설치 유형',
field_key: 'installation_type',
field_type: 'dropdown',
order_no: 1,
is_required: false,
dropdown_config: {
options: [
{ value: 'WALL', label: '벽면형' },
{ value: 'SIDE', label: '측면형' },
],
},
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 2011,
field_name: '조립 종류',
field_key: 'assembly_type',
field_type: 'dropdown',
order_no: 2,
is_required: false,
dropdown_config: {
options: [
{ value: 'M', label: 'M형' },
{ value: 'T', label: 'T형' },
{ value: 'C', label: 'C형' },
{ value: 'D', label: 'D형' },
{ value: 'S', label: 'S형' },
{ value: 'U', label: 'U형' },
],
},
grid_row: 1,
grid_col: 2,
grid_span: 1,
},
{
id: 2012,
field_name: '길이 (mm)',
field_key: 'assembly_length',
field_type: 'dropdown',
order_no: 3,
is_required: false,
dropdown_config: {
options: [
{ value: '2438', label: '2438' },
{ value: '3000', label: '3000' },
{ value: '3500', label: '3500' },
{ value: '4000', label: '4000' },
{ value: '4300', label: '4300' },
],
},
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
],
};
// 절곡 부품 전용 섹션
const bendingSection: DynamicSection = {
id: 203,
title: '절곡 정보',
section_type: 'BENDING',
order_no: 2,
is_collapsible: true,
is_default_open: true,
display_condition: {
field_key: 'part_type',
operator: 'equals',
value: 'BENDING',
},
fields: [
{
id: 2020,
field_name: '재질',
field_key: 'material',
field_type: 'dropdown',
order_no: 1,
is_required: true,
dropdown_config: {
options: [
{ value: 'EGI_1.55T', label: 'EGI 1.55T' },
{ value: 'SUS_1.2T', label: 'SUS 1.2T' },
{ value: 'SUS_1.5T', label: 'SUS 1.5T' },
],
},
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 2021,
field_name: '길이/목함 (mm)',
field_key: 'bending_length',
field_type: 'number',
order_no: 2,
is_required: false,
placeholder: '길이 입력',
grid_row: 1,
grid_col: 2,
grid_span: 1,
},
{
id: 2022,
field_name: '전개도',
field_key: 'bending_diagram',
field_type: 'custom:drawing-canvas',
order_no: 3,
is_required: false,
grid_row: 2,
grid_col: 1,
grid_span: 4,
},
{
id: 2023,
field_name: '전개도 상세',
field_key: 'bending_details',
field_type: 'custom:bending-detail-table',
order_no: 4,
is_required: false,
grid_row: 3,
grid_col: 1,
grid_span: 4,
},
],
};
// 구매 부품 전용 섹션
const purchasedSection: DynamicSection = {
id: 204,
title: '구매 부품 상세',
section_type: 'DETAIL',
order_no: 2,
is_collapsible: true,
is_default_open: true,
display_condition: {
field_key: 'part_type',
operator: 'equals',
value: 'PURCHASED',
},
fields: [
{
id: 2030,
field_name: '구매처',
field_key: 'supplier',
field_type: 'textbox',
order_no: 1,
is_required: false,
placeholder: '구매처를 입력하세요',
grid_row: 1,
grid_col: 1,
grid_span: 2,
},
{
id: 2031,
field_name: '구매 단가',
field_key: 'purchase_price',
field_type: 'currency',
order_no: 2,
is_required: false,
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
{
id: 2032,
field_name: '리드타임 (일)',
field_key: 'lead_time',
field_type: 'number',
order_no: 3,
is_required: false,
placeholder: '0',
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
],
};
sections.push(assemblySection, bendingSection, purchasedSection);
// BOM 섹션 (조립/절곡 부품만)
const bomSection: DynamicSection = {
id: 205,
title: '부품 구성 (BOM)',
section_type: 'BOM',
order_no: 3,
is_collapsible: true,
is_default_open: true,
display_condition: {
field_key: 'part_type',
operator: 'in',
value: ['ASSEMBLY', 'BENDING'],
},
fields: [],
bom_config: {
columns: [
{ key: 'child_item_code', label: '품목코드', width: 150 },
{ key: 'child_item_name', label: '품목명', width: 200 },
{ key: 'quantity', label: '수량', width: 80, type: 'number', editable: true },
{ key: 'unit', label: '단위', width: 80 },
],
allow_search: true,
search_endpoint: '/api/proxy/items/search',
allow_add: true,
allow_delete: true,
},
};
sections.push(bomSection);
return {
page: {
id: 2,
page_name: '부품 등록',
item_type: 'PT',
part_type: partType,
is_active: true,
},
sections,
conditionalSections: [],
conditionalFields: [],
};
}
/**
* 자재(RM/SM/CS) Mock 폼 구조
*/
function getMockMaterialFormStructure(itemType: ItemType): FormStructure {
const typeLabels: Record<string, string> = {
RM: '원자재',
SM: '부자재',
CS: '소모품',
};
return {
page: {
id: itemType === 'RM' ? 3 : itemType === 'SM' ? 4 : 5,
page_name: `${typeLabels[itemType]} 등록`,
item_type: itemType,
is_active: true,
},
sections: [
{
id: 301,
title: '기본 정보',
section_type: 'BASIC',
order_no: 1,
is_collapsible: false,
is_default_open: true,
fields: [
{
id: 3001,
field_name: '품목코드',
field_key: 'item_code',
field_type: 'textbox',
order_no: 1,
is_required: true,
is_readonly: true,
placeholder: '자동 생성',
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 3002,
field_name: '품목명',
field_key: 'item_name',
field_type: 'textbox',
order_no: 2,
is_required: true,
placeholder: '품목명을 입력하세요',
grid_row: 1,
grid_col: 2,
grid_span: 2,
},
{
id: 3003,
field_name: '단위',
field_key: 'unit',
field_type: 'dropdown',
order_no: 3,
is_required: true,
dropdown_config: {
options: [
{ value: 'EA', label: 'EA (개)' },
{ value: 'KG', label: 'KG (킬로그램)' },
{ value: 'M', label: 'M (미터)' },
{ value: 'L', label: 'L (리터)' },
{ value: 'BOX', label: 'BOX (박스)' },
],
},
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
{
id: 3004,
field_name: '규격',
field_key: 'specification',
field_type: 'textbox',
order_no: 4,
is_required: false,
placeholder: '규격을 입력하세요',
grid_row: 2,
grid_col: 1,
grid_span: 2,
},
{
id: 3005,
field_name: '구매 단가',
field_key: 'purchase_price',
field_type: 'currency',
order_no: 5,
is_required: false,
grid_row: 2,
grid_col: 3,
grid_span: 1,
},
{
id: 3006,
field_name: '안전재고',
field_key: 'safety_stock',
field_type: 'number',
order_no: 6,
is_required: false,
placeholder: '0',
grid_row: 2,
grid_col: 4,
grid_span: 1,
},
],
},
{
id: 302,
title: '구매 정보',
section_type: 'DETAIL',
order_no: 2,
is_collapsible: true,
is_default_open: false,
fields: [
{
id: 3010,
field_name: '구매처',
field_key: 'supplier',
field_type: 'textbox',
order_no: 1,
is_required: false,
placeholder: '구매처를 입력하세요',
grid_row: 1,
grid_col: 1,
grid_span: 2,
},
{
id: 3011,
field_name: '리드타임 (일)',
field_key: 'lead_time',
field_type: 'number',
order_no: 2,
is_required: false,
placeholder: '0',
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
{
id: 3012,
field_name: '비고',
field_key: 'note',
field_type: 'textarea',
order_no: 3,
is_required: false,
placeholder: '비고를 입력하세요',
grid_row: 2,
grid_col: 1,
grid_span: 4,
},
],
},
],
conditionalSections: [],
conditionalFields: [],
};
}
/**
* Mock 데이터 가져오기
*/
function getMockFormStructure(itemType: ItemType, partType?: PartType): FormStructure {
switch (itemType) {
case 'FG':
return getMockFGFormStructure();
case 'PT':
return getMockPTFormStructure(partType);
case 'RM':
case 'SM':
case 'CS':
return getMockMaterialFormStructure(itemType);
default:
return getMockFGFormStructure();
}
}
// ===== API 호출 =====
/**
* 폼 구조 API 호출
*/
async function fetchFormStructure(
itemType: ItemType,
partType?: PartType
): Promise<FormStructure> {
const endpoint = partType
? `/api/proxy/item-master/form-structure/${itemType}?part_type=${partType}`
: `/api/proxy/item-master/form-structure/${itemType}`;
try {
const response = await fetch(endpoint);
if (!response.ok) {
// API가 404면 Mock 데이터 사용
if (response.status === 404) {
console.warn(`[useFormStructure] API not found, using mock data for ${itemType}`);
return getMockFormStructure(itemType, partType);
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result: FormStructureResponse = await response.json();
if (!result.success) {
throw new Error(result.message || 'API returned unsuccessful response');
}
// API 응답을 FormStructure 형식으로 변환
return {
page: result.data.page,
sections: result.data.sections,
conditionalSections: result.data.conditional_sections || [],
conditionalFields: result.data.conditional_fields || [],
};
} catch (error) {
console.warn(`[useFormStructure] API call failed, using mock data:`, error);
// API 실패 시 Mock 데이터 폴백
return getMockFormStructure(itemType, partType);
}
}
// ===== 훅 구현 =====
interface UseFormStructureOptions {
itemType: ItemType;
partType?: PartType;
enabled?: boolean;
useMock?: boolean; // 강제로 Mock 데이터 사용
}
/**
* useFormStructure Hook
*
* @param options - 훅 옵션
* @returns 폼 구조 데이터 및 상태
*
* @example
* const { formStructure, isLoading, error, refetch } = useFormStructure({
* itemType: 'FG',
* });
*
* @example
* const { formStructure } = useFormStructure({
* itemType: 'PT',
* partType: 'BENDING',
* });
*/
export function useFormStructure(options: UseFormStructureOptions): UseFormStructureReturn {
const { itemType, partType, enabled = true, useMock = false } = options;
const [formStructure, setFormStructure] = useState<FormStructure | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// 이전 요청 취소용
const abortControllerRef = useRef<AbortController | null>(null);
const cacheKey = getCacheKey(itemType, partType);
/**
* 폼 구조 로드
*/
const loadFormStructure = useCallback(async () => {
// 이전 요청 취소
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
// 캐시 확인
const cached = getFromCache(cacheKey);
if (cached) {
setFormStructure(cached);
setIsLoading(false);
setError(null);
return;
}
setIsLoading(true);
setError(null);
try {
let data: FormStructure;
if (useMock) {
// 강제 Mock 모드
data = getMockFormStructure(itemType, partType);
} else {
// API 호출 (실패 시 자동으로 Mock 폴백)
data = await fetchFormStructure(itemType, partType);
}
// 캐시에 저장
setToCache(cacheKey, data);
setFormStructure(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setIsLoading(false);
}
}, [itemType, partType, cacheKey, useMock]);
/**
* 강제 새로고침
*/
const refetch = useCallback(async () => {
// 캐시 무효화
formStructureCache.delete(cacheKey);
await loadFormStructure();
}, [cacheKey, loadFormStructure]);
// 마운트 시 및 의존성 변경 시 로드
useEffect(() => {
if (enabled) {
loadFormStructure();
}
return () => {
// 언마운트 시 요청 취소
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [enabled, loadFormStructure]);
return {
formStructure,
isLoading,
error,
refetch,
};
}
// ===== 캐시 유틸리티 =====
/**
* 폼 구조 캐시 초기화
*/
export function clearFormStructureCache(): void {
formStructureCache.clear();
}
/**
* 특정 품목 유형의 캐시 무효화
*/
export function invalidateFormStructureCache(itemType: ItemType, partType?: PartType): void {
const key = getCacheKey(itemType, partType);
formStructureCache.delete(key);
}
export default useFormStructure;

View File

@@ -0,0 +1,208 @@
/**
* DynamicItemForm Component
*
* 품목기준관리 설정에 따라 동적으로 폼을 렌더링하는 메인 컴포넌트
*
* 특징:
* - API에서 폼 구조 로드 (품목 유형별)
* - 조건부 섹션/필드 표시
* - 동적 유효성 검증
* - 기존 특수 컴포넌트 (BOM, 전개도) 통합 가능
*
* @example
* // 신규 등록
* <DynamicItemForm
* itemType="FG"
* onSubmit={handleCreate}
* onCancel={handleCancel}
* />
*
* @example
* // 수정
* <DynamicItemForm
* itemType="PT"
* partType="BENDING"
* initialValues={existingItem}
* onSubmit={handleUpdate}
* onCancel={handleCancel}
* />
*/
'use client';
import { useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { DynamicFormRenderer } from './DynamicFormRenderer';
import { useFormStructure, useDynamicFormState } from './hooks';
import type { ItemType, PartType } from '@/types/item';
import type { FormData } from './types';
interface DynamicItemFormProps {
/** 품목 유형 */
itemType: ItemType;
/** 부품 유형 (PT인 경우) */
partType?: PartType;
/** 초기 값 (수정 모드) */
initialValues?: FormData;
/** 제출 핸들러 */
onSubmit: (data: FormData) => Promise<void>;
/** 취소 핸들러 */
onCancel?: () => void;
/** 폼 비활성화 */
disabled?: boolean;
/** Mock 데이터 사용 (API 미구현 시) */
useMock?: boolean;
}
export function DynamicItemForm({
itemType,
partType,
initialValues,
onSubmit,
onCancel,
disabled = false,
useMock = true, // 기본적으로 Mock 사용 (API 구현 후 false로 변경)
}: DynamicItemFormProps) {
// 폼 구조 로드
const {
formStructure,
isLoading: isLoadingStructure,
error: structureError,
refetch: refetchStructure,
} = useFormStructure({
itemType,
partType,
useMock,
});
// 폼 상태 관리
const {
state,
setValue,
setTouched,
validate,
reset,
handleSubmit,
} = useDynamicFormState({
sections: formStructure?.sections || [],
initialValues,
});
// 폼 구조가 변경되면 값 초기화
useEffect(() => {
if (formStructure) {
reset(initialValues);
}
}, [formStructure, initialValues, reset]);
// 로딩 상태
if (isLoadingStructure) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground"> ...</span>
</div>
);
}
// 에러 상태
if (structureError) {
return (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between">
<span> : {structureError.message}</span>
<Button variant="outline" size="sm" onClick={refetchStructure}>
</Button>
</AlertDescription>
</Alert>
);
}
// 폼 구조가 없는 경우
if (!formStructure) {
return (
<Alert>
<AlertDescription>
({itemType}) .
.
</AlertDescription>
</Alert>
);
}
// 폼 제출
const onFormSubmit = handleSubmit(onSubmit);
return (
<form onSubmit={onFormSubmit} className="space-y-6">
{/* 페이지 정보 (디버그용, 필요시 제거) */}
{process.env.NODE_ENV === 'development' && (
<div className="text-xs text-muted-foreground bg-gray-50 p-2 rounded">
📄 {formStructure.page.page_name} | : {formStructure.page.item_type}
{formStructure.page.part_type && ` | 부품유형: ${formStructure.page.part_type}`}
</div>
)}
{/* 동적 폼 렌더러 */}
<DynamicFormRenderer
sections={formStructure.sections}
conditionalSections={formStructure.conditionalSections}
conditionalFields={formStructure.conditionalFields}
values={state.values}
errors={state.errors}
onChange={(fieldKey, value) => {
setValue(fieldKey, value);
}}
onBlur={(fieldKey) => {
setTouched(fieldKey);
}}
disabled={disabled || state.isSubmitting}
/>
{/* 버튼 영역 */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={state.isSubmitting}
>
</Button>
)}
<Button
type="submit"
disabled={disabled || state.isSubmitting}
>
{state.isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
'저장'
)}
</Button>
</div>
</form>
);
}
// 하위 컴포넌트 및 훅 re-export
export { DynamicFormRenderer } from './DynamicFormRenderer';
export { DynamicSection } from './DynamicSection';
export { DynamicField } from './DynamicField';
export {
useFormStructure,
useConditionalFields,
useDynamicFormState,
clearFormStructureCache,
invalidateFormStructureCache,
} from './hooks';
export * from './types';
export default DynamicItemForm;

View File

@@ -0,0 +1,402 @@
/**
* 동적 폼 렌더링 타입 정의
*
* API 응답 구조와 동적 폼 컴포넌트에서 사용하는 타입들
* 참조: [PLAN-2025-11-28] dynamic-item-form-implementation.md
*/
import type { ItemType, PartType } from '@/types/item';
// ===== 필드 타입 =====
/**
* 필드 입력 타입
*/
export type FieldType =
| 'textbox' // 단일 텍스트 입력
| 'textarea' // 여러 줄 텍스트
| 'dropdown' // 선택 목록
| 'searchable-dropdown' // 검색 가능 선택
| 'number' // 숫자 입력
| 'currency' // 통화 입력
| 'date' // 날짜 선택
| 'date-range' // 기간 선택
| 'checkbox' // 체크박스
| 'switch' // 토글 스위치
| 'file' // 파일 업로드
| 'custom:drawing-canvas' // 전개도 그리기
| 'custom:bending-detail-table' // 전개도 상세 입력
| 'custom:bom-table'; // BOM 관리 테이블
/**
* 섹션 타입
*/
export type SectionType =
| 'BASIC' // 기본 정보
| 'DETAIL' // 상세 정보
| 'BOM' // 부품 구성
| 'BENDING' // 절곡 정보
| 'CERTIFICATION'// 인정 정보
| 'CUSTOM'; // 커스텀
// ===== 유효성 검증 =====
/**
* 필드 유효성 검증 규칙
*/
export interface ValidationRules {
maxLength?: number;
minLength?: number;
max?: number;
min?: number;
pattern?: string;
patternMessage?: string;
custom?: string; // 커스텀 검증 함수 이름
}
// ===== 조건부 렌더링 =====
/**
* 조건 연산자
*/
export type ConditionOperator =
| 'equals'
| 'not_equals'
| 'in'
| 'not_in'
| 'contains'
| 'greater_than'
| 'less_than';
/**
* 조건 정의
*/
export interface Condition {
field_key: string;
operator: ConditionOperator;
value: string | number | boolean | string[];
}
/**
* 조건부 섹션 표시 규칙
*/
export interface ConditionalSection {
condition: Condition;
show_sections: number[];
hide_sections?: number[];
}
/**
* 조건부 필드 표시 규칙
*/
export interface ConditionalField {
condition: Condition;
show_fields: number[];
hide_fields?: number[];
}
// ===== 필드 옵션 =====
/**
* 드롭다운 옵션
*/
export interface DropdownOption {
value: string;
label: string;
disabled?: boolean;
}
/**
* 드롭다운 설정
*/
export interface DropdownConfig {
options?: DropdownOption[];
options_endpoint?: string; // API에서 옵션 로드
placeholder?: string;
allow_empty?: boolean;
searchable?: boolean;
}
/**
* 파일 업로드 설정
*/
export interface FileConfig {
accept?: string; // 허용 파일 타입 (예: ".pdf,.doc")
max_size?: number; // 최대 파일 크기 (bytes)
multiple?: boolean;
upload_endpoint?: string;
}
// ===== BOM 섹션 설정 =====
/**
* BOM 컬럼 정의
*/
export interface BOMColumn {
key: string;
label: string;
width?: number;
type?: 'text' | 'number' | 'dropdown';
editable?: boolean;
}
/**
* BOM 섹션 설정
*/
export interface BOMConfig {
columns: BOMColumn[];
allow_search: boolean;
search_endpoint?: string;
allow_add: boolean;
allow_delete: boolean;
allow_reorder?: boolean;
}
// ===== 동적 필드 =====
/**
* 동적 필드 정의
* API 응답: sections[].fields[]
*/
export interface DynamicField {
id: number;
field_name: string; // 표시 이름 (예: "품목명")
field_key: string; // 데이터 키 (예: "item_name")
field_type: FieldType;
order_no: number;
is_required: boolean;
is_readonly?: boolean;
placeholder?: string;
default_value?: string | number | boolean;
validation_rules?: ValidationRules;
help_text?: string;
// 그리드 레이아웃
grid_row?: number;
grid_col?: number;
grid_span?: number; // 1-4 (4 = 전체 너비)
// 타입별 설정
dropdown_config?: DropdownConfig;
file_config?: FileConfig;
// 조건부 표시
display_condition?: Condition;
}
// ===== 동적 섹션 =====
/**
* 동적 섹션 정의
* API 응답: sections[]
*/
export interface DynamicSection {
id: number;
title: string;
description?: string;
section_type: SectionType;
order_no: number;
is_collapsible: boolean;
is_default_open: boolean;
// 섹션 내 필드들
fields: DynamicField[];
// BOM 섹션인 경우
bom_config?: BOMConfig;
// 조건부 표시 (섹션 레벨)
display_condition?: Condition;
}
// ===== 페이지 정의 =====
/**
* 페이지 정보
* API 응답: page
*/
export interface PageInfo {
id: number;
page_name: string;
item_type: ItemType;
part_type?: PartType; // PT인 경우에만
description?: string;
is_active: boolean;
}
// ===== API 응답 구조 =====
/**
* 폼 구조 API 응답
* GET /api/v1/item-master/form-structure/{item_type}
*/
export interface FormStructureResponse {
success: boolean;
data: {
page: PageInfo;
sections: DynamicSection[];
conditional_sections?: ConditionalSection[];
conditional_fields?: ConditionalField[];
};
message?: string;
}
/**
* 폼 구조 데이터 (클라이언트용)
*/
export interface FormStructure {
page: PageInfo;
sections: DynamicSection[];
conditionalSections: ConditionalSection[];
conditionalFields: ConditionalField[];
}
// ===== 폼 상태 =====
/**
* 폼 값 타입
*/
export type FormValue = string | number | boolean | null | undefined | File | File[];
/**
* 폼 데이터 (동적 키-값 쌍)
*/
export type FormData = Record<string, FormValue>;
/**
* 필드 에러 상태
*/
export interface FieldError {
field_key: string;
message: string;
}
/**
* 폼 상태
*/
export interface FormState {
values: FormData;
errors: Record<string, string>;
touched: Record<string, boolean>;
isSubmitting: boolean;
isValid: boolean;
}
// ===== 컴포넌트 Props =====
/**
* DynamicField 컴포넌트 Props
*/
export interface DynamicFieldProps {
field: DynamicField;
value: FormValue;
error?: string;
onChange: (value: FormValue) => void;
onBlur: () => void;
disabled?: boolean;
}
/**
* DynamicSection 컴포넌트 Props
*/
export interface DynamicSectionProps {
section: DynamicSection;
values: FormData;
errors: Record<string, string>;
onChange: (fieldKey: string, value: FormValue) => void;
onBlur: (fieldKey: string) => void;
disabled?: boolean;
}
/**
* DynamicFormRenderer 컴포넌트 Props
*/
export interface DynamicFormRendererProps {
sections: DynamicSection[];
conditionalSections: ConditionalSection[];
conditionalFields: ConditionalField[];
values: FormData;
errors: Record<string, string>;
onChange: (fieldKey: string, value: FormValue) => void;
onBlur: (fieldKey: string) => void;
disabled?: boolean;
}
// ===== 훅 반환 타입 =====
/**
* useFormStructure 훅 반환 타입
*/
export interface UseFormStructureReturn {
formStructure: FormStructure | null;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* useDynamicFormState 훅 반환 타입
*/
export interface UseDynamicFormStateReturn {
state: FormState;
setValue: (fieldKey: string, value: FormValue) => void;
setValues: (values: FormData) => void;
setError: (fieldKey: string, message: string) => void;
clearError: (fieldKey: string) => void;
setTouched: (fieldKey: string) => void;
validate: () => boolean;
reset: (initialValues?: FormData) => void;
handleSubmit: (onSubmit: (data: FormData) => Promise<void>) => (e: React.FormEvent) => Promise<void>;
}
/**
* useConditionalFields 훅 반환 타입
*/
export interface UseConditionalFieldsReturn {
visibleSections: number[];
visibleFields: Record<number, number[]>; // sectionId -> fieldIds
isFieldVisible: (sectionId: number, fieldId: number) => boolean;
isSectionVisible: (sectionId: number) => boolean;
}
// ===== 유틸리티 타입 =====
/**
* 필드 타입 → 기본값 매핑
*/
export const FIELD_TYPE_DEFAULTS: Record<FieldType, FormValue> = {
textbox: '',
textarea: '',
dropdown: '',
'searchable-dropdown': '',
number: 0,
currency: 0,
date: null,
'date-range': null,
checkbox: false,
switch: false,
file: null,
'custom:drawing-canvas': null,
'custom:bending-detail-table': null,
'custom:bom-table': null,
};
/**
* 필드 타입 → 컴포넌트 이름 매핑
*/
export const FIELD_TYPE_COMPONENTS: Record<FieldType, string> = {
textbox: 'TextField',
textarea: 'TextField',
dropdown: 'DropdownField',
'searchable-dropdown': 'DropdownField',
number: 'NumberField',
currency: 'NumberField',
date: 'DateField',
'date-range': 'DateField',
checkbox: 'CheckboxField',
switch: 'CheckboxField',
file: 'FileField',
'custom:drawing-canvas': 'CustomField',
'custom:bending-detail-table': 'CustomField',
'custom:bom-table': 'CustomField',
};