ERP-node/frontend/lib/registry/components/common/ConfigField.tsx

380 lines
12 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState } from "react";
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";
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";
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>) {
const [comboboxOpen, setComboboxOpen] = useState(false);
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}
className="h-7 text-xs"
/>
);
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}
className="h-7 text-xs"
/>
);
case "switch":
return (
<Switch
checked={!!value}
onCheckedChange={handleChange}
/>
);
case "select":
return (
<Select
value={value ?? ""}
onValueChange={handleChange}
>
<SelectTrigger className="h-7 text-xs">
<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 (
<div className="flex items-center gap-1.5">
<input
type="color"
value={value ?? "#000000"}
onChange={(e) => handleChange(e.target.value)}
className="h-7 w-7 cursor-pointer rounded border"
/>
<Input
value={value ?? ""}
onChange={(e) => handleChange(e.target.value)}
placeholder="#000000"
className="h-7 flex-1 text-xs"
/>
</div>
);
case "slider":
return (
<div className="flex items-center gap-1.5">
<Input
type="number"
value={value ?? field.min ?? 0}
onChange={(e) => handleChange(Number(e.target.value))}
min={field.min}
max={field.max}
step={field.step}
className="h-7 w-16 text-xs"
/>
<span className="text-muted-foreground text-[9px]">
{field.min ?? 0}~{field.max ?? 100}
</span>
</div>
);
case "multi-select":
return (
<div className="space-y-0.5">
{(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}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder={field.placeholder || "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
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>
);
}
default:
return null;
}
};
// 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 (라벨 왼쪽, 컨트롤 오른쪽 고정폭)
return (
<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()}
</div>
</div>
);
}