ERP-node/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx

702 lines
26 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import type {
PopSearchConfig,
SearchInputType,
DatePresetOption,
ModalSelectConfig,
ModalDisplayStyle,
ModalSearchMode,
ModalFilterTab,
} from "./types";
import {
SEARCH_INPUT_TYPE_LABELS,
DATE_PRESET_LABELS,
MODAL_DISPLAY_STYLE_LABELS,
MODAL_SEARCH_MODE_LABELS,
MODAL_FILTER_TAB_LABELS,
normalizeInputType,
} from "./types";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import type { TableInfo, ColumnTypeInfo } from "@/lib/api/tableManagement";
// ========================================
// 기본값
// ========================================
const DEFAULT_CONFIG: PopSearchConfig = {
inputType: "text",
fieldName: "",
placeholder: "검색어 입력",
debounceMs: 500,
triggerOnEnter: true,
labelPosition: "top",
labelText: "",
labelVisible: true,
};
// ========================================
// 설정 패널 메인
// ========================================
interface ConfigPanelProps {
config: PopSearchConfig | undefined;
onUpdate: (config: PopSearchConfig) => void;
}
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
const [step, setStep] = useState(0);
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
const cfg: PopSearchConfig = {
...rawCfg,
inputType: normalizeInputType(rawCfg.inputType as string),
};
const update = (partial: Partial<PopSearchConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const STEPS = ["기본 설정", "상세 설정"];
return (
<div className="space-y-3 overflow-y-auto pr-1 pb-32">
{/* Stepper 헤더 */}
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
{STEPS.map((s, i) => (
<button
key={s}
type="button"
onClick={() => setStep(i)}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-colors",
step === i
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-background text-[8px] font-bold text-foreground">
{i + 1}
</span>
{s}
</button>
))}
</div>
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
<div className="flex justify-between pt-2">
<Button
variant="outline"
size="sm"
className="h-7 text-[10px]"
disabled={step === 0}
onClick={() => setStep(step - 1)}
>
<ChevronLeft className="mr-1 h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px]"
disabled={step === STEPS.length - 1}
onClick={() => setStep(step + 1)}
>
<ChevronRight className="ml-1 h-3 w-3" />
</Button>
</div>
</div>
);
}
// ========================================
// STEP 1: 기본 설정
// ========================================
interface StepProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
}
function StepBasicSettings({ cfg, update }: StepProps) {
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={normalizeInputType(cfg.inputType as string)}
onValueChange={(v) => update({ inputType: v as SearchInputType })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SEARCH_INPUT_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={cfg.placeholder || ""}
onChange={(e) => update({ placeholder: e.target.value })}
placeholder="입력 힌트 텍스트"
className="h-8 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="labelVisible"
checked={cfg.labelVisible !== false}
onCheckedChange={(checked) => update({ labelVisible: Boolean(checked) })}
/>
<Label htmlFor="labelVisible" className="text-[10px]"> </Label>
</div>
{cfg.labelVisible !== false && (
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={cfg.labelText || ""}
onChange={(e) => update({ labelText: e.target.value })}
placeholder="예: 거래처명"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.labelPosition || "top"}
onValueChange={(v) => update({ labelPosition: v as "top" | "left" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-xs"> ()</SelectItem>
<SelectItem value="left" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
);
}
// ========================================
// STEP 2: 타입별 상세 설정
// ========================================
function StepDetailSettings({ cfg, update }: StepProps) {
const normalized = normalizeInputType(cfg.inputType as string);
switch (normalized) {
case "text":
case "number":
return <TextDetailSettings cfg={cfg} update={update} />;
case "select":
return <SelectDetailSettings cfg={cfg} update={update} />;
case "date-preset":
return <DatePresetDetailSettings cfg={cfg} update={update} />;
case "modal":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
. ON/OFF .
</p>
</div>
);
default:
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
{cfg.inputType} .
</p>
</div>
);
}
}
// ========================================
// text/number 상세 설정
// ========================================
function TextDetailSettings({ cfg, update }: StepProps) {
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[10px]"> (ms)</Label>
<Input
type="number"
value={cfg.debounceMs ?? 500}
onChange={(e) => update({ debounceMs: Math.max(0, Number(e.target.value)) })}
min={0}
max={5000}
step={100}
className="h-8 text-xs"
/>
<p className="text-[9px] text-muted-foreground">
. 0 (권장: 300~500)
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="triggerOnEnter"
checked={cfg.triggerOnEnter !== false}
onCheckedChange={(checked) => update({ triggerOnEnter: Boolean(checked) })}
/>
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter </Label>
</div>
</div>
);
}
// ========================================
// select 상세 설정
// ========================================
function SelectDetailSettings({ cfg, update }: StepProps) {
const options = cfg.options || [];
const addOption = () => {
update({
options: [...options, { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }],
});
};
const removeOption = (index: number) => {
update({ options: options.filter((_, i) => i !== index) });
};
const updateOption = (index: number, field: "value" | "label", val: string) => {
update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) });
};
return (
<div className="space-y-3">
<Label className="text-[10px]"> </Label>
{options.length === 0 && (
<p className="text-[9px] text-muted-foreground"> . .</p>
)}
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-1">
<Input value={opt.value} onChange={(e) => updateOption(i, "value", e.target.value)} placeholder="값" className="h-7 flex-1 text-[10px]" />
<Input value={opt.label} onChange={(e) => updateOption(i, "label", e.target.value)} placeholder="라벨" className="h-7 flex-1 text-[10px]" />
<button type="button" onClick={() => removeOption(i)} className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive">
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
<Button variant="outline" size="sm" className="h-7 w-full text-[10px]" onClick={addOption}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ========================================
// date-preset 상세 설정
// ========================================
function DatePresetDetailSettings({ cfg, update }: StepProps) {
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
const togglePreset = (preset: DatePresetOption) => {
const next = activePresets.includes(preset)
? activePresets.filter((p) => p !== preset)
: [...activePresets, preset];
update({ datePresets: next.length > 0 ? next : ["today"] });
};
return (
<div className="space-y-3">
<Label className="text-[10px]"> </Label>
{ALL_PRESETS.map((preset) => (
<div key={preset} className="flex items-center gap-2">
<Checkbox
id={`preset_${preset}`}
checked={activePresets.includes(preset)}
onCheckedChange={() => togglePreset(preset)}
/>
<Label htmlFor={`preset_${preset}`} className="text-[10px]">{DATE_PRESET_LABELS[preset]}</Label>
</div>
))}
{activePresets.includes("custom") && (
<p className="text-[9px] text-muted-foreground">
&quot;&quot; UI가 ( )
</p>
)}
</div>
);
}
// ========================================
// modal 상세 설정
// ========================================
const DEFAULT_MODAL_CONFIG: ModalSelectConfig = {
displayStyle: "table",
displayField: "",
valueField: "",
searchMode: "contains",
};
function ModalDetailSettings({ cfg, update }: StepProps) {
const mc: ModalSelectConfig = { ...DEFAULT_MODAL_CONFIG, ...(cfg.modalConfig || {}) };
const updateModal = (partial: Partial<ModalSelectConfig>) => {
update({ modalConfig: { ...mc, ...partial } });
};
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [columnsLoading, setColumnsLoading] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false);
useEffect(() => {
let cancelled = false;
setTablesLoading(true);
tableManagementApi.getTableList().then((res) => {
if (!cancelled && res.success && res.data) setTables(res.data);
}).finally(() => !cancelled && setTablesLoading(false));
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!mc.tableName) { setColumns([]); return; }
let cancelled = false;
setColumnsLoading(true);
getTableColumns(mc.tableName).then((res) => {
if (!cancelled && res.success && res.data?.columns) setColumns(res.data.columns);
}).finally(() => !cancelled && setColumnsLoading(false));
return () => { cancelled = true; };
}, [mc.tableName]);
const toggleArrayItem = (field: "displayColumns" | "searchColumns", col: string) => {
const current = mc[field] || [];
const next = current.includes(col) ? current.filter((c) => c !== col) : [...current, col];
updateModal({ [field]: next });
};
const toggleFilterTab = (tab: ModalFilterTab) => {
const current = mc.filterTabs || [];
const next = current.includes(tab) ? current.filter((t) => t !== tab) : [...current, tab];
updateModal({ filterTabs: next });
};
const updateColumnLabel = (colName: string, label: string) => {
const current = mc.columnLabels || {};
if (!label.trim()) {
const { [colName]: _, ...rest } = current;
updateModal({ columnLabels: Object.keys(rest).length > 0 ? rest : undefined });
} else {
updateModal({ columnLabels: { ...current, [colName]: label } });
}
};
const selectedDisplayCols = mc.displayColumns || [];
return (
<div className="space-y-3">
{/* 보여주기 방식 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={mc.displayStyle || "table"}
onValueChange={(v) => updateModal({ displayStyle: v as ModalDisplayStyle })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MODAL_DISPLAY_STYLE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
테이블: / 아이콘: 아이콘
</p>
</div>
{/* 데이터 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
{tablesLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs"
>
{mc.tableName
? tables.find((t) => t.tableName === mc.tableName)?.displayName || mc.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-2 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-3 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.displayName || ""} ${t.tableName}`}
onSelect={() => {
updateModal({
tableName: t.tableName,
displayColumns: [],
searchColumns: [],
displayField: "",
valueField: "",
columnLabels: undefined,
});
setOpenTableCombo(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mc.tableName === t.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
{t.displayName && t.displayName !== t.tableName && (
<span className="text-[9px] text-muted-foreground">{t.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{mc.tableName && (
<>
{/* 표시할 컬럼 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
{columns.map((col) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`disp_${col.columnName}`}
checked={mc.displayColumns?.includes(col.columnName) ?? false}
onCheckedChange={() => toggleArrayItem("displayColumns", col.columnName)}
/>
<Label htmlFor={`disp_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
</Label>
</div>
))}
</div>
)}
</div>
{/* 컬럼 헤더 라벨 편집 (표시할 컬럼이 선택된 경우만) */}
{selectedDisplayCols.length > 0 && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="space-y-1 rounded border p-2">
{selectedDisplayCols.map((colName) => {
const colInfo = columns.find((c) => c.columnName === colName);
const defaultLabel = colInfo?.displayName || colName;
return (
<div key={colName} className="flex items-center gap-2">
<span className="w-24 shrink-0 truncate text-[9px] text-muted-foreground">
{colName}
</span>
<Input
value={mc.columnLabels?.[colName] ?? ""}
onChange={(e) => updateColumnLabel(colName, e.target.value)}
placeholder={defaultLabel}
className="h-6 flex-1 text-[10px]"
/>
</div>
);
})}
</div>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
)}
{/* 검색 대상 컬럼 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="max-h-24 space-y-1 overflow-y-auto rounded border p-2">
{columns.map((col) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`search_${col.columnName}`}
checked={mc.searchColumns?.includes(col.columnName) ?? false}
onCheckedChange={() => toggleArrayItem("searchColumns", col.columnName)}
/>
<Label htmlFor={`search_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
</Label>
</div>
))}
</div>
</div>
{/* 검색 방식 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={mc.searchMode || "contains"}
onValueChange={(v) => updateModal({ searchMode: v as ModalSearchMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MODAL_SEARCH_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
포함: 어디든 / 시작: 앞에서 / 같음: 정확히
</p>
</div>
{/* 필터 탭 (가나다/ABC) */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="flex gap-3">
{(Object.entries(MODAL_FILTER_TAB_LABELS) as [ModalFilterTab, string][]).map(([key, label]) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox
id={`ftab_${key}`}
checked={mc.filterTabs?.includes(key) ?? false}
onCheckedChange={() => toggleFilterTab(key)}
/>
<Label htmlFor={`ftab_${key}`} className="text-[10px]">{label}</Label>
</div>
))}
</div>
<p className="text-[9px] text-muted-foreground">
() / (ABC)
</p>
</div>
{/* 검색창에 보일 값 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={mc.displayField || "__none__"}
onValueChange={(v) => updateModal({ displayField: v === "__none__" ? "" : v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs text-muted-foreground"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.displayName || col.columnName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
(: 회사명)
</p>
</div>
{/* 필터에 쓸 값 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={mc.valueField || "__none__"}
onValueChange={(v) => updateModal({ valueField: v === "__none__" ? "" : v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs text-muted-foreground"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.displayName || col.columnName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
(: 회사코드)
</p>
</div>
</>
)}
</div>
);
}