/** * V3 메타 컴포넌트 - Field 렌더러 * * V2를 래핑하지 않고 자체적으로 완전히 동작하는 입력 컴포넌트 * webType에 따라 적절한 shadcn/ui 컴포넌트로 렌더링 * formData 기반 데이터 바인딩 지원 */ "use client"; import React, { useEffect, useState, useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; import { Check, ChevronsUpDown, Upload, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { FieldComponentConfig } from "@/lib/api/metaComponent"; import apiClient from "@/lib/api/client"; interface FieldRendererProps { id: string; config: FieldComponentConfig; // 데이터 바인딩 (상위에서 전달) formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; // 컨텍스트 tableName?: string; companyCode?: string; screenId?: number; // UI 모드 isDesignMode?: boolean; disabled?: boolean; className?: string; } export function FieldRenderer({ id, config, formData, onFormDataChange, tableName, companyCode, screenId, isDesignMode = false, disabled = false, className, }: FieldRendererProps) { // 현재 값: formData[binding]에서 읽기 const binding = config.binding || ""; const currentValue = formData?.[binding] ?? config.defaultValue ?? ""; // Entity Search 상태 const [entitySearchOpen, setEntitySearchOpen] = useState(false); const [entityOptions, setEntityOptions] = useState([]); const [entitySearchTerm, setEntitySearchTerm] = useState(""); const [entityLoading, setEntityLoading] = useState(false); // Category 상태 const [categoryOptions, setCategoryOptions] = useState([]); const [categoryLoading, setCategoryLoading] = useState(false); // File Upload 상태 const [selectedFile, setSelectedFile] = useState(null); const [filePreview, setFilePreview] = useState(null); const [isDragging, setIsDragging] = useState(false); // 값 변경 핸들러 const handleChange = (newValue: any) => { if (isDesignMode) return; // 디자인 모드에서는 값 변경 불가 if ((config as any).readonly) return; // 읽기 전용이면 변경 불가 (타입에 추가 예정) if (onFormDataChange && binding) { onFormDataChange(binding, newValue); } }; // autoFill 설정 확인 const autoFillConfig = (config as any)._originalConfig?.autoFill || (config as any).autoFill; // 실제 disabled 상태: 디자인 모드 OR disabled prop OR readonly OR autoFill const isDisabled = isDesignMode || disabled || (config as any).readonly || (autoFillConfig?.enabled && !!currentValue); // autoFill 로직: 마운트 시 자동으로 데이터 로드 useEffect(() => { if (!autoFillConfig?.enabled || !companyCode || currentValue || isDesignMode) return; const fetchAutoFillData = async () => { try { const response = await apiClient.post( `/api/table-management/tables/${autoFillConfig.sourceTable}/data`, { page: 1, size: 1, filters: { [autoFillConfig.filterColumn]: companyCode, }, } ); if (response.data?.success && response.data.data?.length > 0) { const value = response.data.data[0][autoFillConfig.displayColumn]; handleChange(value); } } catch (error) { console.error("[FieldRenderer] autoFill 에러:", error); } }; fetchAutoFillData(); }, [autoFillConfig, companyCode, currentValue, isDesignMode]); // Category 데이터 로드 useEffect(() => { if (config.webType !== "category" || isDesignMode) return; const fetchCategories = async () => { setCategoryLoading(true); try { const response = await apiClient.get("/api/admin/categories/tree"); if (response.data?.success) { let categories = response.data.data || []; // categoryGroupCode 필터링 const categoryGroupCode = (config as any).categoryGroupCode; if (categoryGroupCode) { categories = categories.filter((cat: any) => cat.groupCode === categoryGroupCode); } setCategoryOptions(categories); } } catch (error) { console.error("[FieldRenderer] category 로드 에러:", error); } finally { setCategoryLoading(false); } }; fetchCategories(); }, [config.webType, isDesignMode]); // Entity Search: debounced 검색 const searchEntityDebounced = useCallback( async (searchTerm: string) => { if (!config.join?.sourceTable || !searchTerm) { setEntityOptions([]); return; } setEntityLoading(true); try { const response = await apiClient.post( `/api/table-management/tables/${config.join.sourceTable}/data`, { page: 1, size: 20, search: searchTerm, } ); if (response.data?.success) { setEntityOptions(response.data.data || []); } } catch (error) { console.error("[FieldRenderer] entity search 에러:", error); } finally { setEntityLoading(false); } }, [config.join] ); // Entity Search: debounce 적용 useEffect(() => { if (config.webType !== "entity" || !entitySearchOpen) return; const timer = setTimeout(() => { searchEntityDebounced(entitySearchTerm); }, 300); return () => clearTimeout(timer); }, [entitySearchTerm, entitySearchOpen, config.webType, searchEntityDebounced]); // File Upload: 파일 선택 핸들러 const handleFileSelect = (file: File | null) => { if (!file) return; const fileConfig = (config as any).fileConfig; // 파일 크기 검증 if (fileConfig?.maxSize && file.size > fileConfig.maxSize * 1024 * 1024) { alert(`파일 크기는 ${fileConfig.maxSize}MB를 초과할 수 없습니다.`); return; } // 파일 타입 검증 if (fileConfig?.accept && !fileConfig.accept.split(",").some((type: string) => file.type.includes(type.trim()))) { alert(`허용된 파일 형식: ${fileConfig.accept}`); return; } setSelectedFile(file); handleChange(file.name); // 이미지 미리보기 if (file.type.startsWith("image/")) { const reader = new FileReader(); reader.onload = (e) => setFilePreview(e.target?.result as string); reader.readAsDataURL(file); } else { setFilePreview(null); } }; // File Upload: 드래그앤드롭 const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = () => { setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) handleFileSelect(file); }; // 렌더링할 입력 컴포넌트 선택 const renderInputField = () => { const webType = config.webType || "text"; switch (webType) { // ============ 텍스트 계열 ============ case "text": case "email": case "tel": case "url": case "password": return ( handleChange(e.target.value)} placeholder={config.placeholder} disabled={isDisabled} maxLength={config.validation?.max} className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)} /> ); // ============ 숫자 입력 ============ case "number": return ( handleChange(e.target.value ? Number(e.target.value) : "")} placeholder={config.placeholder} disabled={isDisabled} min={config.validation?.min} max={config.validation?.max} className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)} /> ); // ============ 텍스트 영역 ============ case "textarea": return (