2964 lines
128 KiB
TypeScript
2964 lines
128 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* pop-card-list-v2 설정 패널 (3탭)
|
|
*
|
|
* 탭 1: 데이터 — 테이블/컬럼 선택, 조인, 정렬
|
|
* 탭 2: 카드 디자인 — 열 수, 시각적 그리드 디자이너, 셀 클릭 시 타입별 상세 인라인
|
|
* 탭 3: 동작 — 카드 선택 동작, 오버플로우, 카트
|
|
*/
|
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Check, ChevronsUpDown, Plus, Minus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
import type {
|
|
PopCardListV2Config,
|
|
CardGridConfigV2,
|
|
CardCellDefinitionV2,
|
|
CardCellType,
|
|
CardListDataSource,
|
|
CardColumnJoin,
|
|
CardSortConfig,
|
|
V2OverflowConfig,
|
|
V2CardClickAction,
|
|
V2CardClickModalConfig,
|
|
ActionButtonUpdate,
|
|
TimelineDataSource,
|
|
StatusValueMapping,
|
|
TimelineStatusSemantic,
|
|
SelectModeButtonConfig,
|
|
ActionButtonDef,
|
|
ActionButtonShowCondition,
|
|
ActionButtonClickAction,
|
|
} from "../types";
|
|
import type { ButtonVariant } from "../pop-button";
|
|
import {
|
|
fetchTableList,
|
|
fetchTableColumns,
|
|
type TableInfo,
|
|
type ColumnInfo,
|
|
} from "../pop-dashboard/utils/dataFetcher";
|
|
|
|
// ===== Props =====
|
|
|
|
interface ConfigPanelProps {
|
|
config: PopCardListV2Config | undefined;
|
|
onUpdate: (config: PopCardListV2Config) => void;
|
|
}
|
|
|
|
// ===== 기본 설정값 =====
|
|
|
|
const V2_DEFAULT_CONFIG: PopCardListV2Config = {
|
|
dataSource: { tableName: "" },
|
|
cardGrid: {
|
|
rows: 1,
|
|
cols: 1,
|
|
colWidths: ["1fr"],
|
|
rowHeights: ["32px"],
|
|
gap: 4,
|
|
showCellBorder: true,
|
|
cells: [],
|
|
},
|
|
gridColumns: 3,
|
|
cardGap: 8,
|
|
scrollDirection: "vertical",
|
|
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
|
|
cardClickAction: "none",
|
|
};
|
|
|
|
// ===== 탭 정의 =====
|
|
|
|
type V2ConfigTab = "data" | "design" | "actions";
|
|
|
|
const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [
|
|
{ id: "data", label: "데이터" },
|
|
{ id: "design", label: "카드 디자인" },
|
|
{ id: "actions", label: "동작" },
|
|
];
|
|
|
|
// ===== 셀 타입 라벨 =====
|
|
|
|
const V2_CELL_TYPE_LABELS: Record<CardCellType, { label: string; group: string }> = {
|
|
text: { label: "텍스트", group: "기본" },
|
|
field: { label: "필드 (라벨+값)", group: "기본" },
|
|
image: { label: "이미지", group: "기본" },
|
|
badge: { label: "배지", group: "기본" },
|
|
button: { label: "버튼", group: "동작" },
|
|
"number-input": { label: "숫자 입력", group: "입력" },
|
|
"cart-button": { label: "담기 버튼", group: "입력" },
|
|
"package-summary": { label: "포장 요약", group: "요약" },
|
|
"status-badge": { label: "상태 배지", group: "표시" },
|
|
timeline: { label: "타임라인", group: "표시" },
|
|
"footer-status": { label: "하단 상태", group: "표시" },
|
|
"action-buttons": { label: "액션 버튼", group: "동작" },
|
|
};
|
|
|
|
const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const;
|
|
|
|
// ===== 그리드 유틸 =====
|
|
|
|
const parseFr = (v: string): number => {
|
|
const num = parseFloat(v);
|
|
return isNaN(num) || num <= 0 ? 1 : num;
|
|
};
|
|
|
|
const GRID_LIMITS = {
|
|
cols: { min: 1, max: 6 },
|
|
rows: { min: 1, max: 6 },
|
|
gap: { min: 0, max: 16 },
|
|
minFr: 0.3,
|
|
} as const;
|
|
|
|
const DEFAULT_ROW_HEIGHT = 32;
|
|
const MIN_ROW_HEIGHT = 24;
|
|
|
|
const parsePx = (v: string): number => {
|
|
const num = parseInt(v);
|
|
return isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num;
|
|
};
|
|
|
|
const migrateRowHeight = (v: string): string => {
|
|
if (!v || v.endsWith("fr")) {
|
|
return `${Math.round(parseFr(v) * DEFAULT_ROW_HEIGHT)}px`;
|
|
}
|
|
if (v.endsWith("px")) return v;
|
|
const num = parseInt(v);
|
|
return `${isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num}px`;
|
|
};
|
|
|
|
const shortType = (t: string): string => {
|
|
const lower = t.toLowerCase();
|
|
if (lower.includes("character varying") || lower === "varchar") return "varchar";
|
|
if (lower === "text") return "text";
|
|
if (lower.includes("timestamp")) return "ts";
|
|
if (lower === "integer" || lower === "int4") return "int";
|
|
if (lower === "bigint" || lower === "int8") return "bigint";
|
|
if (lower === "numeric" || lower === "decimal") return "num";
|
|
if (lower === "boolean" || lower === "bool") return "bool";
|
|
if (lower === "date") return "date";
|
|
if (lower === "jsonb" || lower === "json") return "json";
|
|
return t.length > 8 ? t.slice(0, 6) + ".." : t;
|
|
};
|
|
|
|
// ===== 메인 컴포넌트 =====
|
|
|
|
export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
|
const [tab, setTab] = useState<V2ConfigTab>("data");
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
|
|
|
|
const cfg: PopCardListV2Config = {
|
|
...V2_DEFAULT_CONFIG,
|
|
...config,
|
|
dataSource: { ...V2_DEFAULT_CONFIG.dataSource, ...config?.dataSource },
|
|
cardGrid: { ...V2_DEFAULT_CONFIG.cardGrid, ...config?.cardGrid },
|
|
overflow: { ...V2_DEFAULT_CONFIG.overflow, ...config?.overflow } as V2OverflowConfig,
|
|
};
|
|
|
|
const update = (partial: Partial<PopCardListV2Config>) => {
|
|
onUpdate({ ...cfg, ...partial });
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchTableList()
|
|
.then(setTables)
|
|
.catch(() => setTables([]));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!cfg.dataSource.tableName) {
|
|
setColumns([]);
|
|
return;
|
|
}
|
|
fetchTableColumns(cfg.dataSource.tableName)
|
|
.then(setColumns)
|
|
.catch(() => setColumns([]));
|
|
}, [cfg.dataSource.tableName]);
|
|
|
|
useEffect(() => {
|
|
if (cfg.selectedColumns && cfg.selectedColumns.length > 0) {
|
|
setSelectedColumns(cfg.selectedColumns);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [cfg.dataSource.tableName]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{/* 탭 바 */}
|
|
<div className="flex gap-1 rounded-md border bg-muted/30 p-0.5">
|
|
{TAB_LABELS.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setTab(t.id)}
|
|
className={cn(
|
|
"flex-1 rounded-sm py-1 text-center text-[10px] font-medium transition-colors",
|
|
tab === t.id
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 탭 컨텐츠 */}
|
|
{tab === "data" && (
|
|
<TabData
|
|
cfg={cfg}
|
|
tables={tables}
|
|
columns={columns}
|
|
selectedColumns={selectedColumns}
|
|
onTableChange={(tableName) => {
|
|
setSelectedColumns([]);
|
|
update({
|
|
dataSource: { ...cfg.dataSource, tableName },
|
|
selectedColumns: [],
|
|
cardGrid: { ...cfg.cardGrid, cells: [] },
|
|
});
|
|
}}
|
|
onColumnsChange={(cols) => {
|
|
setSelectedColumns(cols);
|
|
update({ selectedColumns: cols });
|
|
}}
|
|
onDataSourceChange={(dataSource) => update({ dataSource })}
|
|
onSortChange={(sort) =>
|
|
update({ dataSource: { ...cfg.dataSource, sort } })
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{tab === "design" && (
|
|
<TabCardDesign
|
|
cfg={cfg}
|
|
columns={columns}
|
|
selectedColumns={selectedColumns}
|
|
tables={tables}
|
|
onGridChange={(cardGrid) => update({ cardGrid })}
|
|
onGridColumnsChange={(gridColumns) => update({ gridColumns })}
|
|
onCardGapChange={(cardGap) => update({ cardGap })}
|
|
/>
|
|
)}
|
|
|
|
{tab === "actions" && (
|
|
<TabActions
|
|
cfg={cfg}
|
|
onUpdate={update}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 탭 1: 데이터 =====
|
|
|
|
function TabData({
|
|
cfg,
|
|
tables,
|
|
columns,
|
|
selectedColumns,
|
|
onTableChange,
|
|
onColumnsChange,
|
|
onDataSourceChange,
|
|
onSortChange,
|
|
}: {
|
|
cfg: PopCardListV2Config;
|
|
tables: TableInfo[];
|
|
columns: ColumnInfo[];
|
|
selectedColumns: string[];
|
|
onTableChange: (tableName: string) => void;
|
|
onColumnsChange: (cols: string[]) => void;
|
|
onDataSourceChange: (ds: CardListDataSource) => void;
|
|
onSortChange: (sort: CardSortConfig[] | undefined) => void;
|
|
}) {
|
|
const [tableOpen, setTableOpen] = useState(false);
|
|
const ds = cfg.dataSource;
|
|
|
|
const selectedDisplay = ds.tableName
|
|
? tables.find((t) => t.tableName === ds.tableName)?.displayName || ds.tableName
|
|
: "";
|
|
|
|
const toggleColumn = (colName: string) => {
|
|
if (selectedColumns.includes(colName)) {
|
|
onColumnsChange(selectedColumns.filter((c) => c !== colName));
|
|
} else {
|
|
onColumnsChange([...selectedColumns, colName]);
|
|
}
|
|
};
|
|
|
|
const sort = ds.sort?.[0];
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 테이블 선택 */}
|
|
<div>
|
|
<Label className="text-xs">데이터 테이블</Label>
|
|
<Popover open={tableOpen} onOpenChange={setTableOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={tableOpen}
|
|
className="mt-1 h-8 w-full justify-between text-xs font-normal"
|
|
>
|
|
{ds.tableName ? selectedDisplay : "테이블 검색 / 선택"}
|
|
<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 text-muted-foreground">
|
|
검색 결과가 없습니다
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
<CommandItem
|
|
value="__none__"
|
|
onSelect={() => { onTableChange(""); setTableOpen(false); }}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", !ds.tableName ? "opacity-100" : "opacity-0")} />
|
|
선택 안 함
|
|
</CommandItem>
|
|
{tables.map((t) => (
|
|
<CommandItem
|
|
key={t.tableName}
|
|
value={`${t.tableName} ${t.displayName || ""}`}
|
|
onSelect={() => { onTableChange(t.tableName); setTableOpen(false); }}
|
|
className="text-xs"
|
|
>
|
|
<Check className={cn("mr-2 h-3 w-3", ds.tableName === t.tableName ? "opacity-100" : "opacity-0")} />
|
|
<div className="flex flex-col">
|
|
<span>{t.displayName || t.tableName}</span>
|
|
{t.displayName && t.displayName !== t.tableName && (
|
|
<span className="text-[10px] text-muted-foreground">{t.tableName}</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 컬럼 선택 */}
|
|
{ds.tableName && columns.length > 0 && (
|
|
<div>
|
|
<Label className="text-xs">
|
|
사용할 컬럼 ({selectedColumns.length}개 선택됨)
|
|
</Label>
|
|
<div className="mt-1 max-h-[160px] space-y-0.5 overflow-auto rounded border p-1">
|
|
{columns.map((col) => (
|
|
<label
|
|
key={col.name}
|
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedColumns.includes(col.name)}
|
|
onChange={() => toggleColumn(col.name)}
|
|
className="h-3 w-3"
|
|
/>
|
|
<span className="text-xs">{col.name}</span>
|
|
<span className="ml-auto text-[10px] text-muted-foreground">
|
|
{shortType(col.type)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 조인 설정 (접이식) */}
|
|
{ds.tableName && (
|
|
<JoinSection
|
|
dataSource={ds}
|
|
tables={tables}
|
|
mainColumns={columns}
|
|
onChange={onDataSourceChange}
|
|
/>
|
|
)}
|
|
|
|
{/* 정렬 */}
|
|
{ds.tableName && columns.length > 0 && (
|
|
<div>
|
|
<Label className="text-xs">정렬 기준</Label>
|
|
<div className="mt-1 flex gap-1">
|
|
<Select
|
|
value={sort?.column || "__none__"}
|
|
onValueChange={(v) =>
|
|
onSortChange(v && v !== "__none__" ? [{ column: v, direction: sort?.direction || "desc" }] : undefined)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
|
<SelectValue placeholder="정렬 컬럼" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
|
{columns.map((c) => (
|
|
<SelectItem key={c.name} value={c.name} className="text-[10px]">{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{sort?.column && (
|
|
<Select
|
|
value={sort.direction}
|
|
onValueChange={(v) =>
|
|
onSortChange([{ column: sort.column, direction: v as "asc" | "desc" }])
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 w-20 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="asc" className="text-[10px]">오름차순</SelectItem>
|
|
<SelectItem value="desc" className="text-[10px]">내림차순</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 조인 섹션 =====
|
|
|
|
function JoinSection({
|
|
dataSource,
|
|
tables,
|
|
mainColumns,
|
|
onChange,
|
|
}: {
|
|
dataSource: CardListDataSource;
|
|
tables: TableInfo[];
|
|
mainColumns: ColumnInfo[];
|
|
onChange: (ds: CardListDataSource) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState((dataSource.joins?.length || 0) > 0);
|
|
const joins = dataSource.joins || [];
|
|
|
|
const addJoin = () => {
|
|
const newJoin: CardColumnJoin = {
|
|
targetTable: "",
|
|
joinType: "LEFT",
|
|
sourceColumn: "",
|
|
targetColumn: "",
|
|
};
|
|
onChange({ ...dataSource, joins: [...joins, newJoin] });
|
|
setExpanded(true);
|
|
};
|
|
|
|
const removeJoin = (index: number) => {
|
|
onChange({ ...dataSource, joins: joins.filter((_, i) => i !== index) });
|
|
};
|
|
|
|
const updateJoin = (index: number, partial: Partial<CardColumnJoin>) => {
|
|
onChange({
|
|
...dataSource,
|
|
joins: joins.map((j, i) => (i === index ? { ...j, ...partial } : j)),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="flex w-full items-center gap-1 text-xs font-medium"
|
|
>
|
|
<span className={cn("transition-transform", expanded && "rotate-90")}>
|
|
{">"}
|
|
</span>
|
|
조인 설정 ({joins.length}개)
|
|
</button>
|
|
{expanded && (
|
|
<div className="mt-2 space-y-2">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
다른 테이블의 데이터를 연결하여 함께 표시 (선택사항)
|
|
</p>
|
|
{joins.map((join, i) => (
|
|
<JoinItemV2
|
|
key={i}
|
|
join={join}
|
|
index={i}
|
|
tables={tables}
|
|
mainColumns={mainColumns}
|
|
mainTableName={dataSource.tableName}
|
|
onUpdate={(partial) => updateJoin(i, partial)}
|
|
onRemove={() => removeJoin(i)}
|
|
/>
|
|
))}
|
|
<Button variant="outline" size="sm" onClick={addJoin} className="h-7 w-full text-xs">
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
테이블 연결 추가
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 조인 아이템 =====
|
|
|
|
function JoinItemV2({
|
|
join,
|
|
index,
|
|
tables,
|
|
mainColumns,
|
|
mainTableName,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
join: CardColumnJoin;
|
|
index: number;
|
|
tables: TableInfo[];
|
|
mainColumns: ColumnInfo[];
|
|
mainTableName: string;
|
|
onUpdate: (partial: Partial<CardColumnJoin>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
|
const [tableOpen, setTableOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!join.targetTable) { setTargetColumns([]); return; }
|
|
fetchTableColumns(join.targetTable)
|
|
.then(setTargetColumns)
|
|
.catch(() => setTargetColumns([]));
|
|
}, [join.targetTable]);
|
|
|
|
const autoMatches = mainColumns.filter((mc) =>
|
|
targetColumns.some((tc) => tc.name === mc.name && tc.type === mc.type)
|
|
);
|
|
|
|
const selectableTables = tables.filter((t) => t.tableName !== mainTableName);
|
|
const hasJoinCondition = join.sourceColumn !== "" && join.targetColumn !== "";
|
|
const selectedTargetCols = join.selectedTargetColumns || [];
|
|
const pickableTargetCols = targetColumns.filter((tc) => tc.name !== join.targetColumn);
|
|
|
|
const toggleTargetCol = (colName: string) => {
|
|
const next = selectedTargetCols.includes(colName)
|
|
? selectedTargetCols.filter((c) => c !== colName)
|
|
: [...selectedTargetCols, colName];
|
|
onUpdate({ selectedTargetColumns: next });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 rounded border p-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-medium">연결 #{index + 1}</span>
|
|
<Button variant="ghost" size="sm" onClick={onRemove} className="h-5 w-5 p-0">
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 대상 테이블 */}
|
|
<Popover open={tableOpen} onOpenChange={setTableOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-[10px]">
|
|
{join.targetTable || "테이블 선택..."}
|
|
<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-[10px]" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-2 text-center text-[10px]">없음</CommandEmpty>
|
|
<CommandGroup>
|
|
{selectableTables.map((t) => (
|
|
<CommandItem
|
|
key={t.tableName}
|
|
value={`${t.tableName} ${t.displayName || ""}`}
|
|
onSelect={() => {
|
|
onUpdate({ targetTable: t.tableName, sourceColumn: "", targetColumn: "", selectedTargetColumns: [] });
|
|
setTableOpen(false);
|
|
}}
|
|
className="text-[10px]"
|
|
>
|
|
<Check className={cn("mr-1 h-3 w-3", join.targetTable === t.tableName ? "opacity-100" : "opacity-0")} />
|
|
{t.tableName}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* 자동 매칭 */}
|
|
{join.targetTable && autoMatches.length > 0 && (
|
|
<div className="space-y-0.5">
|
|
<span className="text-[9px] text-muted-foreground">연결 조건</span>
|
|
{autoMatches.map((mc) => {
|
|
const isSelected = join.sourceColumn === mc.name && join.targetColumn === mc.name;
|
|
return (
|
|
<button
|
|
key={mc.name}
|
|
type="button"
|
|
onClick={() => {
|
|
if (isSelected) onUpdate({ sourceColumn: "", targetColumn: "" });
|
|
else onUpdate({ sourceColumn: mc.name, targetColumn: mc.name });
|
|
}}
|
|
className={cn(
|
|
"flex w-full items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
|
isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border",
|
|
isSelected ? "border-primary bg-primary" : "border-muted-foreground/30"
|
|
)}>
|
|
{isSelected && <Check className="h-2.5 w-2.5 text-primary-foreground" />}
|
|
</div>
|
|
<span className="flex-1 truncate">{mc.name}</span>
|
|
<span className="text-[8px] text-muted-foreground">{shortType(mc.type)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* 수동 매칭 */}
|
|
{join.targetTable && autoMatches.length === 0 && (
|
|
<div className="flex items-center gap-1">
|
|
<Select value={join.sourceColumn || ""} onValueChange={(v) => onUpdate({ sourceColumn: v, targetColumn: "" })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="메인" /></SelectTrigger>
|
|
<SelectContent>
|
|
{mainColumns.map((mc) => (
|
|
<SelectItem key={mc.name} value={mc.name} className="text-[10px]">{mc.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-[10px] text-muted-foreground">=</span>
|
|
<Select value={join.targetColumn || ""} onValueChange={(v) => onUpdate({ targetColumn: v })} disabled={!join.sourceColumn}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="대상" /></SelectTrigger>
|
|
<SelectContent>
|
|
{targetColumns.map((tc) => (
|
|
<SelectItem key={tc.name} value={tc.name} className="text-[10px]">{tc.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* 표시 방식 */}
|
|
{join.targetTable && (
|
|
<div className="flex gap-1">
|
|
{(["LEFT", "INNER"] as const).map((jt) => (
|
|
<button
|
|
key={jt}
|
|
type="button"
|
|
onClick={() => onUpdate({ joinType: jt })}
|
|
className={cn(
|
|
"flex-1 rounded border px-2 py-1 text-[10px] transition-colors",
|
|
join.joinType === jt ? "border-primary bg-primary/10 text-primary" : "border-border hover:bg-muted"
|
|
)}
|
|
>
|
|
{jt === "LEFT" ? "빈칸 허용" : "일치만"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 가져올 컬럼 */}
|
|
{hasJoinCondition && pickableTargetCols.length > 0 && (
|
|
<div>
|
|
<span className="text-[9px] text-muted-foreground">가져올 컬럼 ({selectedTargetCols.length}개)</span>
|
|
<div className="mt-1 max-h-[100px] space-y-0.5 overflow-y-auto">
|
|
{pickableTargetCols.map((tc) => {
|
|
const isChecked = selectedTargetCols.includes(tc.name);
|
|
return (
|
|
<button
|
|
key={tc.name}
|
|
type="button"
|
|
onClick={() => toggleTargetCol(tc.name)}
|
|
className={cn(
|
|
"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
|
isChecked ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border",
|
|
isChecked ? "border-primary bg-primary" : "border-muted-foreground/30"
|
|
)}>
|
|
{isChecked && <Check className="h-2.5 w-2.5 text-primary-foreground" />}
|
|
</div>
|
|
<span className="flex-1 truncate">{tc.name}</span>
|
|
<span className="text-[8px] text-muted-foreground">{shortType(tc.type)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 탭 2: 카드 디자인 =====
|
|
|
|
function TabCardDesign({
|
|
cfg,
|
|
columns,
|
|
selectedColumns,
|
|
tables,
|
|
onGridChange,
|
|
onGridColumnsChange,
|
|
onCardGapChange,
|
|
}: {
|
|
cfg: PopCardListV2Config;
|
|
columns: ColumnInfo[];
|
|
selectedColumns: string[];
|
|
tables: TableInfo[];
|
|
onGridChange: (g: CardGridConfigV2) => void;
|
|
onGridColumnsChange: (n: number) => void;
|
|
onCardGapChange: (n: number) => void;
|
|
}) {
|
|
const availableColumns = columns.filter((c) => selectedColumns.includes(c.name));
|
|
const joinedColumns = (cfg.dataSource.joins || []).flatMap((j) =>
|
|
(j.selectedTargetColumns || []).map((col) => ({
|
|
name: `${j.targetTable}.${col}`,
|
|
displayName: col,
|
|
sourceTable: j.targetTable,
|
|
}))
|
|
);
|
|
const allColumnOptions = [
|
|
...availableColumns.map((c) => ({ value: c.name, label: c.name })),
|
|
...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })),
|
|
];
|
|
|
|
const [selectedCellId, setSelectedCellId] = useState<string | null>(null);
|
|
const [mergeMode, setMergeMode] = useState(false);
|
|
const [mergeCellKeys, setMergeCellKeys] = useState<Set<string>>(new Set());
|
|
const widthBarRef = useRef<HTMLDivElement>(null);
|
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
const gridConfigRef = useRef<CardGridConfigV2 | undefined>(undefined);
|
|
const isDraggingRef = useRef(false);
|
|
const [gridLines, setGridLines] = useState<{ colLines: number[]; rowLines: number[] }>({ colLines: [], rowLines: [] });
|
|
|
|
// 그리드 정규화
|
|
const rawGrid = cfg.cardGrid;
|
|
const migratedRowHeights = (rawGrid.rowHeights || Array(rawGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(migrateRowHeight);
|
|
const safeColWidths = rawGrid.colWidths || [];
|
|
const normalizedColWidths = safeColWidths.length >= rawGrid.cols
|
|
? safeColWidths.slice(0, rawGrid.cols)
|
|
: [...safeColWidths, ...Array(rawGrid.cols - safeColWidths.length).fill("1fr")];
|
|
const normalizedRowHeights = migratedRowHeights.length >= rawGrid.rows
|
|
? migratedRowHeights.slice(0, rawGrid.rows)
|
|
: [...migratedRowHeights, ...Array(rawGrid.rows - migratedRowHeights.length).fill(`${DEFAULT_ROW_HEIGHT}px`)];
|
|
|
|
const grid: CardGridConfigV2 = {
|
|
...rawGrid,
|
|
colWidths: normalizedColWidths,
|
|
rowHeights: normalizedRowHeights,
|
|
};
|
|
gridConfigRef.current = grid;
|
|
|
|
const updateGrid = (partial: Partial<CardGridConfigV2>) => {
|
|
onGridChange({ ...grid, ...partial });
|
|
};
|
|
|
|
// 점유 맵
|
|
const buildOccupationMap = (): Record<string, string> => {
|
|
const map: Record<string, string> = {};
|
|
grid.cells.forEach((cell) => {
|
|
const rs = Number(cell.rowSpan) || 1;
|
|
const cs = Number(cell.colSpan) || 1;
|
|
for (let r = cell.row; r < cell.row + rs; r++) {
|
|
for (let c = cell.col; c < cell.col + cs; c++) {
|
|
map[`${r}-${c}`] = cell.id;
|
|
}
|
|
}
|
|
});
|
|
return map;
|
|
};
|
|
const occupationMap = buildOccupationMap();
|
|
const getCellByOrigin = (r: number, c: number) => grid.cells.find((cell) => cell.row === r && cell.col === c);
|
|
|
|
// 셀 CRUD
|
|
const addCellAt = (row: number, col: number) => {
|
|
const newCell: CardCellDefinitionV2 = {
|
|
id: `cell-${Date.now()}`,
|
|
row, col, rowSpan: 1, colSpan: 1,
|
|
type: "text",
|
|
};
|
|
updateGrid({ cells: [...grid.cells, newCell] });
|
|
setSelectedCellId(newCell.id);
|
|
};
|
|
|
|
const removeCell = (id: string) => {
|
|
updateGrid({ cells: grid.cells.filter((c) => c.id !== id) });
|
|
if (selectedCellId === id) setSelectedCellId(null);
|
|
};
|
|
|
|
const updateCell = (id: string, partial: Partial<CardCellDefinitionV2>) => {
|
|
updateGrid({ cells: grid.cells.map((c) => (c.id === id ? { ...c, ...partial } : c)) });
|
|
};
|
|
|
|
// 병합
|
|
const toggleMergeMode = () => {
|
|
if (mergeMode) { setMergeMode(false); setMergeCellKeys(new Set()); }
|
|
else { setMergeMode(true); setMergeCellKeys(new Set()); setSelectedCellId(null); }
|
|
};
|
|
|
|
const toggleMergeCell = (row: number, col: number) => {
|
|
const key = `${row}-${col}`;
|
|
if (occupationMap[key]) return;
|
|
const next = new Set(mergeCellKeys);
|
|
if (next.has(key)) next.delete(key); else next.add(key);
|
|
setMergeCellKeys(next);
|
|
};
|
|
|
|
const validateMerge = (): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null => {
|
|
if (mergeCellKeys.size < 2) return null;
|
|
const positions = Array.from(mergeCellKeys).map((k) => { const [r, c] = k.split("-").map(Number); return { row: r, col: c }; });
|
|
const minRow = Math.min(...positions.map((p) => p.row));
|
|
const maxRow = Math.max(...positions.map((p) => p.row));
|
|
const minCol = Math.min(...positions.map((p) => p.col));
|
|
const maxCol = Math.max(...positions.map((p) => p.col));
|
|
if (mergeCellKeys.size !== (maxRow - minRow + 1) * (maxCol - minCol + 1)) return null;
|
|
for (const key of mergeCellKeys) { if (occupationMap[key]) return null; }
|
|
return { minRow, maxRow, minCol, maxCol };
|
|
};
|
|
|
|
const confirmMerge = () => {
|
|
const bbox = validateMerge();
|
|
if (!bbox) return;
|
|
const newCell: CardCellDefinitionV2 = {
|
|
id: `cell-${Date.now()}`,
|
|
row: bbox.minRow, col: bbox.minCol,
|
|
rowSpan: bbox.maxRow - bbox.minRow + 1,
|
|
colSpan: bbox.maxCol - bbox.minCol + 1,
|
|
type: "text",
|
|
};
|
|
updateGrid({ cells: [...grid.cells, newCell] });
|
|
setSelectedCellId(newCell.id);
|
|
setMergeMode(false);
|
|
setMergeCellKeys(new Set());
|
|
};
|
|
|
|
// 셀 분할
|
|
const splitCellHorizontally = (cell: CardCellDefinitionV2) => {
|
|
const cs = Number(cell.colSpan) || 1;
|
|
const rs = Number(cell.rowSpan) || 1;
|
|
if (cs >= 2) {
|
|
const leftSpan = Math.ceil(cs / 2);
|
|
const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row, col: cell.col + leftSpan, rowSpan: rs, colSpan: cs - leftSpan, type: "text" };
|
|
const updatedCells = grid.cells.map((c) => c.id === cell.id ? { ...c, colSpan: leftSpan } : c);
|
|
updateGrid({ cells: [...updatedCells, newCell] });
|
|
setSelectedCellId(newCell.id);
|
|
} else {
|
|
if (grid.cols >= GRID_LIMITS.cols.max) return;
|
|
const insertPos = cell.col + 1;
|
|
const updatedCells = grid.cells.map((c) => {
|
|
if (c.id === cell.id) return c;
|
|
const cEnd = c.col + (Number(c.colSpan) || 1) - 1;
|
|
if (c.col >= insertPos) return { ...c, col: c.col + 1 };
|
|
if (cEnd >= insertPos) return { ...c, colSpan: (Number(c.colSpan) || 1) + 1 };
|
|
return c;
|
|
});
|
|
const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row, col: insertPos, rowSpan: rs, colSpan: 1, type: "text" };
|
|
const colIdx = cell.col - 1;
|
|
if (colIdx < 0 || colIdx >= grid.colWidths.length) return;
|
|
const currentFr = parseFr(grid.colWidths[colIdx]);
|
|
const halfFr = Math.max(GRID_LIMITS.minFr, currentFr / 2);
|
|
const frStr = `${Math.round(halfFr * 10) / 10}fr`;
|
|
const newWidths = [...grid.colWidths];
|
|
newWidths[colIdx] = frStr;
|
|
newWidths.splice(colIdx + 1, 0, frStr);
|
|
updateGrid({ cols: grid.cols + 1, colWidths: newWidths, cells: [...updatedCells, newCell] });
|
|
setSelectedCellId(newCell.id);
|
|
}
|
|
};
|
|
|
|
const splitCellVertically = (cell: CardCellDefinitionV2) => {
|
|
const rs = Number(cell.rowSpan) || 1;
|
|
const cs = Number(cell.colSpan) || 1;
|
|
const heights = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`);
|
|
if (rs >= 2) {
|
|
const topSpan = Math.ceil(rs / 2);
|
|
const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: cell.row + topSpan, col: cell.col, rowSpan: rs - topSpan, colSpan: cs, type: "text" };
|
|
const updatedCells = grid.cells.map((c) => c.id === cell.id ? { ...c, rowSpan: topSpan } : c);
|
|
updateGrid({ cells: [...updatedCells, newCell] });
|
|
setSelectedCellId(newCell.id);
|
|
} else {
|
|
if (grid.rows >= GRID_LIMITS.rows.max) return;
|
|
const insertPos = cell.row + 1;
|
|
const updatedCells = grid.cells.map((c) => {
|
|
if (c.id === cell.id) return c;
|
|
const cEnd = c.row + (Number(c.rowSpan) || 1) - 1;
|
|
if (c.row >= insertPos) return { ...c, row: c.row + 1 };
|
|
if (cEnd >= insertPos) return { ...c, rowSpan: (Number(c.rowSpan) || 1) + 1 };
|
|
return c;
|
|
});
|
|
const newCell: CardCellDefinitionV2 = { id: `cell-${Date.now()}`, row: insertPos, col: cell.col, rowSpan: 1, colSpan: cs, type: "text" };
|
|
const newHeights = [...heights];
|
|
newHeights.splice(cell.row - 1 + 1, 0, `${DEFAULT_ROW_HEIGHT}px`);
|
|
updateGrid({ rows: grid.rows + 1, rowHeights: newHeights, cells: [...updatedCells, newCell] });
|
|
setSelectedCellId(newCell.id);
|
|
}
|
|
};
|
|
|
|
// 클릭 핸들러
|
|
const handleEmptyCellClick = (row: number, col: number) => {
|
|
if (mergeMode) toggleMergeCell(row, col);
|
|
else addCellAt(row, col);
|
|
};
|
|
const handleCellClick = (cell: CardCellDefinitionV2) => {
|
|
if (mergeMode) return;
|
|
setSelectedCellId(selectedCellId === cell.id ? null : cell.id);
|
|
};
|
|
|
|
// 열 너비 드래그
|
|
const handleColDragStart = useCallback((e: React.MouseEvent, dividerIndex: number) => {
|
|
e.preventDefault();
|
|
isDraggingRef.current = true;
|
|
const startX = e.clientX;
|
|
const bar = widthBarRef.current;
|
|
if (!bar) return;
|
|
const barWidth = bar.offsetWidth;
|
|
if (barWidth === 0) return;
|
|
const currentGrid = gridConfigRef.current;
|
|
if (!currentGrid) return;
|
|
const startFrs = (currentGrid.colWidths || []).map(parseFr);
|
|
const totalFr = startFrs.reduce((a, b) => a + b, 0);
|
|
|
|
const onMove = (me: MouseEvent) => {
|
|
const delta = me.clientX - startX;
|
|
const frDelta = (delta / barWidth) * totalFr;
|
|
const newFrs = [...startFrs];
|
|
newFrs[dividerIndex] = Math.max(GRID_LIMITS.minFr, startFrs[dividerIndex] + frDelta);
|
|
newFrs[dividerIndex + 1] = Math.max(GRID_LIMITS.minFr, startFrs[dividerIndex + 1] - frDelta);
|
|
onGridChange({ ...currentGrid, colWidths: newFrs.map((fr) => `${Math.round(fr * 10) / 10}fr`) });
|
|
};
|
|
const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}, [onGridChange]);
|
|
|
|
// 행 높이 드래그
|
|
const handleRowDragStart = useCallback((e: React.MouseEvent, dividerIndex: number) => {
|
|
e.preventDefault();
|
|
isDraggingRef.current = true;
|
|
const startY = e.clientY;
|
|
const currentGrid = gridConfigRef.current;
|
|
if (!currentGrid) return;
|
|
const heights = (currentGrid.rowHeights || Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(parsePx);
|
|
if (dividerIndex < 0 || dividerIndex + 1 >= heights.length) return;
|
|
|
|
const onMove = (me: MouseEvent) => {
|
|
const delta = me.clientY - startY;
|
|
const newH = [...heights];
|
|
newH[dividerIndex] = Math.max(MIN_ROW_HEIGHT, heights[dividerIndex] + delta);
|
|
newH[dividerIndex + 1] = Math.max(MIN_ROW_HEIGHT, heights[dividerIndex + 1] - delta);
|
|
onGridChange({ ...currentGrid, rowHeights: newH.map((h) => `${Math.round(h)}px`) });
|
|
};
|
|
const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}, [onGridChange]);
|
|
|
|
// 내부 셀 경계 드래그
|
|
useEffect(() => {
|
|
const gridEl = gridRef.current;
|
|
if (!gridEl) return;
|
|
const measure = () => {
|
|
if (isDraggingRef.current) return;
|
|
const style = window.getComputedStyle(gridEl);
|
|
const colSizes = style.gridTemplateColumns.split(" ").map(parseFloat).filter((v) => !isNaN(v));
|
|
const rowSizes = style.gridTemplateRows.split(" ").map(parseFloat).filter((v) => !isNaN(v));
|
|
const gapSize = parseFloat(style.gap) || 0;
|
|
const colLines: number[] = [];
|
|
let x = 0;
|
|
for (let i = 0; i < colSizes.length - 1; i++) { x += colSizes[i] + gapSize; colLines.push(x - gapSize / 2); }
|
|
const rowLines: number[] = [];
|
|
let y = 0;
|
|
for (let i = 0; i < rowSizes.length - 1; i++) { y += rowSizes[i] + gapSize; rowLines.push(y - gapSize / 2); }
|
|
setGridLines({ colLines, rowLines });
|
|
};
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(gridEl);
|
|
measure();
|
|
return () => observer.disconnect();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [grid.colWidths.join(","), grid.rowHeights?.join(","), grid.gap, grid.cols, grid.rows]);
|
|
|
|
const handleInternalColDrag = useCallback((e: React.MouseEvent, lineIdx: number) => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
isDraggingRef.current = true;
|
|
const startX = e.clientX;
|
|
const gridEl = gridRef.current;
|
|
if (!gridEl) return;
|
|
const gridWidth = gridEl.offsetWidth;
|
|
if (gridWidth === 0) return;
|
|
const currentGrid = gridConfigRef.current;
|
|
if (!currentGrid) return;
|
|
const startFrs = (currentGrid.colWidths || []).map(parseFr);
|
|
const totalFr = startFrs.reduce((a, b) => a + b, 0);
|
|
const onMove = (me: MouseEvent) => {
|
|
const delta = me.clientX - startX;
|
|
const frDelta = (delta / gridWidth) * totalFr;
|
|
const newFrs = [...startFrs];
|
|
newFrs[lineIdx] = Math.max(GRID_LIMITS.minFr, startFrs[lineIdx] + frDelta);
|
|
newFrs[lineIdx + 1] = Math.max(GRID_LIMITS.minFr, startFrs[lineIdx + 1] - frDelta);
|
|
onGridChange({ ...currentGrid, colWidths: newFrs.map((fr) => `${Math.round(fr * 10) / 10}fr`) });
|
|
};
|
|
const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
|
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
|
}, [onGridChange]);
|
|
|
|
const handleInternalRowDrag = useCallback((e: React.MouseEvent, lineIdx: number) => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
isDraggingRef.current = true;
|
|
const startY = e.clientY;
|
|
const currentGrid = gridConfigRef.current;
|
|
if (!currentGrid) return;
|
|
const heights = (currentGrid.rowHeights || Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)).map(parsePx);
|
|
if (lineIdx < 0 || lineIdx + 1 >= heights.length) return;
|
|
const onMove = (me: MouseEvent) => {
|
|
const delta = me.clientY - startY;
|
|
const newH = [...heights];
|
|
newH[lineIdx] = Math.max(MIN_ROW_HEIGHT, heights[lineIdx] + delta);
|
|
newH[lineIdx + 1] = Math.max(MIN_ROW_HEIGHT, heights[lineIdx + 1] - delta);
|
|
onGridChange({ ...currentGrid, rowHeights: newH.map((h) => `${Math.round(h)}px`) });
|
|
};
|
|
const onUp = () => { isDraggingRef.current = false; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
|
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
|
}, [onGridChange]);
|
|
|
|
// 경계선 가시성
|
|
const isColLineVisible = (lineIdx: number): boolean => {
|
|
const leftCol = lineIdx + 1, rightCol = lineIdx + 2;
|
|
for (let r = 1; r <= grid.rows; r++) {
|
|
const left = occupationMap[`${r}-${leftCol}`], right = occupationMap[`${r}-${rightCol}`];
|
|
if (left !== right || (!left && !right)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
const isRowLineVisible = (lineIdx: number): boolean => {
|
|
const topRow = lineIdx + 1, bottomRow = lineIdx + 2;
|
|
for (let c = 1; c <= grid.cols; c++) {
|
|
const top = occupationMap[`${topRow}-${c}`], bottom = occupationMap[`${bottomRow}-${c}`];
|
|
if (top !== bottom || (!top && !bottom)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const selectedCell = selectedCellId ? grid.cells.find((c) => c.id === selectedCellId) : null;
|
|
useEffect(() => {
|
|
if (selectedCellId && !grid.cells.find((c) => c.id === selectedCellId)) setSelectedCellId(null);
|
|
}, [grid.cells, selectedCellId]);
|
|
|
|
const mergeValid = validateMerge();
|
|
|
|
const gridPositions: { row: number; col: number }[] = [];
|
|
for (let r = 1; r <= grid.rows; r++) {
|
|
for (let c = 1; c <= grid.cols; c++) {
|
|
gridPositions.push({ row: r, col: c });
|
|
}
|
|
}
|
|
const rowHeightsArr = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`);
|
|
|
|
// 바 그룹핑
|
|
type BarGroup = { startIdx: number; count: number; totalFr: number };
|
|
const colGroups: BarGroup[] = (() => {
|
|
const groups: BarGroup[] = [];
|
|
if (grid.colWidths.length === 0) return groups;
|
|
let cur: BarGroup = { startIdx: 0, count: 1, totalFr: parseFr(grid.colWidths[0]) };
|
|
for (let i = 0; i < grid.cols - 1; i++) {
|
|
if (isColLineVisible(i)) { groups.push(cur); cur = { startIdx: i + 1, count: 1, totalFr: parseFr(grid.colWidths[i + 1]) }; }
|
|
else { cur.count++; cur.totalFr += parseFr(grid.colWidths[i + 1]); }
|
|
}
|
|
groups.push(cur);
|
|
return groups;
|
|
})();
|
|
|
|
const rowGroups: BarGroup[] = (() => {
|
|
const groups: BarGroup[] = [];
|
|
if (rowHeightsArr.length === 0) return groups;
|
|
let cur: BarGroup = { startIdx: 0, count: 1, totalFr: parsePx(rowHeightsArr[0]) };
|
|
for (let i = 0; i < grid.rows - 1; i++) {
|
|
if (isRowLineVisible(i)) { groups.push(cur); cur = { startIdx: i + 1, count: 1, totalFr: parsePx(rowHeightsArr[i + 1]) }; }
|
|
else { cur.count++; cur.totalFr += parsePx(rowHeightsArr[i + 1]); }
|
|
}
|
|
groups.push(cur);
|
|
return groups;
|
|
})();
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{/* 카드 배치 */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] text-muted-foreground">열 수</span>
|
|
<Button variant="ghost" size="sm" onClick={() => onGridColumnsChange(Math.max(1, (cfg.gridColumns || 3) - 1))} className="h-5 w-5 p-0"><Minus className="h-2.5 w-2.5" /></Button>
|
|
<span className="w-4 text-center text-[10px]">{cfg.gridColumns || 3}</span>
|
|
<Button variant="ghost" size="sm" onClick={() => onGridColumnsChange(Math.min(6, (cfg.gridColumns || 3) + 1))} className="h-5 w-5 p-0"><Plus className="h-2.5 w-2.5" /></Button>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] text-muted-foreground">카드 간격</span>
|
|
<Button variant="ghost" size="sm" onClick={() => onCardGapChange(Math.max(0, (cfg.cardGap || 8) - 4))} className="h-5 w-5 p-0"><Minus className="h-2.5 w-2.5" /></Button>
|
|
<span className="w-6 text-center text-[9px]">{cfg.cardGap || 8}px</span>
|
|
<Button variant="ghost" size="sm" onClick={() => onCardGapChange(Math.min(24, (cfg.cardGap || 8) + 4))} className="h-5 w-5 p-0"><Plus className="h-2.5 w-2.5" /></Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 인라인 툴바 */}
|
|
<div className="flex flex-wrap items-center gap-1.5 rounded border bg-muted/30 px-2 py-1">
|
|
<button
|
|
onClick={() => updateGrid({ showCellBorder: !grid.showCellBorder })}
|
|
className={cn("h-5 rounded border px-1.5 text-[9px] transition-colors", grid.showCellBorder ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-muted-foreground")}
|
|
>보더</button>
|
|
<div className="flex items-center gap-0.5">
|
|
<span className="text-[9px] text-muted-foreground">간격</span>
|
|
<Button variant="ghost" size="sm" onClick={() => updateGrid({ gap: Math.max(GRID_LIMITS.gap.min, grid.gap - 2) })} disabled={grid.gap <= GRID_LIMITS.gap.min} className="h-5 w-5 p-0"><Minus className="h-2.5 w-2.5" /></Button>
|
|
<span className="w-6 text-center text-[9px]">{grid.gap}px</span>
|
|
<Button variant="ghost" size="sm" onClick={() => updateGrid({ gap: Math.min(GRID_LIMITS.gap.max, grid.gap + 2) })} disabled={grid.gap >= GRID_LIMITS.gap.max} className="h-5 w-5 p-0"><Plus className="h-2.5 w-2.5" /></Button>
|
|
</div>
|
|
<button onClick={toggleMergeMode} className={cn("h-5 rounded border px-1.5 text-[9px] transition-colors", mergeMode ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-muted-foreground")}>병합</button>
|
|
<div className="mx-0.5 h-3 w-px bg-border" />
|
|
<Button variant="outline" size="sm" onClick={() => { if (selectedCell) splitCellHorizontally(selectedCell); }} disabled={!selectedCell || (grid.cols >= GRID_LIMITS.cols.max && (Number(selectedCell?.colSpan) || 1) <= 1)} className="h-5 px-1.5 text-[9px]">칸 나누기</Button>
|
|
<Button variant="outline" size="sm" onClick={() => { if (selectedCell) splitCellVertically(selectedCell); }} disabled={!selectedCell || (grid.rows >= GRID_LIMITS.rows.max && (Number(selectedCell?.rowSpan) || 1) <= 1)} className="h-5 px-1.5 text-[9px]">줄 나누기</Button>
|
|
</div>
|
|
|
|
{/* 병합 모드 안내 */}
|
|
{mergeMode && (
|
|
<div className="flex items-center gap-1 rounded border border-primary/50 bg-primary/5 px-2 py-1">
|
|
<span className="flex-1 text-[9px] text-primary">
|
|
{mergeCellKeys.size > 0 ? `${mergeCellKeys.size}칸 선택됨${mergeValid ? " (병합 가능)" : " (직사각형으로 선택)"}` : "빈 셀을 클릭하여 선택"}
|
|
</span>
|
|
<Button variant="default" size="sm" onClick={confirmMerge} disabled={!mergeValid} className="h-5 px-2 text-[9px]">확정</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeCellKeys(new Set()); }} className="h-5 px-1.5 text-[9px]">취소</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 열 너비 드래그 바 */}
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-4 shrink-0" />
|
|
<div ref={widthBarRef} className="flex h-5 flex-1 select-none overflow-hidden rounded border">
|
|
{colGroups.map((group, gi) => (
|
|
<Fragment key={gi}>
|
|
<div className="flex items-center justify-center bg-muted/30 text-[7px] text-muted-foreground" style={{ flex: group.totalFr }}>
|
|
{group.count > 1 ? `${Math.round(group.totalFr * 10) / 10}fr` : grid.colWidths[group.startIdx]}
|
|
</div>
|
|
{gi < colGroups.length - 1 && (
|
|
<div className="w-1 shrink-0 cursor-col-resize bg-border transition-colors hover:bg-primary" onMouseDown={(e) => handleColDragStart(e, group.startIdx + group.count - 1)} />
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 행 높이 바 + 그리드 */}
|
|
<div className="flex gap-1">
|
|
<div className="flex w-4 shrink-0 select-none flex-col overflow-hidden rounded border">
|
|
{rowGroups.map((group, gi) => (
|
|
<Fragment key={gi}>
|
|
<div className="flex items-center justify-center bg-muted/30 text-[6px] text-muted-foreground" style={{ flex: group.totalFr }}>{Math.round(group.totalFr)}</div>
|
|
{gi < rowGroups.length - 1 && (
|
|
<div className="h-1 shrink-0 cursor-row-resize bg-border transition-colors hover:bg-primary" onMouseDown={(e) => handleRowDragStart(e, group.startIdx + group.count - 1)} />
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
<div className="relative flex-1">
|
|
<div
|
|
ref={gridRef}
|
|
className="rounded border bg-muted/10 p-0.5"
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: grid.colWidths.length > 0 ? grid.colWidths.map((w) => `minmax(30px, ${w})`).join(" ") : "1fr",
|
|
gridTemplateRows: rowHeightsArr.join(" "),
|
|
gap: `${Number(grid.gap) || 0}px`,
|
|
}}
|
|
>
|
|
{gridPositions.map(({ row, col }) => {
|
|
const cellAtOrigin = getCellByOrigin(row, col);
|
|
const occupiedBy = occupationMap[`${row}-${col}`];
|
|
const isMergeSelected = mergeCellKeys.has(`${row}-${col}`);
|
|
if (occupiedBy && !cellAtOrigin) return null;
|
|
if (cellAtOrigin) {
|
|
const isSelected = selectedCellId === cellAtOrigin.id;
|
|
return (
|
|
<div
|
|
key={`${row}-${col}`}
|
|
className={cn("flex cursor-pointer items-center justify-center rounded p-0.5 transition-all", isSelected ? "bg-primary/20 ring-2 ring-primary" : "bg-primary/10 hover:bg-primary/15")}
|
|
style={{
|
|
gridColumn: `${col} / span ${Number(cellAtOrigin.colSpan) || 1}`,
|
|
gridRow: `${row} / span ${Number(cellAtOrigin.rowSpan) || 1}`,
|
|
border: grid.showCellBorder ? "1px solid hsl(var(--border))" : "none",
|
|
}}
|
|
onClick={() => handleCellClick(cellAtOrigin)}
|
|
>
|
|
<div className="flex flex-col items-center gap-0.5 overflow-hidden text-center">
|
|
<span className="truncate text-[8px] font-medium text-primary">{cellAtOrigin.columnName || cellAtOrigin.label || "미지정"}</span>
|
|
<span className="text-[6px] text-muted-foreground">{V2_CELL_TYPE_LABELS[cellAtOrigin.type]?.label || cellAtOrigin.type}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
key={`${row}-${col}`}
|
|
className={cn(
|
|
"flex cursor-pointer items-center justify-center rounded border border-dashed transition-colors",
|
|
isMergeSelected ? "border-primary bg-primary/20 text-primary" : mergeMode ? "border-primary/40 text-primary/40 hover:border-primary hover:bg-primary/10" : "border-muted-foreground/30 text-muted-foreground/40 hover:border-primary/50 hover:bg-primary/5"
|
|
)}
|
|
style={{ gridColumn: `${col} / span 1`, gridRow: `${row} / span 1` }}
|
|
onClick={() => handleEmptyCellClick(row, col)}
|
|
>
|
|
{isMergeSelected ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{/* 내부 경계 드래그 오버레이 */}
|
|
<div className="pointer-events-none absolute inset-0" style={{ padding: "2px" }}>
|
|
{gridLines.colLines.map((x, i) => {
|
|
if (!isColLineVisible(i)) return null;
|
|
return <div key={`ch-${i}`} className="pointer-events-auto absolute top-0 bottom-0 z-10 cursor-col-resize transition-colors hover:bg-primary/30" style={{ left: `${x - 3}px`, width: "6px" }} onMouseDown={(e) => handleInternalColDrag(e, i)} />;
|
|
})}
|
|
{gridLines.rowLines.map((y, i) => {
|
|
if (!isRowLineVisible(i)) return null;
|
|
return <div key={`rh-${i}`} className="pointer-events-auto absolute left-0 right-0 z-10 cursor-row-resize transition-colors hover:bg-primary/30" style={{ top: `${y - 3}px`, height: "6px" }} onMouseDown={(e) => handleInternalRowDrag(e, i)} />;
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-[8px] text-muted-foreground">
|
|
{grid.cols}열 x {grid.rows}행 (최대 {GRID_LIMITS.cols.max}x{GRID_LIMITS.rows.max})
|
|
</p>
|
|
|
|
{/* 선택된 셀 설정 패널 */}
|
|
{selectedCell && !mergeMode && (
|
|
<CellDetailEditor
|
|
cell={selectedCell}
|
|
allCells={grid.cells}
|
|
allColumnOptions={allColumnOptions}
|
|
columns={columns}
|
|
selectedColumns={selectedColumns}
|
|
tables={tables}
|
|
dataSource={cfg.dataSource}
|
|
onUpdate={(partial) => updateCell(selectedCell.id, partial)}
|
|
onRemove={() => removeCell(selectedCell.id)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 셀 상세 에디터 (타입별 인라인) =====
|
|
|
|
function CellDetailEditor({
|
|
cell,
|
|
allCells,
|
|
allColumnOptions,
|
|
columns,
|
|
selectedColumns,
|
|
tables,
|
|
dataSource,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
allCells: CardCellDefinitionV2[];
|
|
allColumnOptions: { value: string; label: string }[];
|
|
columns: ColumnInfo[];
|
|
selectedColumns: string[];
|
|
tables: TableInfo[];
|
|
dataSource: CardListDataSource;
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const availableTableOptions = useMemo(() => {
|
|
const opts: { value: string; label: string }[] = [];
|
|
if (dataSource.tableName) {
|
|
opts.push({ value: dataSource.tableName, label: `${dataSource.tableName} (메인)` });
|
|
}
|
|
for (const j of dataSource.joins || []) {
|
|
if (j.targetTable) {
|
|
opts.push({ value: j.targetTable, label: `${j.targetTable} (조인)` });
|
|
}
|
|
}
|
|
const added = new Set(opts.map((o) => o.value));
|
|
for (const c of allCells) {
|
|
const pt = c.timelineSource?.processTable;
|
|
if (pt && !added.has(pt)) {
|
|
opts.push({ value: pt, label: `${pt} (타임라인)` });
|
|
added.add(pt);
|
|
}
|
|
}
|
|
return opts;
|
|
}, [dataSource, allCells]);
|
|
|
|
return (
|
|
<div className="space-y-2 rounded border bg-muted/20 p-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-medium">
|
|
셀 (행{cell.row} 열{cell.col}
|
|
{((Number(cell.colSpan) || 1) > 1 || (Number(cell.rowSpan) || 1) > 1) && `, ${Number(cell.colSpan) || 1}x${Number(cell.rowSpan) || 1}`})
|
|
</span>
|
|
<Button variant="ghost" size="sm" onClick={onRemove} className="h-5 w-5 p-0">
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 컬럼 + 타입 */}
|
|
<div className="flex gap-1">
|
|
{cell.type !== "action-buttons" && (
|
|
<Select value={cell.columnName || "none"} onValueChange={(v) => onUpdate({ columnName: v === "none" ? "" : v })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none" className="text-[10px]">미지정</SelectItem>
|
|
{allColumnOptions.map((o) => (
|
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
<Select value={cell.type} onValueChange={(v) => onUpdate({ type: v as CardCellType })}>
|
|
<SelectTrigger className={cn("h-7 text-[10px]", cell.type === "action-buttons" ? "flex-1" : "w-24")}><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{CELL_TYPE_GROUPS.map((group) => {
|
|
const types = (Object.entries(V2_CELL_TYPE_LABELS) as [CardCellType, { label: string; group: string }][]).filter(([, v]) => v.group === group);
|
|
if (types.length === 0) return null;
|
|
return (
|
|
<Fragment key={group}>
|
|
<SelectItem value={`__group_${group}__`} disabled className="text-[8px] font-medium text-muted-foreground">{group}</SelectItem>
|
|
{types.map(([key, v]) => (
|
|
<SelectItem key={key} value={key} className="text-[10px]">{v.label}</SelectItem>
|
|
))}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 라벨 + 위치 */}
|
|
<div className="flex gap-1">
|
|
<Input value={cell.label || ""} onChange={(e) => onUpdate({ label: e.target.value })} placeholder="라벨 (선택)" className="h-7 flex-1 text-[10px]" />
|
|
<Select value={cell.labelPosition || "top"} onValueChange={(v) => onUpdate({ labelPosition: v as "top" | "left" })}>
|
|
<SelectTrigger className="h-7 w-16 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="top" className="text-[10px]">위</SelectItem>
|
|
<SelectItem value="left" className="text-[10px]">왼쪽</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 크기 + 정렬 */}
|
|
<div className="flex gap-1">
|
|
<Select value={cell.fontSize || "md"} onValueChange={(v) => onUpdate({ fontSize: v as CardCellDefinitionV2["fontSize"] })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="xs" className="text-[10px]">아주 작게</SelectItem>
|
|
<SelectItem value="sm" className="text-[10px]">작게</SelectItem>
|
|
<SelectItem value="md" className="text-[10px]">보통</SelectItem>
|
|
<SelectItem value="lg" className="text-[10px]">크게</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={cell.align || "left"} onValueChange={(v) => onUpdate({ align: v as CardCellDefinitionV2["align"] })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="left" className="text-[10px]">좌측</SelectItem>
|
|
<SelectItem value="center" className="text-[10px]">중앙</SelectItem>
|
|
<SelectItem value="right" className="text-[10px]">우측</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={cell.verticalAlign || "top"} onValueChange={(v) => onUpdate({ verticalAlign: v as CardCellDefinitionV2["verticalAlign"] })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="top" className="text-[10px]">상단</SelectItem>
|
|
<SelectItem value="middle" className="text-[10px]">중간</SelectItem>
|
|
<SelectItem value="bottom" className="text-[10px]">하단</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 타입별 상세 설정 */}
|
|
{cell.type === "status-badge" && <StatusMappingEditor cell={cell} allCells={allCells} onUpdate={onUpdate} />}
|
|
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
|
|
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allCells={allCells} allColumnOptions={allColumnOptions} availableTableOptions={availableTableOptions} onUpdate={onUpdate} />}
|
|
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
|
{cell.type === "field" && <FieldConfigEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
|
{cell.type === "number-input" && (
|
|
<div className="space-y-1">
|
|
<span className="text-[9px] font-medium text-muted-foreground">숫자 입력 설정</span>
|
|
<div className="flex gap-1">
|
|
<Input value={cell.inputUnit || ""} onChange={(e) => onUpdate({ inputUnit: e.target.value })} placeholder="단위 (EA)" className="h-7 flex-1 text-[10px]" />
|
|
<Select value={cell.limitColumn || "__none__"} onValueChange={(v) => onUpdate({ limitColumn: v === "__none__" ? undefined : v })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="제한 컬럼" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
|
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{cell.type === "cart-button" && (
|
|
<div className="space-y-1">
|
|
<span className="text-[9px] font-medium text-muted-foreground">담기 버튼 설정</span>
|
|
<div className="flex gap-1">
|
|
<Input value={cell.cartLabel || ""} onChange={(e) => onUpdate({ cartLabel: e.target.value })} placeholder="담기" className="h-7 flex-1 text-[10px]" />
|
|
<Input value={cell.cartCancelLabel || ""} onChange={(e) => onUpdate({ cartCancelLabel: e.target.value })} placeholder="취소" className="h-7 flex-1 text-[10px]" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 상태 배지 매핑 에디터 =====
|
|
|
|
const SEMANTIC_COLORS: Record<string, string> = {
|
|
pending: "#64748b", active: "#3b82f6", done: "#10b981",
|
|
};
|
|
|
|
function StatusMappingEditor({
|
|
cell,
|
|
allCells,
|
|
onUpdate,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
allCells: CardCellDefinitionV2[];
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
}) {
|
|
const statusMap = cell.statusMap || [];
|
|
|
|
const timelineCell = allCells.find(
|
|
(c) => c.type === "timeline" && c.timelineSource?.statusMappings?.length,
|
|
);
|
|
const hasTimeline = !!timelineCell;
|
|
|
|
const loadFromTimeline = () => {
|
|
const src = timelineCell?.timelineSource;
|
|
if (!src?.statusMappings) return;
|
|
const partial: Partial<CardCellDefinitionV2> = {
|
|
statusMap: src.statusMappings.map((m) => ({
|
|
value: m.dbValue,
|
|
label: m.label,
|
|
color: SEMANTIC_COLORS[m.semantic] || "#6b7280",
|
|
})),
|
|
};
|
|
if (src.statusColumn) {
|
|
partial.column = src.statusColumn;
|
|
}
|
|
onUpdate(partial);
|
|
};
|
|
|
|
const addMapping = () => {
|
|
onUpdate({ statusMap: [...statusMap, { value: "", label: "", color: "#6b7280" }] });
|
|
};
|
|
|
|
const updateMapping = (index: number, partial: Partial<{ value: string; label: string; color: string }>) => {
|
|
onUpdate({ statusMap: statusMap.map((m, i) => (i === index ? { ...m, ...partial } : m)) });
|
|
};
|
|
|
|
const removeMapping = (index: number) => {
|
|
onUpdate({ statusMap: statusMap.filter((_, i) => i !== index) });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[9px] font-medium text-muted-foreground">상태값-색상 매핑</span>
|
|
<div className="flex gap-1">
|
|
{hasTimeline && (
|
|
<Button variant="ghost" size="sm" onClick={loadFromTimeline} className="h-5 px-1.5 text-[9px]">
|
|
타임라인 연동
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
|
|
<Plus className="mr-0.5 h-3 w-3" />추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{statusMap.map((m, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<Input value={m.value} onChange={(e) => updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" />
|
|
<Input value={m.label} onChange={(e) => updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
|
|
<input type="color" value={m.color} onChange={(e) => updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" />
|
|
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 타임라인 설정 =====
|
|
|
|
function TimelineConfigEditor({
|
|
cell,
|
|
allColumnOptions,
|
|
tables,
|
|
onUpdate,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
allColumnOptions: { value: string; label: string }[];
|
|
tables: TableInfo[];
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
}) {
|
|
const src = cell.timelineSource || { processTable: "", foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" };
|
|
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
|
|
const [tableOpen, setTableOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!src.processTable) { setProcessColumns([]); return; }
|
|
fetchTableColumns(src.processTable)
|
|
.then(setProcessColumns)
|
|
.catch(() => setProcessColumns([]));
|
|
}, [src.processTable]);
|
|
|
|
const updateSource = (partial: Partial<TimelineDataSource>) => {
|
|
onUpdate({ timelineSource: { ...src, ...partial } });
|
|
};
|
|
|
|
const colOptions = processColumns.map((c) => ({ value: c.name, label: c.name }));
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<span className="text-[9px] font-medium text-muted-foreground">하위 데이터 소스</span>
|
|
|
|
{/* 하위 테이블 선택 */}
|
|
<div>
|
|
<Label className="text-[9px] text-muted-foreground">하위 테이블</Label>
|
|
<Popover open={tableOpen} onOpenChange={setTableOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" role="combobox" className="mt-0.5 h-7 w-full justify-between text-[10px] font-normal">
|
|
{src.processTable || "테이블 선택"}
|
|
<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-[10px]" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-[10px]">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{tables.map((t) => (
|
|
<CommandItem
|
|
key={t.tableName}
|
|
value={`${t.tableName} ${t.displayName || ""}`}
|
|
onSelect={() => {
|
|
updateSource({ processTable: t.tableName, foreignKey: "", seqColumn: "", nameColumn: "", statusColumn: "" });
|
|
setTableOpen(false);
|
|
}}
|
|
className="text-[10px]"
|
|
>
|
|
<Check className={cn("mr-1 h-3 w-3", src.processTable === t.tableName ? "opacity-100" : "opacity-0")} />
|
|
{t.displayName || t.tableName}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 컬럼 매핑 (하위 테이블 선택 후) */}
|
|
{src.processTable && processColumns.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px] text-muted-foreground">컬럼 매핑</Label>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<div>
|
|
<span className="text-[8px] text-muted-foreground">연결 FK</span>
|
|
<Select value={src.foreignKey || "__none__"} onValueChange={(v) => updateSource({ foreignKey: v === "__none__" ? "" : v })}>
|
|
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">미선택</SelectItem>
|
|
{colOptions.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<span className="text-[8px] text-muted-foreground">순서</span>
|
|
<Select value={src.seqColumn || "__none__"} onValueChange={(v) => updateSource({ seqColumn: v === "__none__" ? "" : v })}>
|
|
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">미선택</SelectItem>
|
|
{colOptions.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<span className="text-[8px] text-muted-foreground">표시명</span>
|
|
<Select value={src.nameColumn || "__none__"} onValueChange={(v) => updateSource({ nameColumn: v === "__none__" ? "" : v })}>
|
|
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">미선택</SelectItem>
|
|
{colOptions.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<span className="text-[8px] text-muted-foreground">상태</span>
|
|
<Select value={src.statusColumn || "__none__"} onValueChange={(v) => updateSource({ statusColumn: v === "__none__" ? "" : v })}>
|
|
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">미선택</SelectItem>
|
|
{colOptions.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 상태 값 매핑 (동적 배열) */}
|
|
{src.processTable && src.statusColumn && (
|
|
<StatusMappingsEditor
|
|
mappings={src.statusMappings || []}
|
|
onChange={(mappings) => updateSource({ statusMappings: mappings })}
|
|
/>
|
|
)}
|
|
|
|
{/* 구분선 */}
|
|
<div className="border-t pt-2">
|
|
<span className="text-[9px] font-medium text-muted-foreground">표시 옵션</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-20 shrink-0 text-[9px] text-muted-foreground">최대 표시 수</span>
|
|
<Input
|
|
type="number"
|
|
min={3}
|
|
max={10}
|
|
value={cell.visibleCount || 5}
|
|
onChange={(e) => onUpdate({ visibleCount: parseInt(e.target.value) || 5 })}
|
|
className="h-7 w-16 text-[10px]"
|
|
/>
|
|
<Select
|
|
value={cell.timelinePriority || "before"}
|
|
onValueChange={(v) => onUpdate({ timelinePriority: v as "before" | "after" })}
|
|
>
|
|
<SelectTrigger className="h-7 w-[72px] text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="before">전 우선</SelectItem>
|
|
<SelectItem value="after">후 우선</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Label className="w-20 shrink-0 text-[9px] text-muted-foreground">현재 항목 강조</Label>
|
|
<Switch
|
|
checked={cell.currentHighlight !== false}
|
|
onCheckedChange={(v) => onUpdate({ currentHighlight: v })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Label className="w-20 shrink-0 text-[9px] text-muted-foreground">클릭시 상세</Label>
|
|
<Switch
|
|
checked={cell.showDetailModal !== false}
|
|
onCheckedChange={(v) => onUpdate({ showDetailModal: v })}
|
|
/>
|
|
<span className="text-[8px] text-muted-foreground">전체 목록 모달</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 상태 값 매핑 에디터 (동적 배열) =====
|
|
|
|
const SEMANTIC_OPTIONS: { value: TimelineStatusSemantic; label: string }[] = [
|
|
{ value: "pending", label: "대기" },
|
|
{ value: "active", label: "진행" },
|
|
{ value: "done", label: "완료" },
|
|
];
|
|
|
|
const DEFAULT_STATUS_MAPPINGS: StatusValueMapping[] = [
|
|
{ dbValue: "waiting", label: "대기", semantic: "pending" },
|
|
{ dbValue: "accepted", label: "접수", semantic: "active" },
|
|
{ dbValue: "in_progress", label: "진행중", semantic: "active" },
|
|
{ dbValue: "completed", label: "완료", semantic: "done" },
|
|
];
|
|
|
|
function StatusMappingsEditor({
|
|
mappings,
|
|
onChange,
|
|
}: {
|
|
mappings: StatusValueMapping[];
|
|
onChange: (mappings: StatusValueMapping[]) => void;
|
|
}) {
|
|
const addMapping = () => {
|
|
onChange([...mappings, { dbValue: "", label: "", semantic: "pending" }]);
|
|
};
|
|
|
|
const updateMapping = (index: number, partial: Partial<StatusValueMapping>) => {
|
|
onChange(mappings.map((m, i) => (i === index ? { ...m, ...partial } : m)));
|
|
};
|
|
|
|
const removeMapping = (index: number) => {
|
|
onChange(mappings.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const applyDefaults = () => {
|
|
onChange([...DEFAULT_STATUS_MAPPINGS]);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] text-muted-foreground">상태 값 매핑</Label>
|
|
<div className="flex gap-1">
|
|
{mappings.length === 0 && (
|
|
<Button variant="ghost" size="sm" onClick={applyDefaults} className="h-5 px-1.5 text-[9px]">
|
|
기본값 적용
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
|
|
<Plus className="mr-0.5 h-3 w-3" />추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-[8px] text-muted-foreground">DB 값, 화면 라벨, 의미(대기/진행/완료)를 매핑합니다.</p>
|
|
{mappings.map((m, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<Input
|
|
value={m.dbValue}
|
|
onChange={(e) => updateMapping(i, { dbValue: e.target.value })}
|
|
placeholder="DB 값"
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
<Input
|
|
value={m.label}
|
|
onChange={(e) => updateMapping(i, { label: e.target.value })}
|
|
placeholder="라벨"
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
<Select value={m.semantic} onValueChange={(v) => updateMapping(i, { semantic: v as TimelineStatusSemantic })}>
|
|
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{SEMANTIC_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<label className="flex shrink-0 cursor-pointer items-center gap-0.5" title="기준 상태 (타임라인 강조 + 상태 판별 기준)">
|
|
<input
|
|
type="radio"
|
|
name="isDerived"
|
|
checked={!!m.isDerived}
|
|
onChange={() => {
|
|
const wasChecked = !!m.isDerived;
|
|
onChange(mappings.map((item, idx) => ({
|
|
...item,
|
|
isDerived: idx === i && !wasChecked ? true : undefined,
|
|
})));
|
|
}}
|
|
className="h-3 w-3"
|
|
/>
|
|
<span className="text-[8px] text-muted-foreground">기준</span>
|
|
</label>
|
|
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 액션 버튼 에디터 (버튼 중심 구조) =====
|
|
|
|
function ActionButtonsEditor({
|
|
cell,
|
|
allCells,
|
|
allColumnOptions,
|
|
availableTableOptions,
|
|
onUpdate,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
allCells: CardCellDefinitionV2[];
|
|
allColumnOptions: { value: string; label: string }[];
|
|
availableTableOptions: { value: string; label: string }[];
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
}) {
|
|
const buttons = cell.actionButtons || [];
|
|
|
|
const statusOptions = useMemo(() => {
|
|
const timelineCell = allCells.find(
|
|
(c) => c.type === "timeline" && c.timelineSource?.statusMappings?.length,
|
|
);
|
|
return timelineCell?.timelineSource?.statusMappings?.map((m) => ({
|
|
value: m.dbValue,
|
|
label: m.label,
|
|
})) || [];
|
|
}, [allCells]);
|
|
|
|
const updateButtons = (newBtns: ActionButtonDef[]) => {
|
|
onUpdate({ actionButtons: newBtns });
|
|
};
|
|
|
|
const addButton = () => {
|
|
updateButtons([...buttons, {
|
|
label: "",
|
|
variant: "default" as ButtonVariant,
|
|
showCondition: { type: "always" },
|
|
clickAction: { type: "immediate" },
|
|
}]);
|
|
};
|
|
|
|
const updateBtn = (idx: number, partial: Partial<ActionButtonDef>) => {
|
|
updateButtons(buttons.map((b, i) => (i === idx ? { ...b, ...partial } : b)));
|
|
};
|
|
|
|
const removeBtn = (idx: number) => {
|
|
updateButtons(buttons.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const updateCondition = (idx: number, partial: Partial<ActionButtonShowCondition>) => {
|
|
const btn = buttons[idx];
|
|
updateBtn(idx, { showCondition: { ...(btn.showCondition || { type: "always" }), ...partial } });
|
|
};
|
|
|
|
// 다중 액션 체이닝 지원: clickActions 배열 우선, 없으면 clickAction 단일값 폴백
|
|
const getActions = (btn: ActionButtonDef): ActionButtonClickAction[] => {
|
|
if (btn.clickActions && btn.clickActions.length > 0) return btn.clickActions;
|
|
return [btn.clickAction];
|
|
};
|
|
|
|
const setActions = (bIdx: number, actions: ActionButtonClickAction[]) => {
|
|
updateBtn(bIdx, { clickActions: actions, clickAction: actions[0] || { type: "immediate" } });
|
|
};
|
|
|
|
const updateAction = (idx: number, aIdx: number, partial: Partial<ActionButtonClickAction>) => {
|
|
const actions = [...getActions(buttons[idx])];
|
|
actions[aIdx] = { ...actions[aIdx], ...partial };
|
|
setActions(idx, actions);
|
|
};
|
|
|
|
const addAction = (bIdx: number) => {
|
|
const actions = [...getActions(buttons[bIdx]), { type: "immediate" as const }];
|
|
setActions(bIdx, actions);
|
|
};
|
|
|
|
const removeAction = (bIdx: number, aIdx: number) => {
|
|
const actions = getActions(buttons[bIdx]).filter((_, i) => i !== aIdx);
|
|
if (actions.length === 0) actions.push({ type: "immediate" as const });
|
|
setActions(bIdx, actions);
|
|
};
|
|
|
|
const addActionUpdate = (bIdx: number, aIdx: number) => {
|
|
const actions = getActions(buttons[bIdx]);
|
|
const a = actions[aIdx];
|
|
updateAction(bIdx, aIdx, { updates: [...(a.updates || []), { column: "", value: "", valueType: "static" as const }] });
|
|
};
|
|
|
|
const updateActionUpdate = (bIdx: number, aIdx: number, uIdx: number, partial: Partial<ActionButtonUpdate>) => {
|
|
const a = getActions(buttons[bIdx])[aIdx];
|
|
updateAction(bIdx, aIdx, { updates: (a.updates || []).map((u, i) => (i === uIdx ? { ...u, ...partial } : u)) });
|
|
};
|
|
|
|
const removeActionUpdate = (bIdx: number, aIdx: number, uIdx: number) => {
|
|
const a = getActions(buttons[bIdx])[aIdx];
|
|
updateAction(bIdx, aIdx, { updates: (a.updates || []).filter((_, i) => i !== uIdx) });
|
|
};
|
|
|
|
const addSelectModeBtn = (bIdx: number, aIdx: number) => {
|
|
const a = getActions(buttons[bIdx])[aIdx];
|
|
const smBtns = a.selectModeButtons || [];
|
|
updateAction(bIdx, aIdx, { selectModeButtons: [...smBtns, { label: "", variant: "outline" as ButtonVariant, clickMode: "cancel-select" as const }] });
|
|
};
|
|
|
|
const updateSelectModeBtn = (bIdx: number, aIdx: number, smIdx: number, partial: Partial<SelectModeButtonConfig>) => {
|
|
const a = getActions(buttons[bIdx])[aIdx];
|
|
const smBtns = (a.selectModeButtons || []).map((s, i) => (i === smIdx ? { ...s, ...partial } : s));
|
|
updateAction(bIdx, aIdx, { selectModeButtons: smBtns });
|
|
};
|
|
|
|
const removeSelectModeBtn = (bIdx: number, aIdx: number, smIdx: number) => {
|
|
const a = getActions(buttons[bIdx])[aIdx];
|
|
updateAction(bIdx, aIdx, { selectModeButtons: (a.selectModeButtons || []).filter((_, i) => i !== smIdx) });
|
|
};
|
|
|
|
const addSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number) => {
|
|
const a = getActions(buttons[bIdx])[aIdx];
|
|
const smBtns = [...(a.selectModeButtons || [])];
|
|
smBtns[smIdx] = { ...smBtns[smIdx], updates: [...(smBtns[smIdx].updates || []), { column: "", value: "", valueType: "static" as const }] };
|
|
updateAction(bIdx, aIdx, { selectModeButtons: smBtns });
|
|
};
|
|
|
|
const updateSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number, uIdx: number, partial: Partial<ActionButtonUpdate>) => {
|
|
const a = getActions(buttons[bIdx])[aIdx];
|
|
const smBtns = [...(a.selectModeButtons || [])];
|
|
smBtns[smIdx] = { ...smBtns[smIdx], updates: (smBtns[smIdx].updates || []).map((u, i) => (i === uIdx ? { ...u, ...partial } : u)) };
|
|
updateAction(bIdx, aIdx, { selectModeButtons: smBtns });
|
|
};
|
|
|
|
const removeSmBtnUpdate = (bIdx: number, aIdx: number, smIdx: number, uIdx: number) => {
|
|
const a = getActions(buttons[bIdx])[aIdx];
|
|
const smBtns = [...(a.selectModeButtons || [])];
|
|
smBtns[smIdx] = { ...smBtns[smIdx], updates: (smBtns[smIdx].updates || []).filter((_, i) => i !== uIdx) };
|
|
updateAction(bIdx, aIdx, { selectModeButtons: smBtns });
|
|
};
|
|
|
|
|
|
const storageKey = `action-btn-editor-${cell.row}-${cell.col}`;
|
|
|
|
const [expandedBtns, setExpandedBtns] = useState<Set<number>>(() => {
|
|
try {
|
|
const saved = sessionStorage.getItem(`${storageKey}-btns`);
|
|
return saved ? new Set(JSON.parse(saved) as number[]) : new Set();
|
|
} catch { return new Set(); }
|
|
});
|
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => {
|
|
try {
|
|
const saved = sessionStorage.getItem(`${storageKey}-secs`);
|
|
return saved ? JSON.parse(saved) : {};
|
|
} catch { return {}; }
|
|
});
|
|
|
|
useEffect(() => {
|
|
try { sessionStorage.setItem(`${storageKey}-btns`, JSON.stringify([...expandedBtns])); } catch {}
|
|
}, [expandedBtns, storageKey]);
|
|
|
|
useEffect(() => {
|
|
try { sessionStorage.setItem(`${storageKey}-secs`, JSON.stringify(expandedSections)); } catch {}
|
|
}, [expandedSections, storageKey]);
|
|
|
|
const toggleBtn = (idx: number) => {
|
|
setExpandedBtns((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(idx)) next.delete(idx); else next.add(idx);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleSection = (key: string) => {
|
|
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
};
|
|
|
|
const isSectionOpen = (key: string) => expandedSections[key] !== false;
|
|
|
|
const ACTION_TYPE_LABELS: Record<string, string> = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" };
|
|
|
|
const getCondSummary = (btn: ActionButtonDef) => {
|
|
const c = btn.showCondition;
|
|
if (!c || c.type === "always") return "항상";
|
|
if (c.type === "timeline-status") {
|
|
const opt = statusOptions.find((o) => o.value === c.value);
|
|
return opt ? opt.label : (c.value || "미설정");
|
|
}
|
|
if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`;
|
|
return "항상";
|
|
};
|
|
|
|
const addButtonAndExpand = () => {
|
|
addButton();
|
|
setExpandedBtns((prev) => new Set([...prev, buttons.length]));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[9px] font-medium text-muted-foreground">버튼 규칙</span>
|
|
<Button variant="ghost" size="sm" onClick={addButtonAndExpand} className="h-5 px-1.5 text-[9px]">
|
|
<Plus className="mr-0.5 h-3 w-3" />추가
|
|
</Button>
|
|
</div>
|
|
|
|
{buttons.length === 0 && (
|
|
<p className="text-[8px] text-muted-foreground">버튼 규칙을 추가하세요. 상태별로 다른 버튼을 설정할 수 있습니다.</p>
|
|
)}
|
|
|
|
{buttons.map((btn, bi) => {
|
|
const condType = btn.showCondition?.type || "always";
|
|
const actions = getActions(btn);
|
|
const isExpanded = expandedBtns.has(bi);
|
|
const actionSummary = actions.map((a) => ACTION_TYPE_LABELS[a.type] || a.type).join(" -> ");
|
|
|
|
return (
|
|
<div key={bi} className="rounded border">
|
|
{/* 접기/펼치기 헤더 */}
|
|
<div
|
|
className="flex cursor-pointer items-center gap-1 p-1.5 hover:bg-muted/40"
|
|
onClick={() => toggleBtn(bi)}
|
|
>
|
|
{isExpanded
|
|
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
|
<span className="shrink-0 text-[9px] font-bold text-muted-foreground">#{bi + 1}</span>
|
|
{isExpanded ? (
|
|
<>
|
|
<Input
|
|
value={btn.label}
|
|
onChange={(e) => updateBtn(bi, { label: e.target.value })}
|
|
onClick={(e) => e.stopPropagation()}
|
|
placeholder="라벨"
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
<Select value={btn.variant || "default"} onValueChange={(v) => updateBtn(bi, { variant: v as ButtonVariant })}>
|
|
<SelectTrigger className="h-6 w-16 text-[10px]" onClick={(e) => e.stopPropagation()}><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default" className="text-[10px]">기본</SelectItem>
|
|
<SelectItem value="outline" className="text-[10px]">외곽</SelectItem>
|
|
<SelectItem value="destructive" className="text-[10px]">위험</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</>
|
|
) : (
|
|
<span className="flex-1 truncate text-[9px] text-muted-foreground">
|
|
{btn.label || "(미입력)"} <span className="text-muted-foreground/60">|</span> {getCondSummary(btn)} <span className="text-muted-foreground/60">|</span> {actionSummary}
|
|
</span>
|
|
)}
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
onClick={(e) => { e.stopPropagation(); removeBtn(bi); }}
|
|
className="h-5 w-5 shrink-0 p-0"
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 펼쳐진 상세 */}
|
|
{isExpanded && (
|
|
<div className="space-y-0.5 border-t px-2 pb-2">
|
|
{/* === 조건 섹션 === */}
|
|
<div
|
|
className="flex cursor-pointer items-center gap-1 py-1 hover:bg-muted/30"
|
|
onClick={() => toggleSection(`${bi}-cond`)}
|
|
>
|
|
{isSectionOpen(`${bi}-cond`)
|
|
? <ChevronDown className="h-2.5 w-2.5 text-muted-foreground" />
|
|
: <ChevronRight className="h-2.5 w-2.5 text-muted-foreground" />}
|
|
<span className="text-[8px] font-medium text-muted-foreground">조건</span>
|
|
{!isSectionOpen(`${bi}-cond`) && (
|
|
<span className="truncate text-[8px] text-muted-foreground/70">{getCondSummary(btn)}</span>
|
|
)}
|
|
</div>
|
|
{isSectionOpen(`${bi}-cond`) && (
|
|
<div className="space-y-1 pl-3">
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-10 shrink-0 text-[8px] text-muted-foreground">활성화</span>
|
|
<Select value={condType} onValueChange={(v) => updateCondition(bi, { type: v as ActionButtonShowCondition["type"] })}>
|
|
<SelectTrigger className="h-6 w-[80px] text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="always" className="text-[10px]">항상</SelectItem>
|
|
<SelectItem value="timeline-status" className="text-[10px]">타임라인</SelectItem>
|
|
<SelectItem value="column-value" className="text-[10px]">카드 컬럼</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{condType === "timeline-status" && (
|
|
<Select
|
|
value={btn.showCondition?.value || "__none__"}
|
|
onValueChange={(v) => updateCondition(bi, { value: v === "__none__" ? "" : v })}
|
|
>
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="상태 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
|
{statusOptions.map((o) => (
|
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label} ({o.value})</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
{condType === "column-value" && (
|
|
<>
|
|
<Select
|
|
value={btn.showCondition?.column || "__none__"}
|
|
onValueChange={(v) => updateCondition(bi, { column: v === "__none__" ? "" : v })}
|
|
>
|
|
<SelectTrigger className="h-6 w-24 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
|
{allColumnOptions.map((o) => (
|
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
value={btn.showCondition?.value || ""}
|
|
onChange={(e) => updateCondition(bi, { value: e.target.value })}
|
|
placeholder="값"
|
|
className="h-6 w-20 text-[10px]"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
{condType !== "always" && (
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-10 shrink-0 text-[8px] text-muted-foreground">그 외</span>
|
|
<Select
|
|
value={btn.showCondition?.unmatchBehavior || "hidden"}
|
|
onValueChange={(v) => updateCondition(bi, { unmatchBehavior: v as "hidden" | "disabled" })}
|
|
>
|
|
<SelectTrigger className="h-6 w-[80px] text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="hidden" className="text-[10px]">숨김</SelectItem>
|
|
<SelectItem value="disabled" className="text-[10px]">비활성화</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-[8px] text-muted-foreground">
|
|
{(btn.showCondition?.unmatchBehavior || "hidden") === "disabled"
|
|
? "보이지만 클릭 불가"
|
|
: "버튼 안 보임"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* === 실행 섹션 (다중 액션) === */}
|
|
<div
|
|
className="flex cursor-pointer items-center gap-1 py-1 hover:bg-muted/30"
|
|
onClick={() => toggleSection(`${bi}-action`)}
|
|
>
|
|
{isSectionOpen(`${bi}-action`)
|
|
? <ChevronDown className="h-2.5 w-2.5 text-muted-foreground" />
|
|
: <ChevronRight className="h-2.5 w-2.5 text-muted-foreground" />}
|
|
<span className="text-[8px] font-medium text-muted-foreground">실행 ({actions.length})</span>
|
|
{!isSectionOpen(`${bi}-action`) && (
|
|
<span className="truncate text-[8px] text-muted-foreground/70">{actionSummary}</span>
|
|
)}
|
|
</div>
|
|
{isSectionOpen(`${bi}-action`) && (
|
|
<div className="space-y-1.5 pl-1">
|
|
{actions.map((action, ai) => {
|
|
const aType = action.type;
|
|
return (
|
|
<div key={ai} className="space-y-1 rounded border bg-muted/20 p-1.5">
|
|
<div className="flex items-center gap-1">
|
|
<span className="shrink-0 text-[8px] font-bold text-muted-foreground">#{ai + 1}</span>
|
|
<Select value={aType} onValueChange={(v) => updateAction(bi, ai, { type: v as ActionButtonClickAction["type"] })}>
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="immediate" className="text-[10px]">즉시 실행</SelectItem>
|
|
<SelectItem value="select-mode" className="text-[10px]">선택 후 실행</SelectItem>
|
|
<SelectItem value="modal-open" className="text-[10px]">모달 열기</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{actions.length > 1 && (
|
|
<Button variant="ghost" size="sm" onClick={() => removeAction(bi, ai)} className="h-5 w-5 shrink-0 p-0">
|
|
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{aType === "immediate" && (
|
|
<ImmediateActionEditor
|
|
action={action}
|
|
allColumnOptions={allColumnOptions}
|
|
availableTableOptions={availableTableOptions}
|
|
onAddUpdate={() => addActionUpdate(bi, ai)}
|
|
onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)}
|
|
onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)}
|
|
onUpdateAction={(p) => updateAction(bi, ai, p)}
|
|
/>
|
|
)}
|
|
|
|
{aType === "select-mode" && (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[8px] font-medium text-muted-foreground">로직 순서</span>
|
|
<Button variant="ghost" size="sm" onClick={() => addSelectModeBtn(bi, ai)} className="h-4 px-1 text-[8px]">
|
|
<Plus className="mr-0.5 h-2.5 w-2.5" />추가
|
|
</Button>
|
|
</div>
|
|
{(action.selectModeButtons || []).map((smBtn, si) => (
|
|
<div key={si} className="space-y-1 rounded border bg-muted/30 p-1.5">
|
|
<div className="flex items-center gap-1">
|
|
<Input value={smBtn.label} onChange={(e) => updateSelectModeBtn(bi, ai, si, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
|
|
<Select value={smBtn.variant || "outline"} onValueChange={(v) => updateSelectModeBtn(bi, ai, si, { variant: v as ButtonVariant })}>
|
|
<SelectTrigger className="h-6 w-14 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default" className="text-[10px]">기본</SelectItem>
|
|
<SelectItem value="outline" className="text-[10px]">외곽</SelectItem>
|
|
<SelectItem value="destructive" className="text-[10px]">위험</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button variant="ghost" size="sm" onClick={() => removeSelectModeBtn(bi, ai, si)} className="h-5 w-5 p-0">
|
|
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-8 shrink-0 text-[8px] text-muted-foreground">동작</span>
|
|
<Select value={smBtn.clickMode} onValueChange={(v) => updateSelectModeBtn(bi, ai, si, { clickMode: v as SelectModeButtonConfig["clickMode"] })}>
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="cancel-select" className="text-[10px]">선택 취소</SelectItem>
|
|
<SelectItem value="status-change" className="text-[10px]">상태 변경</SelectItem>
|
|
<SelectItem value="modal-open" className="text-[10px]">모달 열기</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{smBtn.clickMode === "status-change" && (
|
|
<ImmediateActionEditor
|
|
action={{ type: "immediate", targetTable: smBtn.targetTable, updates: smBtn.updates, confirmMessage: smBtn.confirmMessage }}
|
|
allColumnOptions={allColumnOptions}
|
|
availableTableOptions={availableTableOptions}
|
|
onAddUpdate={() => addSmBtnUpdate(bi, ai, si)}
|
|
onUpdateUpdate={(ui, p) => updateSmBtnUpdate(bi, ai, si, ui, p)}
|
|
onRemoveUpdate={(ui) => removeSmBtnUpdate(bi, ai, si, ui)}
|
|
onUpdateAction={(p) => updateSelectModeBtn(bi, ai, si, { targetTable: p.targetTable ?? smBtn.targetTable, confirmMessage: p.confirmMessage ?? smBtn.confirmMessage })}
|
|
/>
|
|
)}
|
|
{smBtn.clickMode === "modal-open" && (
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-10 shrink-0 text-[8px] text-muted-foreground">POP 화면</span>
|
|
<Input
|
|
value={smBtn.modalScreenId || ""}
|
|
onChange={(e) => updateSelectModeBtn(bi, ai, si, { modalScreenId: e.target.value })}
|
|
placeholder="화면 ID (예: 4481)"
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{(!action.selectModeButtons || action.selectModeButtons.length === 0) && (
|
|
<p className="text-[8px] text-muted-foreground">로직 순서를 추가하세요.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{aType === "modal-open" && (
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-10 shrink-0 text-[8px] text-muted-foreground">POP 화면</span>
|
|
<Input
|
|
value={action.modalScreenId || ""}
|
|
onChange={(e) => updateAction(bi, ai, { modalScreenId: e.target.value })}
|
|
placeholder="화면 ID (예: 4481)"
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<Button variant="ghost" size="sm" onClick={() => addAction(bi)} className="h-5 w-full text-[8px]">
|
|
<Plus className="mr-0.5 h-2.5 w-2.5" />액션 추가
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const SYSTEM_COLUMNS = new Set([
|
|
"id", "company_code", "created_date", "updated_date", "writer",
|
|
]);
|
|
|
|
function ImmediateActionEditor({
|
|
action,
|
|
allColumnOptions,
|
|
availableTableOptions,
|
|
onAddUpdate,
|
|
onUpdateUpdate,
|
|
onRemoveUpdate,
|
|
onUpdateAction,
|
|
}: {
|
|
action: ActionButtonClickAction;
|
|
allColumnOptions: { value: string; label: string }[];
|
|
availableTableOptions: { value: string; label: string }[];
|
|
onAddUpdate: () => void;
|
|
onUpdateUpdate: (uIdx: number, partial: Partial<ActionButtonUpdate>) => void;
|
|
onRemoveUpdate: (uIdx: number) => void;
|
|
onUpdateAction: (partial: Partial<ActionButtonClickAction>) => void;
|
|
}) {
|
|
const isExternalTable = action.targetTable && !availableTableOptions.some((t) => t.value === action.targetTable);
|
|
const [dbSelectMode, setDbSelectMode] = useState(!!isExternalTable);
|
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
|
|
|
const [tableColumnGroups, setTableColumnGroups] = useState<
|
|
{ table: string; label: string; business: { value: string; label: string }[]; system: { value: string; label: string }[] }[]
|
|
>([]);
|
|
|
|
// 외부 DB 모드 시 전체 테이블 로드
|
|
useEffect(() => {
|
|
if (dbSelectMode && allTables.length === 0) {
|
|
fetchTableList().then(setAllTables).catch(() => setAllTables([]));
|
|
}
|
|
}, [dbSelectMode, allTables.length]);
|
|
|
|
// 선택된 테이블 컬럼 로드 (카드 소스 + 외부 공통)
|
|
const effectiveTableOptions = useMemo(() => {
|
|
if (dbSelectMode && action.targetTable) {
|
|
const existing = availableTableOptions.find((t) => t.value === action.targetTable);
|
|
if (!existing) return [...availableTableOptions, { value: action.targetTable, label: `${action.targetTable} (외부)` }];
|
|
}
|
|
return availableTableOptions;
|
|
}, [availableTableOptions, dbSelectMode, action.targetTable]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const loadAll = async () => {
|
|
const groups: typeof tableColumnGroups = [];
|
|
for (const t of effectiveTableOptions) {
|
|
try {
|
|
const cols = await fetchTableColumns(t.value);
|
|
const mapped = cols.map((c) => ({ value: c.name, label: c.name }));
|
|
groups.push({
|
|
table: t.value,
|
|
label: t.label,
|
|
business: mapped.filter((c) => !SYSTEM_COLUMNS.has(c.value)),
|
|
system: mapped.filter((c) => SYSTEM_COLUMNS.has(c.value)),
|
|
});
|
|
} catch {
|
|
groups.push({ table: t.value, label: t.label, business: [], system: [] });
|
|
}
|
|
}
|
|
if (!cancelled) setTableColumnGroups(groups);
|
|
};
|
|
if (effectiveTableOptions.length > 0) loadAll();
|
|
else setTableColumnGroups([]);
|
|
return () => { cancelled = true; };
|
|
}, [effectiveTableOptions]);
|
|
|
|
const selectedGroup = tableColumnGroups.find((g) => g.table === action.targetTable);
|
|
const businessCols = selectedGroup?.business || [];
|
|
const systemCols = selectedGroup?.system || [];
|
|
const tableName = action.targetTable?.trim() || "";
|
|
|
|
// 메인 테이블 컬럼 (조인키 소스 컬럼 선택 용도)
|
|
const mainTableGroup = tableColumnGroups.find((g) => availableTableOptions[0]?.value === g.table);
|
|
const mainCols = mainTableGroup ? [...mainTableGroup.business, ...mainTableGroup.system] : [];
|
|
|
|
return (
|
|
<div className="space-y-1 pl-0.5">
|
|
{/* 대상 테이블 */}
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">대상 테이블</span>
|
|
{!dbSelectMode ? (
|
|
<Select
|
|
value={action.targetTable || "__none__"}
|
|
onValueChange={(v) => {
|
|
if (v === "__db_select__") {
|
|
setDbSelectMode(true);
|
|
onUpdateAction({ targetTable: "" });
|
|
} else {
|
|
onUpdateAction({ targetTable: v === "__none__" ? "" : v, joinConfig: undefined });
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="테이블 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
|
{availableTableOptions.map((t) => (
|
|
<SelectItem key={t.value} value={t.value} className="text-[10px]">{t.label}</SelectItem>
|
|
))}
|
|
<SelectItem value="__db_select__" className="text-[10px] font-medium text-primary">DB에서 직접 선택...</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<div className="flex flex-1 items-center gap-1">
|
|
<DbTableCombobox
|
|
value={action.targetTable || ""}
|
|
tables={allTables}
|
|
onSelect={(v) => onUpdateAction({ targetTable: v })}
|
|
/>
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
onClick={() => { setDbSelectMode(false); onUpdateAction({ targetTable: "", joinConfig: undefined }); }}
|
|
className="h-5 shrink-0 px-1 text-[8px]"
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 외부 DB 선택 시 조인키 설정 */}
|
|
{dbSelectMode && action.targetTable && (
|
|
<>
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">기준 컬럼</span>
|
|
<Select
|
|
value={action.joinConfig?.sourceColumn || "__none__"}
|
|
onValueChange={(v) => onUpdateAction({ joinConfig: { ...action.joinConfig || { sourceColumn: "", targetColumn: "" }, sourceColumn: v === "__none__" ? "" : v } })}
|
|
>
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="메인 테이블 컬럼" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
|
{mainCols.map((c) => (
|
|
<SelectItem key={c.value} value={c.value} className="text-[10px]">{c.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">매칭 컬럼</span>
|
|
<Select
|
|
value={action.joinConfig?.targetColumn || "__none__"}
|
|
onValueChange={(v) => onUpdateAction({ joinConfig: { ...action.joinConfig || { sourceColumn: "", targetColumn: "" }, targetColumn: v === "__none__" ? "" : v } })}
|
|
>
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="외부 테이블 컬럼" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
|
{[...businessCols, ...systemCols].map((c) => (
|
|
<SelectItem key={c.value} value={c.value} className="text-[10px]">{c.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<p className="text-[7px] text-muted-foreground/70 pl-0.5">
|
|
메인.기준컬럼 = 외부.매칭컬럼 으로 연결하여 업데이트
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">확인 메시지</span>
|
|
<Input
|
|
value={action.confirmMessage || ""}
|
|
onChange={(e) => onUpdateAction({ confirmMessage: e.target.value })}
|
|
placeholder="처리하시겠습니까?"
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[8px] font-medium text-muted-foreground">
|
|
변경할 컬럼{tableName ? ` (${tableName})` : ""}
|
|
</span>
|
|
<Button variant="ghost" size="sm" onClick={onAddUpdate} className="h-4 px-1 text-[8px]">
|
|
<Plus className="mr-0.5 h-2.5 w-2.5" />추가
|
|
</Button>
|
|
</div>
|
|
{(action.updates || []).map((u, ui) => (
|
|
<div key={ui} className="flex items-center gap-1">
|
|
<Select value={u.column || "__none__"} onValueChange={(v) => onUpdateUpdate(ui, { column: v === "__none__" ? "" : v })}>
|
|
<SelectTrigger className="h-6 w-24 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
|
{businessCols.length > 0 && (
|
|
<SelectGroup>
|
|
<SelectLabel className="text-[8px] text-muted-foreground">{tableName}</SelectLabel>
|
|
{businessCols.map((o) => (
|
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
)}
|
|
{systemCols.length > 0 && (
|
|
<SelectGroup>
|
|
<SelectLabel className="text-[8px] text-muted-foreground">{tableName} - 시스템</SelectLabel>
|
|
{systemCols.map((o) => (
|
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={u.valueType} onValueChange={(v) => onUpdateUpdate(ui, { valueType: v as ActionButtonUpdate["valueType"] })}>
|
|
<SelectTrigger className="h-6 w-20 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="static" className="text-[10px]">직접입력</SelectItem>
|
|
<SelectItem value="currentUser" className="text-[10px]">현재 사용자</SelectItem>
|
|
<SelectItem value="currentTime" className="text-[10px]">현재 시간</SelectItem>
|
|
<SelectItem value="columnRef" className="text-[10px]">컬럼 참조</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{(u.valueType === "static" || u.valueType === "columnRef") && (
|
|
<Input
|
|
value={u.value || ""}
|
|
onChange={(e) => onUpdateUpdate(ui, { value: e.target.value })}
|
|
placeholder={u.valueType === "static" ? "값" : "컬럼명"}
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => onRemoveUpdate(ui)} className="h-5 w-5 shrink-0 p-0">
|
|
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
{(!action.updates || action.updates.length === 0) && (
|
|
<p className="text-[8px] text-muted-foreground">변경 항목을 추가하면 클릭 시 DB가 변경됩니다.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ===== DB 테이블 검색 Combobox =====
|
|
|
|
function DbTableCombobox({
|
|
value,
|
|
tables,
|
|
onSelect,
|
|
}: {
|
|
value: string;
|
|
tables: TableInfo[];
|
|
onSelect: (tableName: string) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!search) return tables;
|
|
const q = search.toLowerCase();
|
|
return tables.filter(
|
|
(t) =>
|
|
t.tableName.toLowerCase().includes(q) ||
|
|
(t.tableComment || "").toLowerCase().includes(q),
|
|
);
|
|
}, [tables, search]);
|
|
|
|
const selectedLabel = useMemo(() => {
|
|
if (!value) return "DB 테이블 검색...";
|
|
const found = tables.find((t) => t.tableName === value);
|
|
return found ? `${found.tableName}${found.tableComment ? ` (${found.tableComment})` : ""}` : value;
|
|
}, [value, tables]);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="h-6 flex-1 justify-between text-[10px] font-normal"
|
|
>
|
|
<span className="truncate">{selectedLabel}</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)", minWidth: 240 }}
|
|
align="start"
|
|
>
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
placeholder="테이블명 또는 설명 검색..."
|
|
className="text-[10px]"
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
/>
|
|
<CommandList className="max-h-[200px]">
|
|
<CommandEmpty className="py-2 text-center text-[10px] text-muted-foreground">
|
|
검색 결과가 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{filtered.map((t) => (
|
|
<CommandItem
|
|
key={t.tableName}
|
|
value={t.tableName}
|
|
onSelect={() => {
|
|
onSelect(t.tableName);
|
|
setOpen(false);
|
|
setSearch("");
|
|
}}
|
|
className="text-[10px]"
|
|
>
|
|
<Check className={cn("mr-1.5 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
|
|
<span className="font-medium">{t.tableName}</span>
|
|
{t.tableComment && (
|
|
<span className="ml-1 text-muted-foreground">({t.tableComment})</span>
|
|
)}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// ===== 하단 상태 에디터 =====
|
|
|
|
function FooterStatusEditor({
|
|
cell,
|
|
allColumnOptions,
|
|
onUpdate,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
allColumnOptions: { value: string; label: string }[];
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
}) {
|
|
const footerStatusMap = cell.footerStatusMap || [];
|
|
|
|
const addMapping = () => {
|
|
onUpdate({ footerStatusMap: [...footerStatusMap, { value: "", label: "", color: "#6b7280" }] });
|
|
};
|
|
|
|
const updateMapping = (index: number, partial: Partial<{ value: string; label: string; color: string }>) => {
|
|
onUpdate({ footerStatusMap: footerStatusMap.map((m, i) => (i === index ? { ...m, ...partial } : m)) });
|
|
};
|
|
|
|
const removeMapping = (index: number) => {
|
|
onUpdate({ footerStatusMap: footerStatusMap.filter((_, i) => i !== index) });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<span className="text-[9px] font-medium text-muted-foreground">하단 상태 설정</span>
|
|
<div className="flex gap-1">
|
|
<Input
|
|
value={cell.footerLabel || ""}
|
|
onChange={(e) => onUpdate({ footerLabel: e.target.value })}
|
|
placeholder="라벨 (예: 검사의뢰)"
|
|
className="h-7 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Select value={cell.footerStatusColumn || "__none__"} onValueChange={(v) => onUpdate({ footerStatusColumn: v === "__none__" ? undefined : v })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="상태 컬럼" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
|
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Label className="text-[9px] text-muted-foreground">상단 구분선</Label>
|
|
<Switch
|
|
checked={cell.showTopBorder !== false}
|
|
onCheckedChange={(v) => onUpdate({ showTopBorder: v })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[9px] font-medium text-muted-foreground">상태값-색상 매핑</span>
|
|
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
|
|
<Plus className="mr-0.5 h-3 w-3" />추가
|
|
</Button>
|
|
</div>
|
|
{footerStatusMap.map((m, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<Input value={m.value} onChange={(e) => updateMapping(i, { value: e.target.value })} placeholder="값" className="h-6 flex-1 text-[10px]" />
|
|
<Input value={m.label} onChange={(e) => updateMapping(i, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
|
|
<input type="color" value={m.color} onChange={(e) => updateMapping(i, { color: e.target.value })} className="h-6 w-8 cursor-pointer rounded border" />
|
|
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 필드 설정 에디터 =====
|
|
|
|
function FieldConfigEditor({
|
|
cell,
|
|
allColumnOptions,
|
|
onUpdate,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
allColumnOptions: { value: string; label: string }[];
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
}) {
|
|
const valueType = cell.valueType || "column";
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<span className="text-[9px] font-medium text-muted-foreground">필드 설정</span>
|
|
<div className="flex gap-1">
|
|
<Select value={valueType} onValueChange={(v) => onUpdate({ valueType: v as "column" | "formula" })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="column" className="text-[10px]">컬럼 값</SelectItem>
|
|
<SelectItem value="formula" className="text-[10px]">수식</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Input value={cell.unit || ""} onChange={(e) => onUpdate({ unit: e.target.value })} placeholder="단위" className="h-7 w-16 text-[10px]" />
|
|
</div>
|
|
{valueType === "formula" && (
|
|
<div className="flex gap-1">
|
|
<Select value={cell.formulaLeft || ""} onValueChange={(v) => onUpdate({ formulaLeft: v })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="좌항" /></SelectTrigger>
|
|
<SelectContent>
|
|
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={cell.formulaOperator || "+"} onValueChange={(v) => onUpdate({ formulaOperator: v as "+" | "-" | "*" | "/" })}>
|
|
<SelectTrigger className="h-7 w-12 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{["+", "-", "*", "/"].map((op) => <SelectItem key={op} value={op} className="text-[10px]">{op}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={cell.formulaRightType || "input"} onValueChange={(v) => onUpdate({ formulaRightType: v as "input" | "column" })}>
|
|
<SelectTrigger className="h-7 w-16 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="input" className="text-[10px]">입력값</SelectItem>
|
|
<SelectItem value="column" className="text-[10px]">컬럼</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{cell.formulaRightType === "column" && (
|
|
<Select value={cell.formulaRight || ""} onValueChange={(v) => onUpdate({ formulaRight: v })}>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="우항" /></SelectTrigger>
|
|
<SelectContent>
|
|
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 탭 3: 동작 =====
|
|
|
|
function TabActions({
|
|
cfg,
|
|
onUpdate,
|
|
}: {
|
|
cfg: PopCardListV2Config;
|
|
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
|
|
}) {
|
|
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
|
|
const clickAction = cfg.cardClickAction || "none";
|
|
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 카드 선택 시 */}
|
|
<div>
|
|
<Label className="text-xs">카드 선택 시 동작</Label>
|
|
<div className="mt-1 space-y-1">
|
|
{(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => (
|
|
<label key={action} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
|
|
<input
|
|
type="radio"
|
|
name="cardClickAction"
|
|
checked={clickAction === action}
|
|
onChange={() => onUpdate({ cardClickAction: action })}
|
|
className="h-3 w-3"
|
|
/>
|
|
<span className="text-xs">
|
|
{action === "none" && "없음"}
|
|
{action === "publish" && "상세 데이터 전달 (다른 컴포넌트 연결)"}
|
|
{action === "navigate" && "화면 이동"}
|
|
{action === "modal-open" && "모달 열기"}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
{clickAction === "modal-open" && (
|
|
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">POP 화면 ID</span>
|
|
<Input
|
|
value={modalConfig.screenId || ""}
|
|
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
|
|
placeholder="화면 ID (예: 4481)"
|
|
className="h-7 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">모달 제목</span>
|
|
<Input
|
|
value={modalConfig.modalTitle || ""}
|
|
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, modalTitle: e.target.value } })}
|
|
placeholder="비우면 '상세 작업' 표시"
|
|
className="h-7 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">조건</span>
|
|
<Select
|
|
value={modalConfig.condition?.type || "always"}
|
|
onValueChange={(v) => {
|
|
const cond = v === "always" ? undefined : { type: v as "timeline-status" | "column-value", value: "", column: "" };
|
|
onUpdate({ cardClickModalConfig: { ...modalConfig, condition: cond } });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="always" className="text-[10px]">항상</SelectItem>
|
|
<SelectItem value="timeline-status" className="text-[10px]">타임라인 상태 일치 시</SelectItem>
|
|
<SelectItem value="column-value" className="text-[10px]">컬럼 값 일치 시</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{modalConfig.condition?.type === "timeline-status" && (
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">상태 값</span>
|
|
<Input
|
|
value={modalConfig.condition?.value || ""}
|
|
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })}
|
|
placeholder="예: in_progress"
|
|
className="h-7 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
{modalConfig.condition?.type === "column-value" && (
|
|
<>
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">컬럼</span>
|
|
<Input
|
|
value={modalConfig.condition?.column || ""}
|
|
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, column: e.target.value } } })}
|
|
placeholder="컬럼명"
|
|
className="h-7 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">값</span>
|
|
<Input
|
|
value={modalConfig.condition?.value || ""}
|
|
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, condition: { ...modalConfig.condition!, value: e.target.value } } })}
|
|
placeholder="값"
|
|
className="h-7 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 필터 전 비표시 */}
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">필터 전 데이터 숨김</Label>
|
|
<Switch
|
|
checked={!!cfg.hideUntilFiltered}
|
|
onCheckedChange={(checked) => onUpdate({ hideUntilFiltered: checked })}
|
|
/>
|
|
</div>
|
|
{cfg.hideUntilFiltered && (
|
|
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
|
|
연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다.
|
|
</p>
|
|
)}
|
|
|
|
{/* 스크롤 방향 */}
|
|
<div>
|
|
<Label className="text-xs">스크롤 방향</Label>
|
|
<div className="mt-1 flex gap-1">
|
|
{(["vertical", "horizontal"] as const).map((dir) => (
|
|
<button
|
|
key={dir}
|
|
type="button"
|
|
onClick={() => onUpdate({ scrollDirection: dir })}
|
|
className={cn(
|
|
"flex-1 rounded border py-1 text-xs transition-colors",
|
|
(cfg.scrollDirection || "vertical") === dir
|
|
? "border-primary bg-primary/10 text-primary"
|
|
: "border-border hover:bg-muted"
|
|
)}
|
|
>
|
|
{dir === "vertical" ? "세로" : "가로"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오버플로우 */}
|
|
<div>
|
|
<Label className="text-xs">오버플로우</Label>
|
|
<div className="mt-1 flex gap-1">
|
|
{(["loadMore", "pagination"] as const).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
onClick={() => onUpdate({ overflow: { ...overflow, mode } })}
|
|
className={cn(
|
|
"flex-1 rounded border py-1 text-xs transition-colors",
|
|
overflow.mode === mode
|
|
? "border-primary bg-primary/10 text-primary"
|
|
: "border-border hover:bg-muted"
|
|
)}
|
|
>
|
|
{mode === "loadMore" ? "더보기" : "페이지네이션"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-2 space-y-2">
|
|
<div>
|
|
<Label className="text-[10px]">기본 표시 수</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={overflow.visibleCount}
|
|
onChange={(e) => onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })}
|
|
className="mt-0.5 h-7 text-[10px]"
|
|
/>
|
|
</div>
|
|
{overflow.mode === "loadMore" && (
|
|
<div>
|
|
<Label className="text-[10px]">더보기 추가 수</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={overflow.loadMoreCount ?? 6}
|
|
onChange={(e) => onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })}
|
|
className="mt-0.5 h-7 text-[10px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
{overflow.mode === "pagination" && (
|
|
<div>
|
|
<Label className="text-[10px]">페이지당 수</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={overflow.pageSize ?? overflow.visibleCount}
|
|
onChange={(e) => onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })}
|
|
className="mt-0.5 h-7 text-[10px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 장바구니 */}
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">장바구니(카트) 사용</Label>
|
|
<Switch
|
|
checked={!!cfg.cartAction}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } });
|
|
} else {
|
|
onUpdate({ cartAction: undefined });
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|