refactor: 품목관리 시스템 리팩토링 및 Sales 페이지 추가
DynamicItemForm 개선: - 품목코드 자동생성 기능 추가 - 조건부 표시 로직 개선 - 불필요한 컴포넌트 정리 (DynamicField, DynamicSection 등) - 타입 시스템 단순화 새로운 기능: - Sales 페이지 마이그레이션 (견적관리, 거래처관리) - 공통 컴포넌트 추가 (atoms, molecules, organisms, templates) 문서화: - 구현 문서 및 참조 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,91 +1,46 @@
|
||||
/**
|
||||
* CheckboxField Component
|
||||
*
|
||||
* 체크박스/스위치 필드 (checkbox, switch)
|
||||
* 체크박스 필드 컴포넌트
|
||||
* 기존 ItemForm과 100% 동일한 디자인
|
||||
*/
|
||||
|
||||
'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';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
export function CheckboxField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
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>
|
||||
);
|
||||
}
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const boolValue = value === true || value === 'true' || value === 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={field.field_key}
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={disabled || field.is_readonly}
|
||||
id={fieldKey}
|
||||
checked={boolValue}
|
||||
onCheckedChange={(checked) => onChange(checked as boolean)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
<Label htmlFor={fieldKey} className="cursor-pointer">
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</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 mt-1 ml-6">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500 ml-6">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxField;
|
||||
|
||||
@@ -1,479 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,100 +1,46 @@
|
||||
/**
|
||||
* DateField Component
|
||||
*
|
||||
* 날짜 선택 필드 (date, date-range)
|
||||
* 날짜 입력 필드 컴포넌트
|
||||
* 기존 ItemForm과 100% 동일한 디자인
|
||||
*/
|
||||
|
||||
'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';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
export function DateField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
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();
|
||||
};
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const stringValue = value !== null && value !== undefined ? String(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"
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</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>
|
||||
<Input
|
||||
id={fieldKey}
|
||||
type="date"
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DateField;
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* DropdownField Component
|
||||
*
|
||||
* 드롭다운/선택 필드 (dropdown, searchable-dropdown)
|
||||
* 드롭다운(Select) 필드 컴포넌트
|
||||
* 기존 ItemForm과 100% 동일한 디자인
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,105 +13,110 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps, DropdownOption } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
// 옵션을 {label, value} 형태로 정규화
|
||||
function normalizeOptions(rawOptions: unknown): Array<{ label: string; value: string }> {
|
||||
if (!rawOptions) return [];
|
||||
|
||||
// 문자열인 경우: 콤마로 분리
|
||||
if (typeof rawOptions === 'string') {
|
||||
return rawOptions.split(',').map(o => {
|
||||
const trimmed = o.trim();
|
||||
return { label: trimmed, value: trimmed };
|
||||
});
|
||||
}
|
||||
|
||||
// 배열인 경우
|
||||
if (Array.isArray(rawOptions)) {
|
||||
return rawOptions.map(item => {
|
||||
// 이미 {label, value} 형태
|
||||
if (typeof item === 'object' && item !== null && 'value' in item) {
|
||||
return {
|
||||
label: String(item.label || item.value),
|
||||
value: String(item.value),
|
||||
};
|
||||
}
|
||||
// 문자열 배열
|
||||
const str = String(item);
|
||||
return { label: str, value: str };
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function DropdownField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const [options, setOptions] = useState<DropdownOption[]>(
|
||||
field.dropdown_config?.options || []
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
unitOptions,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
|
||||
// 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]);
|
||||
// field_key 또는 field_name이 '단위'/'unit' 관련이면 unitOptions 사용
|
||||
const isUnitField =
|
||||
fieldKey.toLowerCase().includes('unit') ||
|
||||
fieldKey.includes('단위') ||
|
||||
field.field_name.includes('단위') ||
|
||||
field.field_name.toLowerCase().includes('unit');
|
||||
|
||||
const displayValue = value === null || value === undefined ? '' : String(value);
|
||||
// 옵션 목록 결정
|
||||
let options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
onBlur();
|
||||
};
|
||||
if (isUnitField && unitOptions && unitOptions.length > 0) {
|
||||
options = unitOptions.map((u) => ({
|
||||
label: `${u.label} (${u.value})`,
|
||||
value: u.value,
|
||||
}));
|
||||
} else {
|
||||
// field.options를 정규화
|
||||
options = normalizeOptions(field.options);
|
||||
}
|
||||
|
||||
// 옵션이 없으면 드롭다운을 disabled로 표시
|
||||
const hasOptions = options.length > 0;
|
||||
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={displayValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled || field.is_readonly || isLoading}
|
||||
value={stringValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || !hasOptions}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={field.field_key}
|
||||
className={cn(
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500'
|
||||
)}
|
||||
id={fieldKey}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading
|
||||
? '로딩 중...'
|
||||
: field.dropdown_config?.placeholder || '선택하세요'
|
||||
}
|
||||
/>
|
||||
<SelectValue placeholder={
|
||||
hasOptions
|
||||
? (field.placeholder || `${field.field_name}을(를) 선택하세요`)
|
||||
: '옵션이 없습니다'
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.dropdown_config?.allow_empty && (
|
||||
<SelectItem value="">선택 안함</SelectItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<SelectItem key={`${option.value}-${index}`} value={option.value}>
|
||||
{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 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DropdownField;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 동적 필드 렌더러
|
||||
* field_type에 따라 적절한 필드 컴포넌트를 선택하여 렌더링
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { TextField } from './TextField';
|
||||
import { NumberField } from './NumberField';
|
||||
import { DropdownField } from './DropdownField';
|
||||
import { CheckboxField } from './CheckboxField';
|
||||
import { DateField } from './DateField';
|
||||
import { TextareaField } from './TextareaField';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
export function DynamicFieldRenderer(props: DynamicFieldRendererProps) {
|
||||
const { field } = props;
|
||||
|
||||
switch (field.field_type) {
|
||||
case 'textbox':
|
||||
return <TextField {...props} />;
|
||||
|
||||
case 'number':
|
||||
return <NumberField {...props} />;
|
||||
|
||||
case 'dropdown':
|
||||
return <DropdownField {...props} />;
|
||||
|
||||
case 'checkbox':
|
||||
return <CheckboxField {...props} />;
|
||||
|
||||
case 'date':
|
||||
return <DateField {...props} />;
|
||||
|
||||
case 'textarea':
|
||||
return <TextareaField {...props} />;
|
||||
|
||||
default:
|
||||
// 알 수 없는 타입은 텍스트 필드로 폴백
|
||||
console.warn(`Unknown field type: ${field.field_type}, falling back to TextField`);
|
||||
return <TextField {...props} />;
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,122 +1,59 @@
|
||||
/**
|
||||
* NumberField Component
|
||||
*
|
||||
* 숫자 입력 필드 (number, currency)
|
||||
* 숫자 입력 필드 컴포넌트
|
||||
* 기존 ItemForm과 100% 동일한 디자인
|
||||
*/
|
||||
|
||||
'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;
|
||||
}
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
export function NumberField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const isCurrency = field.field_type === 'currency';
|
||||
const numValue = typeof value === 'number' ? value : parseFormattedNumber(String(value || '0'));
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
|
||||
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);
|
||||
// properties에서 단위, 정밀도 등 추출
|
||||
const unit = field.properties?.unit as string | undefined;
|
||||
const step = field.properties?.precision ? Math.pow(10, -field.properties.precision) : 1;
|
||||
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{unit && ` (${unit})`}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</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>
|
||||
<Input
|
||||
id={fieldKey}
|
||||
type="number"
|
||||
placeholder={field.placeholder || `${field.field_name}을(를) 입력하세요`}
|
||||
value={stringValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
// 빈 문자열이면 null, 아니면 숫자로 변환
|
||||
onChange(newValue === '' ? null : Number(newValue));
|
||||
}}
|
||||
disabled={disabled}
|
||||
step={step}
|
||||
min={field.validation_rules?.min}
|
||||
max={field.validation_rules?.max}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberField;
|
||||
}
|
||||
@@ -1,83 +1,47 @@
|
||||
/**
|
||||
* TextField Component
|
||||
*
|
||||
* 텍스트 입력 필드 (textbox, textarea)
|
||||
* 텍스트 입력 필드 컴포넌트
|
||||
* 기존 ItemForm과 100% 동일한 디자인
|
||||
*/
|
||||
|
||||
'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';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
export function TextField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
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);
|
||||
};
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const stringValue = value !== null && value !== undefined ? String(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"
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</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'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
id={fieldKey}
|
||||
type="text"
|
||||
placeholder={field.placeholder || `${field.field_name}을(를) 입력하세요`}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextField;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 텍스트영역(Textarea) 필드 컴포넌트
|
||||
* 기존 ItemForm과 100% 동일한 디자인
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
export function TextareaField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
|
||||
// properties에서 rows 추출
|
||||
const rows = field.properties?.rows as number | undefined;
|
||||
|
||||
return (
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={fieldKey}
|
||||
placeholder={field.placeholder || `${field.field_name}을(를) 입력하세요`}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={rows || 3}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* 동적 필드 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
export { TextField } from './TextField';
|
||||
export { DropdownField } from './DropdownField';
|
||||
export { NumberField } from './NumberField';
|
||||
export { DateField } from './DateField';
|
||||
export { DropdownField } from './DropdownField';
|
||||
export { CheckboxField } from './CheckboxField';
|
||||
export { FileField } from './FileField';
|
||||
export { CustomField } from './CustomField';
|
||||
export { DateField } from './DateField';
|
||||
export { TextareaField } from './TextareaField';
|
||||
export { DynamicFieldRenderer } from './DynamicFieldRenderer';
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* 동적 폼 훅 인덱스
|
||||
*/
|
||||
|
||||
export { useFormStructure, clearFormStructureCache, invalidateFormStructureCache } from './useFormStructure';
|
||||
export { useConditionalFields } from './useConditionalFields';
|
||||
export { useFormStructure } from './useFormStructure';
|
||||
export { useDynamicFormState } from './useDynamicFormState';
|
||||
export { useConditionalDisplay } from './useConditionalDisplay';
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 조건부 표시 훅
|
||||
*
|
||||
* display_condition을 기반으로 섹션/필드의 visibility를 결정
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
DynamicFormStructure,
|
||||
DynamicFormData,
|
||||
DisplayCondition,
|
||||
FieldConditionConfig,
|
||||
} from '../types';
|
||||
|
||||
interface ConditionalDisplayResult {
|
||||
/** 섹션이 표시되어야 하는지 확인 */
|
||||
shouldShowSection: (sectionId: number) => boolean;
|
||||
/** 필드가 표시되어야 하는지 확인 */
|
||||
shouldShowField: (fieldId: number) => boolean;
|
||||
/** 조건부 표시 설정이 있는 트리거 필드 목록 */
|
||||
triggerFields: Array<{
|
||||
fieldKey: string;
|
||||
fieldId: number;
|
||||
condition: DisplayCondition;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 표시 훅
|
||||
*
|
||||
* @param structure - 폼 구조
|
||||
* @param formData - 현재 폼 데이터
|
||||
* @returns 조건부 표시 관련 함수들
|
||||
*/
|
||||
export function useConditionalDisplay(
|
||||
structure: DynamicFormStructure | null,
|
||||
formData: DynamicFormData
|
||||
): ConditionalDisplayResult {
|
||||
// 조건부 표시 설정이 있는 필드들 수집
|
||||
const triggerFields = useMemo(() => {
|
||||
if (!structure) return [];
|
||||
|
||||
const triggers: ConditionalDisplayResult['triggerFields'] = [];
|
||||
|
||||
// 모든 섹션의 필드 검사
|
||||
structure.sections.forEach((section) => {
|
||||
section.fields.forEach((dynamicField) => {
|
||||
const field = dynamicField.field;
|
||||
if (field.display_condition) {
|
||||
const condition = field.display_condition as DisplayCondition;
|
||||
triggers.push({
|
||||
fieldKey: field.field_key || `field_${field.id}`,
|
||||
fieldId: field.id,
|
||||
condition,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 직접 필드도 검사
|
||||
structure.directFields.forEach((dynamicField) => {
|
||||
const field = dynamicField.field;
|
||||
if (field.display_condition) {
|
||||
const condition = field.display_condition as DisplayCondition;
|
||||
triggers.push({
|
||||
fieldKey: field.field_key || `field_${field.id}`,
|
||||
fieldId: field.id,
|
||||
condition,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 디버깅: 조건부 표시 설정 확인
|
||||
console.log('[useConditionalDisplay] 트리거 필드 목록:', triggers.map(t => ({
|
||||
fieldKey: t.fieldKey,
|
||||
fieldId: t.fieldId,
|
||||
fieldConditions: t.condition.fieldConditions?.map(fc => ({
|
||||
expectedValue: fc.expectedValue,
|
||||
targetFieldIds: fc.targetFieldIds,
|
||||
targetSectionIds: fc.targetSectionIds,
|
||||
})),
|
||||
})));
|
||||
|
||||
return triggers;
|
||||
}, [structure]);
|
||||
|
||||
// 현재 활성화된 조건들 계산
|
||||
const activeConditions = useMemo(() => {
|
||||
const activeSectionIds = new Set<string>();
|
||||
const activeFieldIds = new Set<string>();
|
||||
|
||||
// 각 트리거 필드의 현재 값 확인
|
||||
triggerFields.forEach((trigger) => {
|
||||
const currentValue = formData[trigger.fieldKey];
|
||||
const condition = trigger.condition;
|
||||
|
||||
// fieldConditions 배열 순회
|
||||
if (condition.fieldConditions) {
|
||||
condition.fieldConditions.forEach((fc: FieldConditionConfig) => {
|
||||
// 현재 값과 기대값이 일치하는지 확인
|
||||
const isMatch = String(currentValue) === fc.expectedValue;
|
||||
|
||||
// 디버깅: 조건 매칭 확인
|
||||
console.log('[useConditionalDisplay] 조건 매칭 체크:', {
|
||||
triggerFieldKey: trigger.fieldKey,
|
||||
currentValue: String(currentValue),
|
||||
expectedValue: fc.expectedValue,
|
||||
isMatch,
|
||||
targetFieldIds: fc.targetFieldIds,
|
||||
});
|
||||
|
||||
if (isMatch) {
|
||||
// 일치하면 타겟 섹션/필드 활성화
|
||||
if (fc.targetSectionIds) {
|
||||
fc.targetSectionIds.forEach((id) => activeSectionIds.add(id));
|
||||
}
|
||||
if (fc.targetFieldIds) {
|
||||
fc.targetFieldIds.forEach((id) => activeFieldIds.add(id));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[useConditionalDisplay] 활성화된 필드 ID:', [...activeFieldIds]);
|
||||
|
||||
return { activeSectionIds, activeFieldIds };
|
||||
}, [triggerFields, formData]);
|
||||
|
||||
// 조건부 표시가 적용되는 섹션 ID 목록
|
||||
const conditionalSectionIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
triggerFields.forEach((trigger) => {
|
||||
const condition = trigger.condition;
|
||||
if (condition.fieldConditions) {
|
||||
condition.fieldConditions.forEach((fc: FieldConditionConfig) => {
|
||||
if (fc.targetSectionIds) {
|
||||
fc.targetSectionIds.forEach((id) => ids.add(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ids;
|
||||
}, [triggerFields]);
|
||||
|
||||
// 조건부 표시가 적용되는 필드 ID 목록
|
||||
const conditionalFieldIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
triggerFields.forEach((trigger) => {
|
||||
const condition = trigger.condition;
|
||||
if (condition.fieldConditions) {
|
||||
condition.fieldConditions.forEach((fc: FieldConditionConfig) => {
|
||||
if (fc.targetFieldIds) {
|
||||
fc.targetFieldIds.forEach((id) => ids.add(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ids;
|
||||
}, [triggerFields]);
|
||||
|
||||
// 섹션 표시 여부 확인
|
||||
const shouldShowSection = useMemo(() => {
|
||||
return (sectionId: number): boolean => {
|
||||
const sectionIdStr = String(sectionId);
|
||||
|
||||
// 이 섹션이 조건부 표시 대상인지 확인
|
||||
if (!conditionalSectionIds.has(sectionIdStr)) {
|
||||
// 조건부 표시 대상이 아니면 항상 표시
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건부 표시 대상이면 활성화된 섹션인지 확인
|
||||
return activeConditions.activeSectionIds.has(sectionIdStr);
|
||||
};
|
||||
}, [conditionalSectionIds, activeConditions]);
|
||||
|
||||
// 필드 표시 여부 확인
|
||||
const shouldShowField = useMemo(() => {
|
||||
return (fieldId: number): boolean => {
|
||||
const fieldIdStr = String(fieldId);
|
||||
|
||||
// 이 필드가 조건부 표시 대상인지 확인
|
||||
if (!conditionalFieldIds.has(fieldIdStr)) {
|
||||
// 조건부 표시 대상이 아니면 항상 표시
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건부 표시 대상이면 활성화된 필드인지 확인
|
||||
return activeConditions.activeFieldIds.has(fieldIdStr);
|
||||
};
|
||||
}, [conditionalFieldIds, activeConditions]);
|
||||
|
||||
return {
|
||||
shouldShowSection,
|
||||
shouldShowField,
|
||||
triggerFields,
|
||||
};
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,341 +1,188 @@
|
||||
/**
|
||||
* useDynamicFormState Hook
|
||||
* 동적 폼 상태 관리 훅
|
||||
*
|
||||
* 동적 폼의 상태 관리 훅
|
||||
* - 필드 값 관리
|
||||
* - 유효성 검증
|
||||
* - 에러 상태 관리
|
||||
* - 폼 제출 처리
|
||||
* - 밸리데이션
|
||||
* - 에러 관리
|
||||
* - 폼 제출
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
FormState,
|
||||
FormData,
|
||||
FormValue,
|
||||
DynamicSection,
|
||||
DynamicField,
|
||||
UseDynamicFormStateReturn,
|
||||
ValidationRules,
|
||||
FIELD_TYPE_DEFAULTS,
|
||||
DynamicFormData,
|
||||
DynamicFormErrors,
|
||||
DynamicFieldValue,
|
||||
UseDynamicFormStateResult,
|
||||
} from '../types';
|
||||
import type { ItemFieldResponse } from '@/types/item-master-api';
|
||||
|
||||
/**
|
||||
* 폼 구조에서 초기 값 생성
|
||||
*/
|
||||
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;
|
||||
initialData?: DynamicFormData
|
||||
): UseDynamicFormStateResult {
|
||||
const [formData, setFormData] = useState<DynamicFormData>(initialData || {});
|
||||
const [errors, setErrors] = useState<DynamicFormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 초기 상태 생성
|
||||
const [state, setState] = useState<FormState>(() => ({
|
||||
values: buildInitialValues(sections, initialValues),
|
||||
errors: {},
|
||||
touched: {},
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 단일 필드 값 설정
|
||||
*/
|
||||
const setValue = useCallback((fieldKey: string, value: FormValue) => {
|
||||
setState((prev) => ({
|
||||
// 필드 값 설정
|
||||
const setFieldValue = useCallback((fieldKey: string, value: DynamicFieldValue) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
values: {
|
||||
...prev.values,
|
||||
[fieldKey]: value,
|
||||
},
|
||||
// 값 변경 시 해당 필드 에러 클리어
|
||||
errors: {
|
||||
...prev.errors,
|
||||
[fieldKey]: undefined as unknown as string,
|
||||
},
|
||||
[fieldKey]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 여러 필드 값 일괄 설정
|
||||
*/
|
||||
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 };
|
||||
// 값이 변경되면 해당 필드의 에러 제거
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldKey];
|
||||
return {
|
||||
...prev,
|
||||
errors: newErrors,
|
||||
isValid: Object.keys(newErrors).length === 0,
|
||||
};
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 touched 상태 설정
|
||||
*/
|
||||
const setTouched = useCallback((fieldKey: string) => {
|
||||
setState((prev) => ({
|
||||
// 에러 설정
|
||||
const setError = useCallback((fieldKey: string, error: string) => {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
touched: {
|
||||
...prev.touched,
|
||||
[fieldKey]: true,
|
||||
},
|
||||
[fieldKey]: error,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 전체 폼 유효성 검증
|
||||
*/
|
||||
const validate = useCallback((): boolean => {
|
||||
const errors = validateForm(sections, state.values);
|
||||
const isValid = Object.keys(errors).length === 0;
|
||||
// 에러 제거
|
||||
const clearError = useCallback((fieldKey: string) => {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldKey];
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors,
|
||||
isValid,
|
||||
}));
|
||||
// 모든 에러 제거
|
||||
const clearAllErrors = useCallback(() => {
|
||||
setErrors({});
|
||||
}, []);
|
||||
|
||||
return isValid;
|
||||
}, [sections, state.values]);
|
||||
// 단일 필드 밸리데이션
|
||||
const validateField = useCallback(
|
||||
(field: ItemFieldResponse, value: DynamicFieldValue): string | null => {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
|
||||
/**
|
||||
* 폼 리셋
|
||||
*/
|
||||
const reset = useCallback(
|
||||
(resetValues?: FormData) => {
|
||||
setState({
|
||||
values: buildInitialValues(sections, resetValues || initialValues),
|
||||
errors: {},
|
||||
touched: {},
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
});
|
||||
// 필수 필드 체크
|
||||
if (field.is_required) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return `${field.field_name}은(는) 필수 입력 항목입니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 값이 없으면 추가 검증 스킵
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// validation_rules 적용
|
||||
const rules = field.validation_rules;
|
||||
if (rules) {
|
||||
// min 검증 (숫자)
|
||||
if (rules.min !== undefined && field.field_type === 'number') {
|
||||
const numValue = Number(value);
|
||||
if (numValue < rules.min) {
|
||||
return `${field.field_name}은(는) ${rules.min} 이상이어야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// max 검증 (숫자)
|
||||
if (rules.max !== undefined && field.field_type === 'number') {
|
||||
const numValue = Number(value);
|
||||
if (numValue > rules.max) {
|
||||
return `${field.field_name}은(는) ${rules.max} 이하여야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// minLength 검증 (문자열)
|
||||
if (rules.minLength !== undefined && typeof value === 'string') {
|
||||
if (value.length < rules.minLength) {
|
||||
return `${field.field_name}은(는) ${rules.minLength}자 이상이어야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// maxLength 검증 (문자열)
|
||||
if (rules.maxLength !== undefined && typeof value === 'string') {
|
||||
if (value.length > rules.maxLength) {
|
||||
return `${field.field_name}은(는) ${rules.maxLength}자 이하여야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// pattern 검증 (정규식)
|
||||
if (rules.pattern && typeof value === 'string') {
|
||||
const regex = new RegExp(rules.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return rules.patternMessage || `${field.field_name}의 형식이 올바르지 않습니다.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[sections, initialValues]
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 폼 제출 핸들러 생성
|
||||
*/
|
||||
// 전체 필드 밸리데이션
|
||||
const validateAll = useCallback(
|
||||
(fields: ItemFieldResponse[]): boolean => {
|
||||
const newErrors: DynamicFormErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
for (const field of fields) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const value = formData[fieldKey];
|
||||
const error = validateField(field, value);
|
||||
|
||||
if (error) {
|
||||
newErrors[fieldKey] = error;
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
},
|
||||
[formData, validateField]
|
||||
);
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = useCallback(
|
||||
(submitFn: (data: FormData) => Promise<void>) => {
|
||||
return async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
async (onSubmit: (data: DynamicFormData) => Promise<void>) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// 유효성 검증
|
||||
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,
|
||||
}));
|
||||
}
|
||||
};
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} catch (err) {
|
||||
console.error('폼 제출 실패:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[validate, state.values]
|
||||
[formData]
|
||||
);
|
||||
|
||||
// 폼 초기화
|
||||
const resetForm = useCallback((newInitialData?: DynamicFormData) => {
|
||||
setFormData(newInitialData || {});
|
||||
setErrors({});
|
||||
setIsSubmitting(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
setValue,
|
||||
setValues,
|
||||
formData,
|
||||
errors,
|
||||
isSubmitting,
|
||||
setFieldValue,
|
||||
setError,
|
||||
clearError,
|
||||
setTouched,
|
||||
validate,
|
||||
reset,
|
||||
clearAllErrors,
|
||||
validateField,
|
||||
validateAll,
|
||||
handleSubmit,
|
||||
resetForm,
|
||||
};
|
||||
}
|
||||
|
||||
export default useDynamicFormState;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* DynamicBOMSection - 동적 BOM 섹션
|
||||
*
|
||||
* 기존 BOMSection과 100% 동일한 디자인
|
||||
* 품목 검색 API 연동 (/api/proxy/items?search=xxx)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Fragment, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Check, Package, Plus, Search, Trash2, Loader2 } from 'lucide-react';
|
||||
import type { BOMLine, BOMSearchState, DynamicSection } from '../types';
|
||||
|
||||
// 품목 검색 결과 타입
|
||||
interface SearchedItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
specification?: string;
|
||||
material?: string;
|
||||
unit: string;
|
||||
partType?: string;
|
||||
bendingDiagram?: string;
|
||||
}
|
||||
|
||||
// Debounce 훅
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export interface DynamicBOMSectionProps {
|
||||
section: DynamicSection;
|
||||
bomLines: BOMLine[];
|
||||
setBomLines: (lines: BOMLine[]) => void;
|
||||
bomSearchStates: Record<string, BOMSearchState>;
|
||||
setBomSearchStates: (states: Record<string, BOMSearchState>) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function DynamicBOMSection({
|
||||
section,
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
isSubmitting,
|
||||
}: DynamicBOMSectionProps) {
|
||||
// 품목 검색 상태
|
||||
const [searchResults, setSearchResults] = useState<Record<string, SearchedItem[]>>({});
|
||||
const [isSearching, setIsSearching] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 품목 검색 API 호출
|
||||
const searchItems = useCallback(async (lineId: string, query: string) => {
|
||||
if (!query || query.length < 1) {
|
||||
setSearchResults((prev) => ({ ...prev, [lineId]: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching((prev) => ({ ...prev, [lineId]: true }));
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('search', query);
|
||||
params.append('size', '20');
|
||||
|
||||
const response = await fetch(`/api/proxy/items?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const paginatedData = result.data;
|
||||
const rawItems = paginatedData?.data ?? paginatedData ?? [];
|
||||
|
||||
const mappedItems: SearchedItem[] = rawItems.map((item: Record<string, unknown>) => ({
|
||||
id: String(item.id),
|
||||
itemCode: (item.code ?? item.item_code ?? '') as string,
|
||||
itemName: (item.name ?? item.item_name ?? '') as string,
|
||||
specification: (item.specification ?? '') as string,
|
||||
material: (item.material ?? '') as string,
|
||||
unit: (item.unit ?? 'EA') as string,
|
||||
partType: (item.part_type ?? '') as string,
|
||||
bendingDiagram: (item.bending_diagram ?? '') as string,
|
||||
}));
|
||||
|
||||
setSearchResults((prev) => ({ ...prev, [lineId]: mappedItems }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('품목 검색 실패:', error);
|
||||
setSearchResults((prev) => ({ ...prev, [lineId]: [] }));
|
||||
} finally {
|
||||
setIsSearching((prev) => ({ ...prev, [lineId]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{section.section.title}</CardTitle>
|
||||
{section.section.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{section.section.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
setBomLines([...bomLines, newLine]);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
BOM 품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bomLines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="h-16 w-16 text-muted-foreground mb-4 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
아직 부품 구성이 추가되지 않았습니다
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
품목의 구성 부품, 원자재, 부자재를 추가할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[280px]">품목코드 / 품목명 입력</TableHead>
|
||||
<TableHead className="w-[180px]">품목명</TableHead>
|
||||
<TableHead className="w-[150px]">규격</TableHead>
|
||||
<TableHead className="w-[100px]">재질</TableHead>
|
||||
<TableHead className="w-20">수량</TableHead>
|
||||
<TableHead className="w-16">단위</TableHead>
|
||||
<TableHead className="w-24 text-right">단가</TableHead>
|
||||
<TableHead className="w-[180px]">비고</TableHead>
|
||||
<TableHead className="w-16">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomLines.map((line) => (
|
||||
<BOMLineRow
|
||||
key={line.id}
|
||||
line={line}
|
||||
bomLines={bomLines}
|
||||
setBomLines={setBomLines}
|
||||
bomSearchStates={bomSearchStates}
|
||||
setBomSearchStates={setBomSearchStates}
|
||||
searchResults={searchResults[line.id] || []}
|
||||
isSearching={isSearching[line.id] || false}
|
||||
searchItems={searchItems}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// BOM 라인 Row 컴포넌트 (검색 debounce 처리용)
|
||||
function BOMLineRow({
|
||||
line,
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
searchResults,
|
||||
isSearching,
|
||||
searchItems,
|
||||
}: {
|
||||
line: BOMLine;
|
||||
bomLines: BOMLine[];
|
||||
setBomLines: (lines: BOMLine[]) => void;
|
||||
bomSearchStates: Record<string, BOMSearchState>;
|
||||
setBomSearchStates: (states: Record<string, BOMSearchState>) => void;
|
||||
searchResults: SearchedItem[];
|
||||
isSearching: boolean;
|
||||
searchItems: (lineId: string, query: string) => void;
|
||||
}) {
|
||||
const searchState = bomSearchStates[line.id] || { searchValue: '', isOpen: false };
|
||||
const searchValue = searchState.searchValue;
|
||||
const searchOpen = searchState.isOpen;
|
||||
|
||||
// Debounce 검색
|
||||
const debouncedSearchValue = useDebounce(searchValue, 300);
|
||||
|
||||
// 안정적인 검색 함수 ref
|
||||
const searchItemsRef = useRef(searchItems);
|
||||
searchItemsRef.current = searchItems;
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchValue && searchOpen) {
|
||||
searchItemsRef.current(line.id, debouncedSearchValue);
|
||||
}
|
||||
}, [debouncedSearchValue, searchOpen, line.id]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Popover
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, isOpen: open },
|
||||
});
|
||||
// 팝오버 열릴 때 검색 실행
|
||||
if (open && searchValue) {
|
||||
searchItems(line.id, searchValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
placeholder="품목코드 또는 품목명 입력..."
|
||||
value={line.childItemCode || searchValue}
|
||||
onChange={(e) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: e.target.value, isOpen: true },
|
||||
});
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!line.childItemCode) {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, isOpen: true },
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
readOnly={!!line.childItemCode}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
{line.childItemCode && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: '', isOpen: true },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="품목코드 또는 품목명 검색..."
|
||||
value={searchValue}
|
||||
onValueChange={(value) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, searchValue: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm text-muted-foreground">검색 중...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<CommandEmpty>
|
||||
{searchValue ? '검색 결과가 없습니다.' : '품목코드 또는 품목명을 입력하세요.'}
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<CommandGroup>
|
||||
{searchResults.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.itemCode} ${item.itemName}`}
|
||||
onSelect={() => {
|
||||
const isBendingPart = item.partType === 'BENDING';
|
||||
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id
|
||||
? {
|
||||
...l,
|
||||
childItemCode: item.itemCode || '',
|
||||
childItemName: item.itemName || '',
|
||||
specification: item.specification || '',
|
||||
material: item.material || '',
|
||||
unit: item.unit || 'EA',
|
||||
unitPrice: 0,
|
||||
isBending: isBendingPart,
|
||||
bendingDiagram: isBendingPart
|
||||
? item.bendingDiagram
|
||||
: undefined,
|
||||
}
|
||||
: l
|
||||
)
|
||||
);
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: '', isOpen: false },
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-gray-100 px-2 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
<span className="text-sm">{item.itemName}</span>
|
||||
{item.specification && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({item.specification})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{item.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{line.childItemName || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{line.specification || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<Input
|
||||
value={line.material || ''}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, material: e.target.value } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="재질"
|
||||
className="w-full text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.quantity}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, quantity: Number(e.target.value) } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{line.unit}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.unitPrice || 0}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, unitPrice: Number(e.target.value) } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
min="0"
|
||||
className="w-full text-right"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={line.note || ''}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, note: e.target.value } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="비고"
|
||||
className="w-full text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBomLines(bomLines.filter((l) => l.id !== line.id));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 절곡품인 경우 전개도 정보 표시 */}
|
||||
{line.isBending && line.bendingDiagram && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="bg-blue-50 p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-100 text-blue-700">
|
||||
절곡품 전개도 정보
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 전개도 이미지 */}
|
||||
<div>
|
||||
<Label className="text-xs mb-2 block">전개도 이미지</Label>
|
||||
<div className="border rounded-lg p-2 bg-white">
|
||||
<img
|
||||
src={line.bendingDiagram}
|
||||
alt="절곡 전개도"
|
||||
className="max-w-full h-auto max-h-[300px] object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
2
src/components/items/DynamicItemForm/sections/index.ts
Normal file
2
src/components/items/DynamicItemForm/sections/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as DynamicBOMSection } from './DynamicBOMSection';
|
||||
export type { DynamicBOMSectionProps } from './DynamicBOMSection';
|
||||
@@ -1,402 +1,244 @@
|
||||
/**
|
||||
* 동적 폼 렌더링 타입 정의
|
||||
* DynamicItemForm 타입 정의
|
||||
*
|
||||
* API 응답 구조와 동적 폼 컴포넌트에서 사용하는 타입들
|
||||
* 참조: [PLAN-2025-11-28] dynamic-item-form-implementation.md
|
||||
* 품목기준관리 API 기반 동적 폼 렌더링용 타입
|
||||
*/
|
||||
|
||||
import type { ItemType, PartType } from '@/types/item';
|
||||
import type {
|
||||
ItemPageResponse,
|
||||
ItemSectionResponse,
|
||||
ItemFieldResponse,
|
||||
BomItemResponse,
|
||||
PageStructureResponse,
|
||||
} from '@/types/item-master-api';
|
||||
|
||||
// ===== 필드 타입 =====
|
||||
// ============================================
|
||||
// 조건부 표시 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 필드 입력 타입
|
||||
* 조건부 표시 설정 - 개별 조건
|
||||
*/
|
||||
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 FieldConditionConfig {
|
||||
fieldKey: string; // 조건을 가진 필드의 키
|
||||
expectedValue: string; // 이 값일 때
|
||||
targetFieldIds?: string[]; // 이 필드들 표시
|
||||
targetSectionIds?: string[]; // 이 섹션들 표시
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 섹션 표시 규칙
|
||||
* 필드의 display_condition 구조
|
||||
*/
|
||||
export interface ConditionalSection {
|
||||
condition: Condition;
|
||||
show_sections: number[];
|
||||
hide_sections?: number[];
|
||||
export interface DisplayCondition {
|
||||
targetType: 'field' | 'section';
|
||||
fieldConditions?: FieldConditionConfig[];
|
||||
sectionIds?: string[]; // legacy 지원
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 필드 표시 규칙
|
||||
* 단순화된 단위 옵션 타입 (필드 컴포넌트용)
|
||||
*/
|
||||
export interface ConditionalField {
|
||||
condition: Condition;
|
||||
show_fields: number[];
|
||||
hide_fields?: number[];
|
||||
}
|
||||
|
||||
// ===== 필드 옵션 =====
|
||||
|
||||
/**
|
||||
* 드롭다운 옵션
|
||||
*/
|
||||
export interface DropdownOption {
|
||||
export interface SimpleUnitOption {
|
||||
label: string;
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 폼 구조 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 드롭다운 설정
|
||||
* 동적 폼에 사용되는 페이지 구조
|
||||
*/
|
||||
export interface DropdownConfig {
|
||||
options?: DropdownOption[];
|
||||
options_endpoint?: string; // API에서 옵션 로드
|
||||
placeholder?: string;
|
||||
allow_empty?: boolean;
|
||||
searchable?: boolean;
|
||||
export interface DynamicFormStructure {
|
||||
page: ItemPageResponse;
|
||||
sections: DynamicSection[];
|
||||
directFields: DynamicField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 설정
|
||||
*/
|
||||
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;
|
||||
|
||||
// 섹션 내 필드들
|
||||
section: ItemSectionResponse;
|
||||
orderNo: number;
|
||||
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;
|
||||
bomItems: DynamicBomItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 구조 데이터 (클라이언트용)
|
||||
* 동적 필드 (순서 포함)
|
||||
*/
|
||||
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 DynamicField {
|
||||
field: ItemFieldResponse;
|
||||
orderNo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 상태
|
||||
* 동적 BOM 항목 (순서 포함)
|
||||
*/
|
||||
export interface FormState {
|
||||
values: FormData;
|
||||
errors: Record<string, string>;
|
||||
touched: Record<string, boolean>;
|
||||
isSubmitting: boolean;
|
||||
isValid: boolean;
|
||||
export interface DynamicBomItem {
|
||||
bomItem: BomItemResponse;
|
||||
orderNo: number;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 Props =====
|
||||
// ============================================
|
||||
// BOM 관련 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* DynamicField 컴포넌트 Props
|
||||
* BOM 라인 (폼에서 사용하는 BOM 항목)
|
||||
*/
|
||||
export interface DynamicFieldProps {
|
||||
field: DynamicField;
|
||||
value: FormValue;
|
||||
export interface BOMLine {
|
||||
id: string;
|
||||
childItemCode: string;
|
||||
childItemName: string;
|
||||
specification?: string;
|
||||
material?: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice?: number;
|
||||
note?: string;
|
||||
isBending?: boolean;
|
||||
bendingDiagram?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 검색 상태
|
||||
*/
|
||||
export interface BOMSearchState {
|
||||
searchValue: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 폼 상태 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 동적 폼 필드 값 타입
|
||||
*/
|
||||
export type DynamicFieldValue = string | number | boolean | null | undefined | Record<string, unknown>[] | Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 동적 폼 데이터 (field_key를 key로 사용)
|
||||
*/
|
||||
export type DynamicFormData = Record<string, DynamicFieldValue>;
|
||||
|
||||
/**
|
||||
* 동적 폼 에러
|
||||
*/
|
||||
export type DynamicFormErrors = Record<string, string>;
|
||||
|
||||
// ============================================
|
||||
// 컴포넌트 Props 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* DynamicItemForm 메인 컴포넌트 Props
|
||||
*/
|
||||
export interface DynamicItemFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
|
||||
initialData?: DynamicFormData;
|
||||
onSubmit: (data: DynamicFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 필드 렌더러 Props
|
||||
*/
|
||||
export interface DynamicFieldRendererProps {
|
||||
field: ItemFieldResponse;
|
||||
value: DynamicFieldValue;
|
||||
onChange: (value: DynamicFieldValue) => void;
|
||||
error?: string;
|
||||
onChange: (value: FormValue) => void;
|
||||
onBlur: () => void;
|
||||
disabled?: boolean;
|
||||
unitOptions?: SimpleUnitOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicSection 컴포넌트 Props
|
||||
* 동적 섹션 렌더러 Props
|
||||
*/
|
||||
export interface DynamicSectionProps {
|
||||
export interface DynamicSectionRendererProps {
|
||||
section: DynamicSection;
|
||||
values: FormData;
|
||||
errors: Record<string, string>;
|
||||
onChange: (fieldKey: string, value: FormValue) => void;
|
||||
onBlur: (fieldKey: string) => void;
|
||||
formData: DynamicFormData;
|
||||
errors: DynamicFormErrors;
|
||||
onChange: (fieldKey: string, value: DynamicFieldValue) => void;
|
||||
disabled?: boolean;
|
||||
unitOptions?: SimpleUnitOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// ===== 훅 반환 타입 =====
|
||||
// ============================================
|
||||
// Hook 반환 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* useFormStructure 훅 반환 타입
|
||||
*/
|
||||
export interface UseFormStructureReturn {
|
||||
formStructure: FormStructure | null;
|
||||
export interface UseFormStructureResult {
|
||||
structure: DynamicFormStructure | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
error: string | null;
|
||||
unitOptions: SimpleUnitOption[];
|
||||
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;
|
||||
export interface UseDynamicFormStateResult {
|
||||
formData: DynamicFormData;
|
||||
errors: DynamicFormErrors;
|
||||
isSubmitting: boolean;
|
||||
setFieldValue: (fieldKey: string, value: DynamicFieldValue) => void;
|
||||
setError: (fieldKey: string, error: 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>;
|
||||
clearAllErrors: () => void;
|
||||
validateField: (field: ItemFieldResponse, value: DynamicFieldValue) => string | null;
|
||||
validateAll: (fields: ItemFieldResponse[]) => boolean;
|
||||
handleSubmit: (onSubmit: (data: DynamicFormData) => Promise<void>) => Promise<void>;
|
||||
resetForm: (initialData?: DynamicFormData) => 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 type FieldTypeComponentMap = {
|
||||
textbox: 'TextField';
|
||||
number: 'NumberField';
|
||||
dropdown: 'DropdownField';
|
||||
checkbox: 'CheckboxField';
|
||||
date: 'DateField';
|
||||
textarea: 'TextareaField';
|
||||
};
|
||||
|
||||
/**
|
||||
* 필드 타입 → 컴포넌트 이름 매핑
|
||||
* API 응답을 DynamicFormStructure로 변환
|
||||
*/
|
||||
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',
|
||||
};
|
||||
export function convertToFormStructure(
|
||||
response: PageStructureResponse
|
||||
): DynamicFormStructure {
|
||||
return {
|
||||
page: response.page,
|
||||
sections: response.sections.map((s) => ({
|
||||
section: s.section,
|
||||
orderNo: s.order_no,
|
||||
fields: s.fields.map((f) => ({
|
||||
field: f.field,
|
||||
orderNo: f.order_no,
|
||||
})),
|
||||
bomItems: s.bom_items.map((b) => ({
|
||||
bomItem: b.bom_item,
|
||||
orderNo: b.order_no,
|
||||
})),
|
||||
})),
|
||||
directFields: response.direct_fields.map((f) => ({
|
||||
field: f.field,
|
||||
orderNo: f.order_no,
|
||||
})),
|
||||
};
|
||||
}
|
||||
377
src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts
Normal file
377
src/components/items/DynamicItemForm/utils/itemCodeGenerator.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 품목코드/품목명 자동생성 유틸리티
|
||||
*
|
||||
* MVP용 프론트엔드 구현 - 하드코딩 내역은 추후 백엔드 API로 이관 필요
|
||||
*
|
||||
* @see claudedocs/item-master/[REF] item-code-hardcoding.md
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// [하드코딩] 영문약어 매핑 테이블
|
||||
// TODO: 추후 백엔드 API 또는 품목기준관리에서 설정 가능하도록 변경
|
||||
// ============================================
|
||||
export const ITEM_CODE_PREFIX_MAP: Record<string, string> = {
|
||||
// 부품 - 조립품
|
||||
'가이드레일': 'GR',
|
||||
'케이스': 'CASE',
|
||||
'브라켓': 'BRK',
|
||||
|
||||
// 부품 - 구매품
|
||||
'모터': 'MOTOR',
|
||||
'제어기': 'CTL',
|
||||
'전동개폐기': 'OPENER',
|
||||
'스위치': 'SW',
|
||||
'센서': 'SENSOR',
|
||||
'리모컨': 'REMOTE',
|
||||
|
||||
// 부품 - 절곡품
|
||||
'레일': 'RAIL',
|
||||
'커버': 'COVER',
|
||||
'플레이트': 'PLATE',
|
||||
|
||||
// 제품
|
||||
'스크린': 'SCREEN',
|
||||
'셔터': 'SHUTTER',
|
||||
'방화스크린': 'FIRE-SCR',
|
||||
'롤스크린': 'ROLL-SCR',
|
||||
|
||||
// 원자재
|
||||
'알루미늄': 'ALU',
|
||||
'스틸': 'STEEL',
|
||||
'철판': 'STEEL',
|
||||
|
||||
// 부자재/소모품
|
||||
'볼트': 'BOLT',
|
||||
'너트': 'NUT',
|
||||
'와셔': 'WASHER',
|
||||
'나사': 'SCREW',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// [하드코딩] 절곡품 코드 체계
|
||||
// TODO: 추후 품목기준관리에서 설정 가능하도록 변경
|
||||
// ============================================
|
||||
export const BENDING_CODE_SYSTEM = {
|
||||
// 품목명코드 (category2)
|
||||
품목명코드: {
|
||||
'R': '가이드레일',
|
||||
'S': '스크린',
|
||||
'C': '케이스',
|
||||
'B': '박스',
|
||||
'T': '트림',
|
||||
'L': '라스틱',
|
||||
'G': '기타',
|
||||
} as Record<string, string>,
|
||||
|
||||
// 종류코드 (category3)
|
||||
종류코드: {
|
||||
'M': '마감',
|
||||
'T': '티',
|
||||
'C': '채널',
|
||||
'D': '단면',
|
||||
'S': '상부',
|
||||
'U': '하부',
|
||||
'F': '플랫',
|
||||
'P': '피스',
|
||||
'L': '리드',
|
||||
'B': '브라켓',
|
||||
'E': '엔드',
|
||||
'I': '이음',
|
||||
'A': '각재',
|
||||
} as Record<string, string>,
|
||||
|
||||
// 길이코드 매핑 (mm → 코드)
|
||||
길이코드: {
|
||||
1219: '12',
|
||||
2438: '24',
|
||||
3000: '30',
|
||||
3500: '35',
|
||||
4000: '40',
|
||||
4150: '41',
|
||||
4200: '42',
|
||||
4300: '43',
|
||||
} as Record<number, string>,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// [하드코딩] 조립품 설치유형 매핑
|
||||
// TODO: 추후 품목기준관리에서 설정 가능하도록 변경
|
||||
// ============================================
|
||||
export const INSTALLATION_TYPE_MAP: Record<string, string> = {
|
||||
'standard': '표준형',
|
||||
'top': '상부형',
|
||||
'bottom': '하부형',
|
||||
'side': '측면형',
|
||||
'custom': '맞춤형',
|
||||
};
|
||||
|
||||
/**
|
||||
* 품목명에서 영문약어 추출
|
||||
* @param itemName 품목명 (한글)
|
||||
* @returns 영문약어 또는 기본값
|
||||
*/
|
||||
export function getItemCodePrefix(itemName: string): string {
|
||||
// 정확한 매칭 먼저 시도
|
||||
if (ITEM_CODE_PREFIX_MAP[itemName]) {
|
||||
return ITEM_CODE_PREFIX_MAP[itemName];
|
||||
}
|
||||
|
||||
// 부분 매칭 시도 (품목명에 키워드가 포함된 경우)
|
||||
for (const [keyword, prefix] of Object.entries(ITEM_CODE_PREFIX_MAP)) {
|
||||
if (itemName.includes(keyword)) {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭 실패 시 품목명의 첫 글자들로 생성 (임시)
|
||||
// 예: "새로운품목" → "ITEM" (기본값)
|
||||
return 'ITEM';
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 품목 목록에서 다음 순번 계산
|
||||
* @param existingCodes 기존 품목코드 배열 (예: ["GR-001", "GR-002"])
|
||||
* @param prefix 영문약어 (예: "GR")
|
||||
* @returns 다음 순번 (예: "003")
|
||||
*/
|
||||
export function getNextSequence(existingCodes: string[], prefix: string): string {
|
||||
const pattern = new RegExp(`^${prefix}-(\\d+)$`, 'i');
|
||||
|
||||
let maxSeq = 0;
|
||||
existingCodes.forEach(code => {
|
||||
const match = code.match(pattern);
|
||||
if (match) {
|
||||
const seq = parseInt(match[1], 10);
|
||||
if (seq > maxSeq) {
|
||||
maxSeq = seq;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return String(maxSeq + 1).padStart(3, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목코드 생성 (영문약어-순번)
|
||||
* @param itemName 품목명 (한글)
|
||||
* @param existingCodes 기존 품목코드 배열
|
||||
* @returns 새 품목코드 (예: "GR-003")
|
||||
*/
|
||||
export function generateItemCode(itemName: string, existingCodes: string[]): string {
|
||||
const prefix = getItemCodePrefix(itemName);
|
||||
const sequence = getNextSequence(existingCodes, prefix);
|
||||
return `${prefix}-${sequence}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 절곡품 품목코드 생성 (품목명코드 + 종류코드 + 길이코드)
|
||||
* @param category2 품목명코드 (R, S, C 등)
|
||||
* @param category3 종류코드 (M, T, C 등)
|
||||
* @param lengthMm 길이 (mm)
|
||||
* @returns 품목코드 (예: "RC24")
|
||||
*/
|
||||
export function generateBendingItemCode(
|
||||
category2: string,
|
||||
category3: string,
|
||||
lengthMm: number
|
||||
): string {
|
||||
// 길이코드 변환
|
||||
let lengthCode = BENDING_CODE_SYSTEM.길이코드[lengthMm];
|
||||
if (!lengthCode) {
|
||||
// 매핑에 없으면 100으로 나눈 값 사용
|
||||
lengthCode = String(Math.floor(lengthMm / 100)).padStart(2, '0');
|
||||
}
|
||||
|
||||
return `${category2}${category3}${lengthCode}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조립품 품목명 생성 (품목명 + 설치유형 + 측면규격*길이코드)
|
||||
* @param itemName 기본 품목명 (가이드레일)
|
||||
* @param installationType 설치유형 (standard → 표준형)
|
||||
* @param sideSpecWidth 측면규격 너비
|
||||
* @param sideSpecHeight 측면규격 높이
|
||||
* @param lengthMm 길이 (mm)
|
||||
* @returns 조합된 품목명 (예: "가이드레일표준형50*60*24")
|
||||
*/
|
||||
export function generateAssemblyItemName(
|
||||
itemName: string,
|
||||
installationType: string,
|
||||
sideSpecWidth?: number,
|
||||
sideSpecHeight?: number,
|
||||
lengthMm?: number
|
||||
): string {
|
||||
const installationTypeKorean = INSTALLATION_TYPE_MAP[installationType] || installationType;
|
||||
|
||||
let result = `${itemName}${installationTypeKorean}`;
|
||||
|
||||
if (sideSpecWidth && sideSpecHeight && lengthMm) {
|
||||
// 길이코드 변환
|
||||
let lengthCode = BENDING_CODE_SYSTEM.길이코드[lengthMm];
|
||||
if (!lengthCode) {
|
||||
lengthCode = String(Math.floor(lengthMm / 100)).padStart(2, '0');
|
||||
}
|
||||
|
||||
result += `${sideSpecWidth}*${sideSpecHeight}*${lengthCode}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 절곡품 품목명 생성 (품목명 + 종류 + 규격)
|
||||
* @param category2Label 품목명 라벨 (가이드레일)
|
||||
* @param category3Label 종류 라벨 (채널)
|
||||
* @param specification 규격
|
||||
* @returns 조합된 품목명
|
||||
*/
|
||||
export function generateBendingItemName(
|
||||
category2Label: string,
|
||||
category3Label: string,
|
||||
specification?: string
|
||||
): string {
|
||||
let result = `${category2Label} ${category3Label}`;
|
||||
if (specification) {
|
||||
result += ` ${specification}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구매품 품목명 생성 (품목명 + 규격)
|
||||
* @param itemName 기본 품목명
|
||||
* @param specification 규격
|
||||
* @returns 조합된 품목명 (예: "모터 0.4KW")
|
||||
*/
|
||||
export function generatePurchasedItemName(
|
||||
itemName: string,
|
||||
specification?: string
|
||||
): string {
|
||||
if (specification) {
|
||||
return `${itemName} ${specification}`;
|
||||
}
|
||||
return itemName;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 절곡 부품 품목코드 자동생성 (간소화 버전)
|
||||
// 2025-12-03 추가
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 문자열에서 마지막 괄호 안의 단일 문자 추출
|
||||
* @param str 입력 문자열 (예: "가이드레일(벽면형) (R)")
|
||||
* @returns 괄호 안의 문자 (예: "R") 또는 빈 문자열
|
||||
*/
|
||||
export function extractParenthesisCode(str: string): string {
|
||||
if (!str) return '';
|
||||
|
||||
// 마지막 괄호 안의 내용 추출 (예: "(R)" → "R")
|
||||
const match = str.match(/\(([A-Za-z가-힣])\)\s*$/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// 대안: 마지막 괄호가 없으면 앞쪽 괄호에서 추출 시도
|
||||
const altMatch = str.match(/\(([A-Za-z가-힣])\)/);
|
||||
return altMatch ? altMatch[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 절곡품 품목코드 생성 (품목명코드 + 종류코드)
|
||||
* @param itemNameValue 품목명 드롭다운 값 (예: "가이드레일(벽면형) (R)")
|
||||
* @param categoryValue 종류 드롭다운 값 (예: "본체 (M)")
|
||||
* @param shapeLengthValue 모양&길이 드롭다운 값 (예: "2438" 또는 "30")
|
||||
* @returns 품목코드 (예: "RM" 또는 "RM30")
|
||||
*/
|
||||
export function generateBendingItemCodeSimple(
|
||||
itemNameValue: string,
|
||||
categoryValue: string,
|
||||
shapeLengthValue?: string
|
||||
): string {
|
||||
const itemNameCode = extractParenthesisCode(itemNameValue);
|
||||
const categoryCode = extractParenthesisCode(categoryValue);
|
||||
|
||||
if (!itemNameCode && !categoryValue) return '';
|
||||
|
||||
let code = `${itemNameCode}${categoryCode}`;
|
||||
|
||||
// 모양&길이가 있으면 길이 축약 추가
|
||||
if (shapeLengthValue) {
|
||||
// 숫자만 추출
|
||||
const lengthNum = parseInt(shapeLengthValue.replace(/[^0-9]/g, ''), 10);
|
||||
if (lengthNum > 0) {
|
||||
// 100으로 나눈 값 (예: 2438 → 24, 3000 → 30)
|
||||
const lengthCode = Math.floor(lengthNum / 100).toString();
|
||||
code += lengthCode;
|
||||
}
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 조립 부품 품목명/규격 자동생성
|
||||
// 2025-12-03 추가
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 조립 부품 품목명 생성 (품목명 + 가로x세로)
|
||||
* @param itemName 선택한 품목명 (가이드레일)
|
||||
* @param sideSpecWidth 측면규격 가로 (mm)
|
||||
* @param sideSpecHeight 측면규격 세로 (mm)
|
||||
* @returns 조합된 품목명 (예: "가이드레일 50x60")
|
||||
*/
|
||||
export function generateAssemblyItemNameSimple(
|
||||
itemName: string,
|
||||
sideSpecWidth?: number | string,
|
||||
sideSpecHeight?: number | string
|
||||
): string {
|
||||
if (!itemName) return '';
|
||||
|
||||
if (sideSpecWidth && sideSpecHeight) {
|
||||
return `${itemName} ${sideSpecWidth}x${sideSpecHeight}`;
|
||||
}
|
||||
|
||||
return itemName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조립 부품 규격 생성 (가로x세로x길이)
|
||||
* @param sideSpecWidth 측면규격 가로 (mm)
|
||||
* @param sideSpecHeight 측면규격 세로 (mm)
|
||||
* @param assemblyLength 길이 (mm) - 네자리 그대로 사용
|
||||
* @returns 조합된 규격 (예: "50x60x2438")
|
||||
*/
|
||||
export function generateAssemblySpecification(
|
||||
sideSpecWidth?: number | string,
|
||||
sideSpecHeight?: number | string,
|
||||
assemblyLength?: number | string
|
||||
): string {
|
||||
if (!sideSpecWidth || !sideSpecHeight || !assemblyLength) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${sideSpecWidth}x${sideSpecHeight}x${assemblyLength}`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 하드코딩 내역 목록 (문서화용)
|
||||
// ============================================
|
||||
export const HARDCODED_ITEMS = {
|
||||
ITEM_CODE_PREFIX_MAP: {
|
||||
description: '품목명 → 영문약어 매핑 테이블',
|
||||
location: 'itemCodeGenerator.ts',
|
||||
migrationTarget: '품목기준관리 API 또는 별도 설정 테이블',
|
||||
},
|
||||
BENDING_CODE_SYSTEM: {
|
||||
description: '절곡품 코드 체계 (품목명코드, 종류코드, 길이코드)',
|
||||
location: 'itemCodeGenerator.ts',
|
||||
migrationTarget: '품목기준관리 API 또는 별도 설정 테이블',
|
||||
},
|
||||
INSTALLATION_TYPE_MAP: {
|
||||
description: '조립품 설치유형 매핑',
|
||||
location: 'itemCodeGenerator.ts',
|
||||
migrationTarget: '품목기준관리 API 또는 별도 설정 테이블',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user