2026-03-05 11:30:31 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-11 14:46:07 +09:00
|
|
|
import React, { useState } from "react";
|
2026-03-05 11:30:31 +09:00
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-03-11 14:46:07 +09:00
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
|
import {
|
|
|
|
|
Command,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandInput,
|
|
|
|
|
CommandItem,
|
|
|
|
|
CommandList,
|
|
|
|
|
} from "@/components/ui/command";
|
|
|
|
|
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-03-05 11:30:31 +09:00
|
|
|
import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes";
|
|
|
|
|
|
|
|
|
|
interface ConfigFieldProps<T = any> {
|
|
|
|
|
field: ConfigFieldDefinition<T>;
|
|
|
|
|
value: any;
|
|
|
|
|
onChange: (key: string, value: any) => void;
|
|
|
|
|
tableColumns?: ConfigOption[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ConfigField<T>({
|
|
|
|
|
field,
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
tableColumns,
|
|
|
|
|
}: ConfigFieldProps<T>) {
|
2026-03-11 14:46:07 +09:00
|
|
|
const [comboboxOpen, setComboboxOpen] = useState(false);
|
|
|
|
|
|
2026-03-05 11:30:31 +09:00
|
|
|
const handleChange = (newValue: any) => {
|
|
|
|
|
onChange(field.key, newValue);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderField = () => {
|
|
|
|
|
switch (field.type) {
|
|
|
|
|
case "text":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
value={value ?? ""}
|
|
|
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
|
|
|
placeholder={field.placeholder}
|
2026-03-11 14:46:07 +09:00
|
|
|
className="h-7 text-xs"
|
2026-03-05 11:30:31 +09:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
value={value ?? ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
handleChange(
|
|
|
|
|
e.target.value === "" ? undefined : Number(e.target.value),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
placeholder={field.placeholder}
|
|
|
|
|
min={field.min}
|
|
|
|
|
max={field.max}
|
|
|
|
|
step={field.step}
|
2026-03-11 14:46:07 +09:00
|
|
|
className="h-7 text-xs"
|
2026-03-05 11:30:31 +09:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "switch":
|
|
|
|
|
return (
|
|
|
|
|
<Switch
|
|
|
|
|
checked={!!value}
|
|
|
|
|
onCheckedChange={handleChange}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
value={value ?? ""}
|
|
|
|
|
onValueChange={handleChange}
|
|
|
|
|
>
|
2026-03-11 14:46:07 +09:00
|
|
|
<SelectTrigger className="h-7 text-xs">
|
2026-03-05 11:30:31 +09:00
|
|
|
<SelectValue placeholder={field.placeholder || "선택"} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{(field.options || []).map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
|
|
|
{opt.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "textarea":
|
|
|
|
|
return (
|
|
|
|
|
<Textarea
|
|
|
|
|
value={value ?? ""}
|
|
|
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
|
|
|
placeholder={field.placeholder}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
rows={3}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "color":
|
|
|
|
|
return (
|
2026-03-11 14:46:07 +09:00
|
|
|
<div className="flex items-center gap-1.5">
|
2026-03-05 11:30:31 +09:00
|
|
|
<input
|
|
|
|
|
type="color"
|
|
|
|
|
value={value ?? "#000000"}
|
|
|
|
|
onChange={(e) => handleChange(e.target.value)}
|
2026-03-11 14:46:07 +09:00
|
|
|
className="h-7 w-7 cursor-pointer rounded border"
|
2026-03-05 11:30:31 +09:00
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
value={value ?? ""}
|
|
|
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
|
|
|
placeholder="#000000"
|
2026-03-11 14:46:07 +09:00
|
|
|
className="h-7 flex-1 text-xs"
|
2026-03-05 11:30:31 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "slider":
|
|
|
|
|
return (
|
2026-03-11 14:46:07 +09:00
|
|
|
<div className="flex items-center gap-1.5">
|
2026-03-05 11:30:31 +09:00
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
value={value ?? field.min ?? 0}
|
|
|
|
|
onChange={(e) => handleChange(Number(e.target.value))}
|
|
|
|
|
min={field.min}
|
|
|
|
|
max={field.max}
|
|
|
|
|
step={field.step}
|
2026-03-11 14:46:07 +09:00
|
|
|
className="h-7 w-16 text-xs"
|
2026-03-05 11:30:31 +09:00
|
|
|
/>
|
2026-03-11 14:46:07 +09:00
|
|
|
<span className="text-muted-foreground text-[9px]">
|
|
|
|
|
{field.min ?? 0}~{field.max ?? 100}
|
2026-03-05 11:30:31 +09:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "multi-select":
|
|
|
|
|
return (
|
2026-03-11 14:46:07 +09:00
|
|
|
<div className="space-y-0.5">
|
2026-03-05 11:30:31 +09:00
|
|
|
{(field.options || []).map((opt) => {
|
|
|
|
|
const selected = Array.isArray(value) && value.includes(opt.value);
|
|
|
|
|
return (
|
|
|
|
|
<label
|
|
|
|
|
key={opt.value}
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selected}
|
|
|
|
|
onChange={() => {
|
|
|
|
|
const current = Array.isArray(value) ? [...value] : [];
|
|
|
|
|
if (selected) {
|
|
|
|
|
handleChange(current.filter((v: string) => v !== opt.value));
|
|
|
|
|
} else {
|
|
|
|
|
handleChange([...current, opt.value]);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="rounded"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs">{opt.label}</span>
|
|
|
|
|
</label>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "key-value": {
|
|
|
|
|
const entries: Array<[string, string]> = Object.entries(
|
|
|
|
|
(value as Record<string, string>) || {},
|
|
|
|
|
);
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{entries.map(([k, v], idx) => (
|
|
|
|
|
<div key={idx} className="flex items-center gap-1">
|
|
|
|
|
<Input
|
|
|
|
|
value={k}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newObj = { ...(value || {}) };
|
|
|
|
|
delete newObj[k];
|
|
|
|
|
newObj[e.target.value] = v;
|
|
|
|
|
handleChange(newObj);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="키"
|
|
|
|
|
className="h-7 flex-1 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
value={v}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
handleChange({ ...(value || {}), [k]: e.target.value });
|
|
|
|
|
}}
|
|
|
|
|
placeholder="값"
|
|
|
|
|
className="h-7 flex-1 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-7 w-7"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const newObj = { ...(value || {}) };
|
|
|
|
|
delete newObj[k];
|
|
|
|
|
handleChange(newObj);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 w-full text-xs"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
handleChange({ ...(value || {}), "": "" });
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "column-picker": {
|
|
|
|
|
const options = tableColumns || field.options || [];
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
value={value ?? ""}
|
|
|
|
|
onValueChange={handleChange}
|
|
|
|
|
>
|
2026-03-11 14:46:07 +09:00
|
|
|
<SelectTrigger className="h-7 text-xs">
|
2026-03-05 11:30:31 +09:00
|
|
|
<SelectValue placeholder={field.placeholder || "컬럼 선택"} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{options.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
|
|
|
{opt.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 14:46:07 +09:00
|
|
|
case "checkbox":
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Checkbox
|
|
|
|
|
id={`field-${field.key}`}
|
|
|
|
|
checked={!!value}
|
|
|
|
|
onCheckedChange={handleChange}
|
|
|
|
|
/>
|
|
|
|
|
{field.description && (
|
|
|
|
|
<label
|
|
|
|
|
htmlFor={`field-${field.key}`}
|
|
|
|
|
className="cursor-pointer text-xs text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
{field.description}
|
|
|
|
|
</label>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "combobox": {
|
|
|
|
|
const options = field.options || [];
|
|
|
|
|
const selectedLabel = options.find((opt) => opt.value === value)?.label;
|
|
|
|
|
return (
|
|
|
|
|
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={comboboxOpen}
|
|
|
|
|
className="h-7 w-full justify-between text-xs font-normal"
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">
|
|
|
|
|
{selectedLabel || field.placeholder || "선택"}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
align="start"
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="검색..." className="text-xs" />
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty className="py-2 text-center text-xs">
|
|
|
|
|
결과 없음
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{options.map((opt) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={opt.value}
|
|
|
|
|
value={opt.value}
|
|
|
|
|
onSelect={(currentValue) => {
|
|
|
|
|
handleChange(currentValue === value ? "" : currentValue);
|
|
|
|
|
setComboboxOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-1.5 h-3 w-3",
|
|
|
|
|
value === opt.value ? "opacity-100" : "opacity-0",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 11:30:31 +09:00
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-11 14:46:07 +09:00
|
|
|
// textarea, multi-select, key-value는 전체 폭 수직 레이아웃
|
|
|
|
|
const isFullWidth = ["textarea", "multi-select", "key-value"].includes(field.type);
|
|
|
|
|
// checkbox는 description을 인라인으로 표시하므로 별도 처리
|
|
|
|
|
const isCheckbox = field.type === "checkbox";
|
|
|
|
|
|
|
|
|
|
if (isFullWidth) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="py-1.5">
|
|
|
|
|
<Label className="mb-1 block text-xs text-muted-foreground">{field.label}</Label>
|
|
|
|
|
{field.description && !isCheckbox && (
|
|
|
|
|
<p className="text-muted-foreground/60 mb-1 text-[9px]">{field.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
{renderField()}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// switch, checkbox: 라벨 왼쪽, 컨트롤 오른쪽 (고정폭 없이)
|
|
|
|
|
if (field.type === "switch" || isCheckbox) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-between py-1.5">
|
|
|
|
|
<Label className="mr-3 truncate text-xs text-muted-foreground">{field.label}</Label>
|
|
|
|
|
{renderField()}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기본: 수평 property row (라벨 왼쪽, 컨트롤 오른쪽 고정폭)
|
2026-03-05 11:30:31 +09:00
|
|
|
return (
|
2026-03-11 14:46:07 +09:00
|
|
|
<div className="flex items-center justify-between py-1.5">
|
|
|
|
|
<Label className="mr-3 min-w-0 shrink truncate text-xs text-muted-foreground">
|
|
|
|
|
{field.label}
|
|
|
|
|
</Label>
|
|
|
|
|
<div className="w-[140px] flex-shrink-0">
|
|
|
|
|
{renderField()}
|
2026-03-05 11:30:31 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|