ERP-node/frontend/lib/meta-components/Field/FieldRenderer.tsx

642 lines
21 KiB
TypeScript
Raw Normal View History

2026-03-01 03:39:00 +09:00
/**
* 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<string, any>;
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<any[]>([]);
const [entitySearchTerm, setEntitySearchTerm] = useState("");
const [entityLoading, setEntityLoading] = useState(false);
// Category 상태
const [categoryOptions, setCategoryOptions] = useState<any[]>([]);
const [categoryLoading, setCategoryLoading] = useState(false);
// File Upload 상태
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [filePreview, setFilePreview] = useState<string | null>(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 (
<Input
id={id}
type={webType}
value={currentValue || ""}
onChange={(e) => 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 (
<Input
id={id}
type="number"
value={currentValue || ""}
onChange={(e) => 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 (
<Textarea
id={id}
value={currentValue || ""}
onChange={(e) => handleChange(e.target.value)}
placeholder={config.placeholder}
disabled={isDisabled}
maxLength={config.validation?.max}
rows={4}
className={cn("text-xs sm:text-sm", className)}
/>
);
// ============ 날짜/시간 ============
case "date":
return (
<Input
id={id}
type="date"
value={currentValue || ""}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)}
/>
);
case "datetime":
return (
<Input
id={id}
type="datetime-local"
value={currentValue || ""}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)}
/>
);
// ============ Select (드롭다운) ============
case "select":
return (
<Select
value={currentValue || ""}
onValueChange={handleChange}
disabled={isDisabled}
>
<SelectTrigger id={id} className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)}>
<SelectValue placeholder={config.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{config.options?.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
// ============ 체크박스 ============
case "checkbox":
return (
<div className="flex items-center space-x-2">
<Checkbox
id={id}
checked={!!currentValue}
onCheckedChange={handleChange}
disabled={isDisabled}
/>
<label
htmlFor={id}
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm"
>
{config.label}
</label>
</div>
);
// ============ 라디오 그룹 ============
case "radio":
return (
<RadioGroup
value={currentValue || ""}
onValueChange={handleChange}
disabled={isDisabled}
>
{config.options?.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${id}-${option.value}`} />
<Label htmlFor={`${id}-${option.value}`} className="text-xs sm:text-sm">
{option.label}
</Label>
</div>
))}
</RadioGroup>
);
// ============ 토글 스위치 ============
case "toggle":
return (
<div className="flex items-center space-x-2">
<Switch
id={id}
checked={!!currentValue}
onCheckedChange={handleChange}
disabled={isDisabled}
/>
<Label htmlFor={id} className="text-xs sm:text-sm">
{config.label}
</Label>
</div>
);
// ============ 파일 업로드 (간단 버전) ============
case "file":
const fileConfig = (config as any).fileConfig;
return (
<div className="space-y-2">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
isDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25",
isDisabled && "cursor-not-allowed opacity-50"
)}
>
{filePreview ? (
<div className="relative">
<img src={filePreview} alt="미리보기" className="max-h-32 rounded" />
<Button
type="button"
size="icon"
variant="destructive"
className="absolute -right-2 -top-2 h-6 w-6"
onClick={() => {
setSelectedFile(null);
setFilePreview(null);
handleChange("");
}}
disabled={isDisabled}
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Upload className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-xs text-muted-foreground sm:text-sm">
{selectedFile ? selectedFile.name : "파일을 드래그하거나 클릭하세요"}
</p>
{fileConfig?.accept && (
<p className="text-[10px] text-muted-foreground">: {fileConfig.accept}</p>
)}
</>
)}
<Input
id={id}
type="file"
accept={fileConfig?.accept}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
disabled={isDisabled}
className="hidden"
/>
{!filePreview && (
<label
htmlFor={id}
className={cn(
"mt-2 cursor-pointer rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:bg-primary/90",
isDisabled && "pointer-events-none"
)}
>
</label>
)}
</div>
</div>
);
// ============ 엔티티 (간단 텍스트 입력, 향후 검색 기능 추가) ============
case "entity":
// join 설정이 있으면 검색 가능한 combobox
if (config.join?.sourceTable) {
const displayValue = entityOptions.find(
(opt) => opt[config.join!.valueColumn] === currentValue
)?.[config.join!.displayColumn] || currentValue;
return (
<Popover open={entitySearchOpen} onOpenChange={setEntitySearchOpen}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
role="combobox"
aria-expanded={entitySearchOpen}
disabled={isDisabled}
className={cn(
"h-8 w-full justify-between text-xs sm:h-10 sm:text-sm",
!currentValue && "text-muted-foreground",
className
)}
>
{displayValue || config.placeholder || "검색하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput
placeholder="검색..."
value={entitySearchTerm}
onValueChange={setEntitySearchTerm}
className="text-xs sm:text-sm"
/>
<CommandList>
{entityLoading ? (
<div className="py-6 text-center text-xs text-muted-foreground"> ...</div>
) : entityOptions.length === 0 ? (
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
) : (
<CommandGroup>
{entityOptions.map((option) => {
const optionValue = option[config.join!.valueColumn];
const optionLabel = option[config.join!.displayColumn];
return (
<CommandItem
key={optionValue}
value={optionValue}
onSelect={() => {
handleChange(optionValue);
setEntitySearchOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
currentValue === optionValue ? "opacity-100" : "opacity-0"
)}
/>
{optionLabel}
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// join 설정 없으면 일반 텍스트 입력
return (
<Input
id={id}
type="text"
value={currentValue || ""}
onChange={(e) => handleChange(e.target.value)}
placeholder={config.placeholder || `${config.join?.displayColumn || "항목"} 입력`}
disabled={isDisabled}
className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)}
/>
);
// ============ 카테고리 (드롭다운) ============
case "category":
return (
<Select
value={currentValue || ""}
onValueChange={handleChange}
disabled={isDisabled || categoryLoading}
>
<SelectTrigger id={id} className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)}>
<SelectValue placeholder={categoryLoading ? "로딩 중..." : config.placeholder || "카테고리 선택"} />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((category) => (
<SelectItem
key={category.categoryCode}
value={category.categoryCode}
className="text-xs sm:text-sm"
>
{category.categoryName}
</SelectItem>
))}
</SelectContent>
</Select>
);
// ============ 채번 필드 (읽기 전용) ============
case "numbering":
return (
<Input
id={id}
type="text"
value={currentValue || "(자동 생성)"}
readOnly
disabled
className={cn("h-8 text-xs sm:h-10 sm:text-sm bg-muted", className)}
/>
);
// ============ 기본 (텍스트) ============
default:
return (
<Input
id={id}
type="text"
value={currentValue || ""}
onChange={(e) => handleChange(e.target.value)}
placeholder={config.placeholder}
disabled={isDisabled}
className={cn("h-8 text-xs sm:h-10 sm:text-sm", className)}
/>
);
}
};
// checkbox, toggle 타입은 자체 라벨 포함
const hasInternalLabel = config.webType === "checkbox" || config.webType === "toggle";
// 라벨 없이 렌더링
if (hasInternalLabel || !config.label) {
return (
<div className={cn("space-y-1", className)}>
{renderInputField()}
</div>
);
}
// 라벨과 함께 렌더링
return (
<div className={cn("space-y-1.5", className)}>
<Label htmlFor={id} className="text-xs font-medium sm:text-sm">
{config.label}
{config.required && <span className="ml-1 text-destructive">*</span>}
</Label>
{renderInputField()}
{config.placeholder && (
<p className="text-[10px] text-muted-foreground sm:text-xs">
{config.placeholder}
</p>
)}
</div>
);
}