feat: 품목관리 동적 렌더링 시스템 구현
- DynamicItemForm 컴포넌트 구조 생성 - DynamicField: 필드 타입별 렌더링 - DynamicSection: 섹션 단위 렌더링 - DynamicFormRenderer: 페이지 전체 렌더링 - 필드 타입별 컴포넌트 (TextField, NumberField, DropdownField, CheckboxField, DateField, FileField, CustomField) - 커스텀 훅 (useDynamicFormState, useFormStructure, useConditionalFields) - DataTable 공통 컴포넌트 (테이블, 페이지네이션, 검색, 탭필터, 통계카드) - ItemFormWrapper: Feature Flag 기반 폼 선택 - 타입 정의 및 문서화 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* CheckboxField Component
|
||||
*
|
||||
* 체크박스/스위치 필드 (checkbox, switch)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CheckboxField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const isSwitch = field.field_type === 'switch';
|
||||
const checked = value === true || value === 'true' || value === 1;
|
||||
|
||||
const handleChange = (newChecked: boolean) => {
|
||||
onChange(newChecked);
|
||||
onBlur();
|
||||
};
|
||||
|
||||
if (isSwitch) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
<Switch
|
||||
id={field.field_key}
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={disabled || field.is_readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={field.field_key}
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={disabled || field.is_readonly}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium cursor-pointer',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500",
|
||||
(disabled || field.is_readonly) && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground ml-6">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500 ml-6">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxField;
|
||||
479
src/components/items/DynamicItemForm/fields/CustomField.tsx
Normal file
479
src/components/items/DynamicItemForm/fields/CustomField.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* CustomField Component
|
||||
*
|
||||
* 특수 필드 컴포넌트 래퍼 (전개도, BOM 등)
|
||||
* - custom:drawing-canvas → DrawingCanvas
|
||||
* - custom:bending-detail-table → BendingDetailTable (전개도 상세 테이블)
|
||||
* - custom:bom-table → BOMSection
|
||||
*
|
||||
* 기존 ItemForm의 특수 컴포넌트를 재사용하면서 동적 폼과 통합
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FileImage, Plus, Trash2 } from 'lucide-react';
|
||||
import type { DynamicFieldProps, FormValue } from '../types';
|
||||
import type { BendingDetail, BOMLine } from '@/types/item';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ===== BOM 테이블 컴포넌트 =====
|
||||
|
||||
interface BOMTableProps {
|
||||
value: BOMLine[];
|
||||
onChange: (lines: BOMLine[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function BOMTable({ value, onChange, disabled }: BOMTableProps) {
|
||||
const bomLines = Array.isArray(value) ? value : [];
|
||||
|
||||
const addLine = () => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
onChange([...bomLines, newLine]);
|
||||
};
|
||||
|
||||
const updateLine = (index: number, field: keyof BOMLine, fieldValue: string | number) => {
|
||||
const updated = [...bomLines];
|
||||
updated[index] = { ...updated[index], [field]: fieldValue };
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const removeLine = (index: number) => {
|
||||
onChange(bomLines.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">부품 구성 (BOM)</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{bomLines.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
|
||||
<p>등록된 BOM 항목이 없습니다.</p>
|
||||
<p className="text-sm mt-1">위 버튼을 클릭하여 품목을 추가하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-2">
|
||||
<div className="col-span-3">품목코드</div>
|
||||
<div className="col-span-4">품목명</div>
|
||||
<div className="col-span-2">수량</div>
|
||||
<div className="col-span-2">단위</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
{bomLines.map((line, index) => (
|
||||
<div key={line.id} className="grid grid-cols-12 gap-2 items-center">
|
||||
<Input
|
||||
className="col-span-3 h-8 text-sm"
|
||||
placeholder="품목코드"
|
||||
value={line.childItemCode}
|
||||
onChange={(e) => updateLine(index, 'childItemCode', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
className="col-span-4 h-8 text-sm"
|
||||
placeholder="품목명"
|
||||
value={line.childItemName}
|
||||
onChange={(e) => updateLine(index, 'childItemName', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
className="col-span-2 h-8 text-sm text-right"
|
||||
type="number"
|
||||
min={1}
|
||||
value={line.quantity}
|
||||
onChange={(e) => updateLine(index, 'quantity', parseInt(e.target.value) || 1)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
className="col-span-2 h-8 text-sm"
|
||||
value={line.unit}
|
||||
onChange={(e) => updateLine(index, 'unit', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="col-span-1 h-8 w-8 p-0"
|
||||
onClick={() => removeLine(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 전개도 상세 테이블 컴포넌트 =====
|
||||
|
||||
interface BendingDetailTableProps {
|
||||
value: BendingDetail[];
|
||||
onChange: (details: BendingDetail[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function BendingDetailTable({ value, onChange, disabled }: BendingDetailTableProps) {
|
||||
const details = Array.isArray(value) ? value : [];
|
||||
|
||||
// 폭 합계 계산
|
||||
const totalWidth = details.reduce((acc, d) => acc + d.input + d.elongation, 0);
|
||||
|
||||
const addRow = () => {
|
||||
const newRow: BendingDetail = {
|
||||
id: `bend-${Date.now()}`,
|
||||
no: details.length + 1,
|
||||
input: 0,
|
||||
elongation: -1,
|
||||
calculated: 0,
|
||||
sum: 0,
|
||||
shaded: false,
|
||||
};
|
||||
onChange([...details, newRow]);
|
||||
};
|
||||
|
||||
const updateRow = (index: number, field: keyof BendingDetail, fieldValue: number | boolean) => {
|
||||
const updated = [...details];
|
||||
updated[index] = { ...updated[index], [field]: fieldValue };
|
||||
|
||||
// calculated와 sum 자동 계산
|
||||
if (field === 'input' || field === 'elongation') {
|
||||
const input = field === 'input' ? (fieldValue as number) : updated[index].input;
|
||||
const elongation = field === 'elongation' ? (fieldValue as number) : updated[index].elongation;
|
||||
updated[index].calculated = input + elongation;
|
||||
|
||||
// 누적 합계 재계산
|
||||
let runningSum = 0;
|
||||
for (let i = 0; i <= index; i++) {
|
||||
runningSum += updated[i].input + updated[i].elongation;
|
||||
updated[i].sum = runningSum;
|
||||
}
|
||||
}
|
||||
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const updated = details.filter((_, i) => i !== index);
|
||||
// 번호 재정렬
|
||||
updated.forEach((row, i) => {
|
||||
row.no = i + 1;
|
||||
});
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
전개도 상세 입력
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
폭 합계: <strong className="text-foreground">{totalWidth.toFixed(1)} mm</strong>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addRow}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
행 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{details.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
|
||||
<p>전개도 상세 데이터가 없습니다.</p>
|
||||
<p className="text-sm mt-1">위 버튼을 클릭하여 행을 추가하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-2">
|
||||
<div className="col-span-1">No</div>
|
||||
<div className="col-span-2">입력값</div>
|
||||
<div className="col-span-2">연신율</div>
|
||||
<div className="col-span-2">계산값</div>
|
||||
<div className="col-span-2">합계</div>
|
||||
<div className="col-span-2">음영</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
{details.map((row, index) => (
|
||||
<div key={row.id} className="grid grid-cols-12 gap-2 items-center">
|
||||
<div className="col-span-1 text-sm text-center text-muted-foreground">
|
||||
{row.no}
|
||||
</div>
|
||||
<Input
|
||||
className="col-span-2 h-8 text-sm text-right"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={row.input}
|
||||
onChange={(e) => updateRow(index, 'input', parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
className="col-span-2 h-8 text-sm text-right"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={row.elongation}
|
||||
onChange={(e) => updateRow(index, 'elongation', parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="col-span-2 text-sm text-right pr-3 text-muted-foreground">
|
||||
{(row.input + row.elongation).toFixed(1)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-right pr-3 font-medium">
|
||||
{row.sum.toFixed(1)}
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.shaded}
|
||||
onChange={(e) => updateRow(index, 'shaded', e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="col-span-1 h-8 w-8 p-0"
|
||||
onClick={() => removeRow(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 전개도 캔버스 (간단 버전) =====
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
value: string | null;
|
||||
onChange: (dataUrl: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DrawingCanvasSimple({ value, onChange, disabled }: DrawingCanvasProps) {
|
||||
const [inputMethod, setInputMethod] = useState<'file' | 'drawing'>('file');
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
onChange(event.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileImage className="h-4 w-4" />
|
||||
전개도 이미지
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 입력 방식 선택 */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="drawingMethod"
|
||||
checked={inputMethod === 'file'}
|
||||
onChange={() => setInputMethod('file')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm">파일 업로드</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="drawingMethod"
|
||||
checked={inputMethod === 'drawing'}
|
||||
onChange={() => setInputMethod('drawing')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm">직접 그리기 (준비 중)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 */}
|
||||
{inputMethod === 'file' && (
|
||||
<div>
|
||||
{value ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={value}
|
||||
alt="전개도"
|
||||
className="max-w-full h-auto border rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={handleClear}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
id="drawing-file-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="drawing-file-input"
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FileImage className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>클릭하여 이미지를 업로드하세요</p>
|
||||
<p className="text-xs mt-1">PNG, JPG, GIF 지원</p>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 직접 그리기 (플레이스홀더) */}
|
||||
{inputMethod === 'drawing' && (
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
|
||||
<p>직접 그리기 기능은 준비 중입니다.</p>
|
||||
<p className="text-sm mt-1">기존 DrawingCanvas 컴포넌트와 통합 예정</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 메인 CustomField 컴포넌트 =====
|
||||
|
||||
export function CustomField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const renderCustomComponent = () => {
|
||||
switch (field.field_type) {
|
||||
case 'custom:drawing-canvas':
|
||||
return (
|
||||
<DrawingCanvasSimple
|
||||
value={value as string | null}
|
||||
onChange={(dataUrl) => {
|
||||
onChange(dataUrl);
|
||||
onBlur();
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom:bending-detail-table':
|
||||
return (
|
||||
<BendingDetailTable
|
||||
value={value as BendingDetail[] || []}
|
||||
onChange={(details) => {
|
||||
onChange(details as unknown as FormValue);
|
||||
onBlur();
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom:bom-table':
|
||||
return (
|
||||
<BOMTable
|
||||
value={value as BOMLine[] || []}
|
||||
onChange={(lines) => {
|
||||
onChange(lines as unknown as FormValue);
|
||||
onBlur();
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-4 border border-orange-200 bg-orange-50 rounded-md">
|
||||
<p className="text-sm text-orange-600">
|
||||
알 수 없는 커스텀 필드 타입: {field.field_type}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 커스텀 필드는 자체 레이블이 있으므로 별도 레이블 불필요
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderCustomComponent()}
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomField;
|
||||
100
src/components/items/DynamicItemForm/fields/DateField.tsx
Normal file
100
src/components/items/DynamicItemForm/fields/DateField.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* DateField Component
|
||||
*
|
||||
* 날짜 선택 필드 (date, date-range)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function DateField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// 값을 Date 객체로 변환
|
||||
const dateValue = value ? new Date(value as string) : undefined;
|
||||
const isValidDate = dateValue && !isNaN(dateValue.getTime());
|
||||
|
||||
const handleSelect = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
// ISO 문자열로 변환 (YYYY-MM-DD)
|
||||
onChange(format(date, 'yyyy-MM-dd'));
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
setOpen(false);
|
||||
onBlur();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={field.field_key}
|
||||
variant="outline"
|
||||
disabled={disabled || field.is_readonly}
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!isValidDate && 'text-muted-foreground',
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{isValidDate
|
||||
? format(dateValue, 'yyyy년 MM월 dd일', { locale: ko })
|
||||
: field.placeholder || '날짜를 선택하세요'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={isValidDate ? dateValue : undefined}
|
||||
onSelect={handleSelect}
|
||||
locale={ko}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DateField;
|
||||
118
src/components/items/DynamicItemForm/fields/DropdownField.tsx
Normal file
118
src/components/items/DynamicItemForm/fields/DropdownField.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* DropdownField Component
|
||||
*
|
||||
* 드롭다운/선택 필드 (dropdown, searchable-dropdown)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps, DropdownOption } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function DropdownField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const [options, setOptions] = useState<DropdownOption[]>(
|
||||
field.dropdown_config?.options || []
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// API에서 옵션 로드 (options_endpoint가 있는 경우)
|
||||
useEffect(() => {
|
||||
if (field.dropdown_config?.options_endpoint) {
|
||||
setIsLoading(true);
|
||||
fetch(field.dropdown_config.options_endpoint)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setOptions(data.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[DropdownField] Failed to load options:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [field.dropdown_config?.options_endpoint]);
|
||||
|
||||
const displayValue = value === null || value === undefined ? '' : String(value);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
onBlur();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={displayValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled || field.is_readonly || isLoading}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={field.field_key}
|
||||
className={cn(
|
||||
error && 'border-red-500 focus:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500'
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading
|
||||
? '로딩 중...'
|
||||
: field.dropdown_config?.placeholder || '선택하세요'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.dropdown_config?.allow_empty && (
|
||||
<SelectItem value="">선택 안함</SelectItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DropdownField;
|
||||
203
src/components/items/DynamicItemForm/fields/FileField.tsx
Normal file
203
src/components/items/DynamicItemForm/fields/FileField.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* FileField Component
|
||||
*
|
||||
* 파일 업로드 필드
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Upload, X, FileText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* 파일 크기 포맷팅
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function FileField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const fileConfig = field.file_config || {};
|
||||
const accept = fileConfig.accept || '*';
|
||||
const maxSize = fileConfig.max_size || 10 * 1024 * 1024; // 기본 10MB
|
||||
const multiple = fileConfig.multiple || false;
|
||||
|
||||
// 현재 파일(들)
|
||||
const files: File[] = Array.isArray(value)
|
||||
? value
|
||||
: value instanceof File
|
||||
? [value]
|
||||
: [];
|
||||
|
||||
const handleFileSelect = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const validFiles: File[] = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
|
||||
// 파일 크기 검사
|
||||
if (file.size > maxSize) {
|
||||
alert(`"${file.name}" 파일이 너무 큽니다. 최대 ${formatFileSize(maxSize)}까지 업로드 가능합니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
if (multiple) {
|
||||
onChange([...files, ...validFiles]);
|
||||
} else {
|
||||
onChange(validFiles[0]);
|
||||
}
|
||||
onBlur();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFileSelect(e.target.files);
|
||||
// 같은 파일 재선택 허용을 위해 리셋
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (disabled || field.is_readonly) return;
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (disabled || field.is_readonly) return;
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
if (multiple) {
|
||||
const newFiles = files.filter((_, i) => i !== index);
|
||||
onChange(newFiles.length > 0 ? newFiles : null);
|
||||
} else {
|
||||
onChange(null);
|
||||
}
|
||||
onBlur();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled || field.is_readonly) return;
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors',
|
||||
dragOver && 'border-primary bg-primary/5',
|
||||
error && 'border-red-500',
|
||||
(disabled || field.is_readonly) && 'opacity-50 cursor-not-allowed bg-gray-50',
|
||||
!dragOver && !error && 'border-gray-300 hover:border-gray-400'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleInputChange}
|
||||
disabled={disabled || field.is_readonly}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Upload className="mx-auto h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
클릭하거나 파일을 드래그하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{accept !== '*' ? `허용 형식: ${accept}` : '모든 형식 허용'} |
|
||||
최대 {formatFileSize(maxSize)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 선택된 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="h-4 w-4 text-gray-500 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!disabled && !field.is_readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFile(index);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileField;
|
||||
122
src/components/items/DynamicItemForm/fields/NumberField.tsx
Normal file
122
src/components/items/DynamicItemForm/fields/NumberField.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* NumberField Component
|
||||
*
|
||||
* 숫자 입력 필드 (number, currency)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* 숫자 포맷팅 (천단위 콤마)
|
||||
*/
|
||||
function formatNumber(value: number | string, isCurrency: boolean): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return '';
|
||||
|
||||
if (isCurrency) {
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 포맷팅된 문자열을 숫자로 변환
|
||||
*/
|
||||
function parseFormattedNumber(value: string): number {
|
||||
// 콤마 제거 후 숫자로 변환
|
||||
const cleaned = value.replace(/,/g, '');
|
||||
const num = parseFloat(cleaned);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
export function NumberField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const isCurrency = field.field_type === 'currency';
|
||||
const numValue = typeof value === 'number' ? value : parseFormattedNumber(String(value || '0'));
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
// 빈 값 허용
|
||||
if (inputValue === '' || inputValue === '-') {
|
||||
onChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 숫자와 콤마, 소수점, 마이너스만 허용
|
||||
const cleaned = inputValue.replace(/[^\d.,-]/g, '');
|
||||
const num = parseFormattedNumber(cleaned);
|
||||
|
||||
// 유효성 검사
|
||||
const { min, max } = field.validation_rules || {};
|
||||
if (min !== undefined && num < min) return;
|
||||
if (max !== undefined && num > max) return;
|
||||
|
||||
onChange(num);
|
||||
},
|
||||
[onChange, field.validation_rules]
|
||||
);
|
||||
|
||||
// 표시용 값 (통화면 포맷팅)
|
||||
const displayValue = formatNumber(numValue, isCurrency);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
{isCurrency && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
₩
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
id={field.field_key}
|
||||
name={field.field_key}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={field.placeholder || '0'}
|
||||
disabled={disabled || field.is_readonly}
|
||||
className={cn(
|
||||
isCurrency && 'pl-8',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500',
|
||||
'text-right'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberField;
|
||||
83
src/components/items/DynamicItemForm/fields/TextField.tsx
Normal file
83
src/components/items/DynamicItemForm/fields/TextField.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* TextField Component
|
||||
*
|
||||
* 텍스트 입력 필드 (textbox, textarea)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldProps } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function TextField({
|
||||
field,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFieldProps) {
|
||||
const isTextarea = field.field_type === 'textarea';
|
||||
const displayValue = value === null || value === undefined ? '' : String(value);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={field.field_key}
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
|
||||
)}
|
||||
>
|
||||
{field.field_name}
|
||||
</Label>
|
||||
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
id={field.field_key}
|
||||
name={field.field_key}
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.is_readonly}
|
||||
className={cn(
|
||||
'min-h-[100px]',
|
||||
error && 'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={field.field_key}
|
||||
name={field.field_key}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.is_readonly}
|
||||
maxLength={field.validation_rules?.maxLength}
|
||||
className={cn(
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
field.is_readonly && 'bg-gray-50 text-gray-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.help_text && !error && (
|
||||
<p className="text-xs text-muted-foreground">{field.help_text}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextField;
|
||||
11
src/components/items/DynamicItemForm/fields/index.ts
Normal file
11
src/components/items/DynamicItemForm/fields/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 동적 필드 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
export { TextField } from './TextField';
|
||||
export { DropdownField } from './DropdownField';
|
||||
export { NumberField } from './NumberField';
|
||||
export { DateField } from './DateField';
|
||||
export { CheckboxField } from './CheckboxField';
|
||||
export { FileField } from './FileField';
|
||||
export { CustomField } from './CustomField';
|
||||
Reference in New Issue
Block a user