1069 lines
40 KiB
TypeScript
1069 lines
40 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,
|
|
DateSelectionMode,
|
|
CalendarDisplayMode,
|
|
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,
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 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":
|
|
return <DateDetailSettings 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 상세 설정
|
|
// ========================================
|
|
|
|
const DATE_SELECTION_MODE_LABELS: Record<DateSelectionMode, string> = {
|
|
single: "단일 날짜",
|
|
range: "기간 선택",
|
|
};
|
|
|
|
const CALENDAR_DISPLAY_LABELS: Record<CalendarDisplayMode, string> = {
|
|
popover: "팝오버 (PC용)",
|
|
modal: "모달 (터치/POP용)",
|
|
};
|
|
|
|
function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
|
|
const mode: DateSelectionMode = cfg.dateSelectionMode || "single";
|
|
const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal";
|
|
const autoFilterMode = mode === "range" ? "range" : "equals";
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">날짜 선택 모드</Label>
|
|
<Select
|
|
value={mode}
|
|
onValueChange={(v) => update({ dateSelectionMode: v as DateSelectionMode })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(DATE_SELECTION_MODE_LABELS).map(([key, label]) => (
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
{mode === "single"
|
|
? "캘린더에서 날짜 하나를 선택합니다"
|
|
: "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">캘린더 표시 방식</Label>
|
|
<Select
|
|
value={calDisplay}
|
|
onValueChange={(v) => update({ calendarDisplay: v as CalendarDisplayMode })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(CALENDAR_DISPLAY_LABELS).map(([key, label]) => (
|
|
<SelectItem key={key} value={key} className="text-xs">
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
{calDisplay === "modal"
|
|
? "터치 친화적인 큰 모달로 캘린더가 열립니다"
|
|
: "입력란 아래에 작은 팝오버로 열립니다"}
|
|
</p>
|
|
</div>
|
|
|
|
<FilterConnectionSection
|
|
cfg={cfg}
|
|
update={update}
|
|
showFieldName
|
|
fixedFilterMode={autoFilterMode}
|
|
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">
|
|
"직접" 선택 시 날짜 입력 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>
|
|
|
|
{/* 중복 제거 (Distinct) */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<Checkbox
|
|
id="modal_distinct"
|
|
checked={mc.distinct ?? false}
|
|
onCheckedChange={(checked) => updateModal({ distinct: !!checked })}
|
|
/>
|
|
<Label htmlFor="modal_distinct" className="text-[10px]">중복 제거 (Distinct)</Label>
|
|
</div>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
표시 필드 기준으로 동일한 값이 여러 건이면 하나만 표시
|
|
</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>
|
|
);
|
|
}
|