642 lines
21 KiB
TypeScript
642 lines
21 KiB
TypeScript
|
|
/**
|
||
|
|
* 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>
|
||
|
|
);
|
||
|
|
}
|