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

986 lines
37 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo } 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, AlertTriangle } 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,
SearchFilterMode,
DatePresetOption,
ModalSelectConfig,
ModalDisplayStyle,
ModalSearchMode,
ModalFilterTab,
} from "./types";
import {
SEARCH_INPUT_TYPE_LABELS,
SEARCH_FILTER_MODE_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;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: 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} allComponents={allComponents} connections={connections} componentId={componentId} />}
<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;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
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, allComponents, connections, componentId }: StepProps) {
const normalized = normalizeInputType(cfg.inputType as string);
switch (normalized) {
case "text":
case "number":
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "select":
return <SelectDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "date-preset":
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
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>
);
}
}
// ========================================
// 공통: 필터 연결 설정 섹션
// ========================================
interface FilterConnectionSectionProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
showFieldName: boolean;
fixedFilterMode?: SearchFilterMode;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
interface ConnectedComponentInfo {
tableNames: string[];
displayedColumns: Set<string>;
}
/**
* 연결된 대상 컴포넌트의 tableName과 카드에서 표시 중인 컬럼을 추출한다.
*/
function getConnectedComponentInfo(
componentId?: string,
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[],
): ConnectedComponentInfo {
const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
if (!componentId || !connections || !allComponents) return empty;
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const tableNames = new Set<string>();
const displayedColumns = new Set<string>();
for (const tid of targetIds) {
const comp = allComponents.find((c) => c.id === tid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, any>;
const tn = compCfg.dataSource?.tableName;
if (tn) tableNames.add(tn);
// pop-card-list: cardTemplate에서 사용 중인 컬럼 수집
const tpl = compCfg.cardTemplate;
if (tpl) {
if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField);
if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField);
if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn);
if (Array.isArray(tpl.body?.fields)) {
for (const f of tpl.body.fields) {
if (f.columnName) displayedColumns.add(f.columnName);
}
}
}
// pop-string-list: selectedColumns / listColumns
if (Array.isArray(compCfg.selectedColumns)) {
for (const col of compCfg.selectedColumns) displayedColumns.add(col);
}
if (Array.isArray(compCfg.listColumns)) {
for (const lc of compCfg.listColumns) {
if (lc.columnName) displayedColumns.add(lc.columnName);
}
}
}
return { tableNames: Array.from(tableNames), displayedColumns };
}
function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) {
const connInfo = useMemo(
() => getConnectedComponentInfo(componentId, connections, allComponents),
[componentId, connections, allComponents],
);
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
const connectedTablesKey = connInfo.tableNames.join(",");
useEffect(() => {
if (connInfo.tableNames.length === 0) {
setTargetColumns([]);
return;
}
let cancelled = false;
setColumnsLoading(true);
Promise.all(connInfo.tableNames.map((t) => getTableColumns(t)))
.then((results) => {
if (cancelled) return;
const allCols: ColumnTypeInfo[] = [];
const seen = new Set<string>();
for (const res of results) {
if (res.success && res.data?.columns) {
for (const col of res.data.columns) {
if (!seen.has(col.columnName)) {
seen.add(col.columnName);
allCols.push(col);
}
}
}
}
setTargetColumns(allCols);
})
.finally(() => { if (!cancelled) setColumnsLoading(false); });
return () => { cancelled = true; };
}, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps
const hasConnection = connInfo.tableNames.length > 0;
const { displayedCols, otherCols } = useMemo(() => {
if (connInfo.displayedColumns.size === 0) {
return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns };
}
const displayed: ColumnTypeInfo[] = [];
const others: ColumnTypeInfo[] = [];
for (const col of targetColumns) {
if (connInfo.displayedColumns.has(col.columnName)) {
displayed.push(col);
} else {
others.push(col);
}
}
return { displayedCols: displayed, otherCols: others };
}, [targetColumns, connInfo.displayedColumns]);
const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []);
const toggleFilterColumn = (colName: string) => {
const current = new Set(selectedFilterCols);
if (current.has(colName)) {
current.delete(colName);
} else {
current.add(colName);
}
const next = Array.from(current);
update({
filterColumns: next,
fieldName: next[0] || "",
});
};
const renderColumnCheckbox = (col: ColumnTypeInfo) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`filter_col_${col.columnName}`}
checked={selectedFilterCols.includes(col.columnName)}
onCheckedChange={() => toggleFilterColumn(col.columnName)}
/>
<Label htmlFor={`filter_col_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
</Label>
</div>
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2 border-t pt-3">
<span className="text-[10px] font-medium text-muted-foreground"> </span>
</div>
{!hasConnection && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
.
.
</p>
</div>
)}
{hasConnection && showFieldName && (
<div className="space-y-1">
<Label className="text-[10px]">
<span className="text-destructive">*</span>
</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>
) : targetColumns.length > 0 ? (
<div className="max-h-48 space-y-2 overflow-y-auto rounded border p-2">
{displayedCols.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-primary"> </p>
{displayedCols.map(renderColumnCheckbox)}
</div>
)}
{displayedCols.length > 0 && otherCols.length > 0 && (
<div className="border-t" />
)}
{otherCols.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-muted-foreground"> </p>
{otherCols.map(renderColumnCheckbox)}
</div>
)}
</div>
) : (
<p className="text-[9px] text-muted-foreground">
</p>
)}
{selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
</p>
</div>
)}
{selectedFilterCols.length > 0 && (
<p className="text-[9px] text-muted-foreground">
{selectedFilterCols.length} -
</p>
)}
{selectedFilterCols.length === 0 && (
<p className="text-[9px] text-muted-foreground">
( )
</p>
)}
</div>
)}
{fixedFilterMode ? (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="flex h-8 items-center rounded-md border bg-muted px-3 text-xs text-muted-foreground">
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
</div>
<p className="text-[9px] text-muted-foreground">
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
</p>
</div>
) : (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.filterMode || "contains"}
onValueChange={(v) => update({ filterMode: v as SearchFilterMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SEARCH_FILTER_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>
)}
</div>
);
}
// ========================================
// text/number 상세 설정
// ========================================
function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: 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>
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
// ========================================
// select 상세 설정
// ========================================
function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: 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>
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
// ========================================
// date-preset 상세 설정
// ========================================
function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: 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>
)}
<FilterConnectionSection cfg={cfg} update={update} showFieldName fixedFilterMode="range" allComponents={allComponents} connections={connections} componentId={componentId} />
</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>
<FilterConnectionSection cfg={cfg} update={update} showFieldName={false} />
</>
)}
</div>
);
}