2195 lines
92 KiB
TypeScript
2195 lines
92 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* pop-card-list-v2 설정 패널 (3탭)
|
|
*
|
|
* 탭 1: 데이터 — 테이블/컬럼 선택, 조인, 정렬
|
|
* 탭 2: 카드 디자인 — 열 수, 시각적 그리드 디자이너, 셀 클릭 시 타입별 상세 인라인
|
|
* 탭 3: 동작 — 카드 선택 동작, 오버플로우, 카트
|
|
*/
|
|
|
|
import { useState, useEffect, useRef, useCallback, 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,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Check, ChevronsUpDown, Plus, Minus, Trash2 } 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,
|
|
ActionButtonUpdate,
|
|
TimelineDataSource,
|
|
StatusValueMapping,
|
|
TimelineStatusSemantic,
|
|
} 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}
|
|
allColumnOptions={allColumnOptions}
|
|
columns={columns}
|
|
selectedColumns={selectedColumns}
|
|
tables={tables}
|
|
onUpdate={(partial) => updateCell(selectedCell.id, partial)}
|
|
onRemove={() => removeCell(selectedCell.id)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 셀 상세 에디터 (타입별 인라인) =====
|
|
|
|
function CellDetailEditor({
|
|
cell,
|
|
allColumnOptions,
|
|
columns,
|
|
selectedColumns,
|
|
tables,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
allColumnOptions: { value: string; label: string }[];
|
|
columns: ColumnInfo[];
|
|
selectedColumns: string[];
|
|
tables: TableInfo[];
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
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">
|
|
<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="h-7 w-24 text-[10px]"><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} onUpdate={onUpdate} />}
|
|
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
|
|
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allColumnOptions={allColumnOptions} 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>
|
|
);
|
|
}
|
|
|
|
// ===== 상태 배지 매핑 에디터 =====
|
|
|
|
function StatusMappingEditor({
|
|
cell,
|
|
onUpdate,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
}) {
|
|
const statusMap = cell.statusMap || [];
|
|
|
|
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>
|
|
<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>
|
|
{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>
|
|
<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,
|
|
allColumnOptions,
|
|
onUpdate,
|
|
}: {
|
|
cell: CardCellDefinitionV2;
|
|
allColumnOptions: { value: string; label: string }[];
|
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
|
}) {
|
|
const rules = cell.actionRules || [];
|
|
const [expandedBtn, setExpandedBtn] = useState<string | null>(null);
|
|
|
|
const cloneRules = () => rules.map((r) => ({ ...r, buttons: r.buttons.map((b) => ({ ...b })) }));
|
|
|
|
const addRule = () => {
|
|
onUpdate({
|
|
actionRules: [
|
|
...rules,
|
|
{ whenStatus: "", buttons: [{ label: "", variant: "default" as ButtonVariant, taskPreset: "" }] },
|
|
],
|
|
});
|
|
};
|
|
|
|
const updateRule = (index: number, partial: Partial<{ whenStatus: string }>) => {
|
|
onUpdate({ actionRules: rules.map((r, i) => (i === index ? { ...r, ...partial } : r)) });
|
|
};
|
|
|
|
const removeRule = (index: number) => {
|
|
onUpdate({ actionRules: rules.filter((_, i) => i !== index) });
|
|
setExpandedBtn(null);
|
|
};
|
|
|
|
const addButton = (ruleIndex: number) => {
|
|
const nr = cloneRules();
|
|
nr[ruleIndex].buttons.push({ label: "", variant: "default" as ButtonVariant, taskPreset: "" });
|
|
onUpdate({ actionRules: nr });
|
|
};
|
|
|
|
const updateButton = (ruleIndex: number, btnIndex: number, partial: Record<string, unknown>) => {
|
|
const nr = cloneRules();
|
|
nr[ruleIndex].buttons[btnIndex] = { ...nr[ruleIndex].buttons[btnIndex], ...partial };
|
|
onUpdate({ actionRules: nr });
|
|
};
|
|
|
|
const removeButton = (ruleIndex: number, btnIndex: number) => {
|
|
const nr = cloneRules();
|
|
nr[ruleIndex].buttons = nr[ruleIndex].buttons.filter((_, i) => i !== btnIndex);
|
|
onUpdate({ actionRules: nr });
|
|
setExpandedBtn(null);
|
|
};
|
|
|
|
// updates 배열 관리
|
|
const addUpdate = (ri: number, bi: number) => {
|
|
const nr = cloneRules();
|
|
const btn = nr[ri].buttons[bi];
|
|
btn.updates = [...(btn.updates || []), { column: "", value: "", valueType: "static" as const }];
|
|
onUpdate({ actionRules: nr });
|
|
};
|
|
|
|
const updateUpdateEntry = (ri: number, bi: number, ui: number, partial: Partial<ActionButtonUpdate>) => {
|
|
const nr = cloneRules();
|
|
const btn = nr[ri].buttons[bi];
|
|
btn.updates = (btn.updates || []).map((u, i) => (i === ui ? { ...u, ...partial } : u));
|
|
onUpdate({ actionRules: nr });
|
|
};
|
|
|
|
const removeUpdate = (ri: number, bi: number, ui: number) => {
|
|
const nr = cloneRules();
|
|
const btn = nr[ri].buttons[bi];
|
|
btn.updates = (btn.updates || []).filter((_, i) => i !== ui);
|
|
onUpdate({ actionRules: nr });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[9px] font-medium text-muted-foreground">상태별 버튼 규칙</span>
|
|
<Button variant="ghost" size="sm" onClick={addRule} className="h-5 px-1.5 text-[9px]">
|
|
<Plus className="mr-0.5 h-3 w-3" />규칙 추가
|
|
</Button>
|
|
</div>
|
|
{rules.map((rule, ri) => (
|
|
<div key={ri} className="space-y-1 rounded border p-1.5">
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[8px] text-muted-foreground">조건:</span>
|
|
<Input value={rule.whenStatus} onChange={(e) => updateRule(ri, { whenStatus: e.target.value })} placeholder="상태값 (예: waiting)" className="h-6 flex-1 text-[10px]" />
|
|
<Button variant="ghost" size="sm" onClick={() => removeRule(ri)} className="h-5 w-5 p-0">
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
|
|
{rule.buttons.map((btn, bi) => {
|
|
const btnKey = `${ri}-${bi}`;
|
|
const isExpanded = expandedBtn === btnKey;
|
|
|
|
return (
|
|
<div key={bi} className="space-y-1 pl-2">
|
|
<div className="flex items-center gap-1">
|
|
<Input value={btn.label} onChange={(e) => updateButton(ri, bi, { label: e.target.value })} placeholder="라벨" className="h-6 flex-1 text-[10px]" />
|
|
<Select value={btn.variant || "default"} onValueChange={(v) => updateButton(ri, bi, { variant: v })}>
|
|
<SelectTrigger className="h-6 w-20 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>
|
|
<SelectItem value="secondary" className="text-[10px]">보조</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="ghost" size="sm"
|
|
onClick={() => setExpandedBtn(isExpanded ? null : btnKey)}
|
|
className={cn("h-5 w-5 p-0", isExpanded && "bg-primary/10")}
|
|
>
|
|
<ChevronsUpDown className="h-3 w-3" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => removeButton(ri, bi)} className="h-5 w-5 p-0">
|
|
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
|
|
</Button>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="ml-1 space-y-1 rounded border border-dashed p-1.5">
|
|
<span className="text-[8px] font-medium text-muted-foreground">클릭 시 동작</span>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">대상 테이블</span>
|
|
<Input
|
|
value={btn.targetTable || ""}
|
|
onChange={(e) => updateButton(ri, bi, { targetTable: e.target.value })}
|
|
placeholder="work_order_process"
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">확인 메시지</span>
|
|
<Input
|
|
value={btn.confirmMessage || ""}
|
|
onChange={(e) => updateButton(ri, bi, { 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">변경할 컬럼</span>
|
|
<Button variant="ghost" size="sm" onClick={() => addUpdate(ri, bi)} className="h-4 px-1 text-[8px]">
|
|
<Plus className="mr-0.5 h-2.5 w-2.5" />추가
|
|
</Button>
|
|
</div>
|
|
|
|
{(btn.updates || []).map((u, ui) => (
|
|
<div key={ui} className="flex items-center gap-1">
|
|
<Select value={u.column || "__none__"} onValueChange={(v) => updateUpdateEntry(ri, bi, ui, { 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>
|
|
<Select value={u.valueType} onValueChange={(v) => updateUpdateEntry(ri, bi, 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) => updateUpdateEntry(ri, bi, ui, { value: e.target.value })}
|
|
placeholder={u.valueType === "static" ? "값" : "컬럼명"}
|
|
className="h-6 flex-1 text-[10px]"
|
|
/>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => removeUpdate(ri, bi, ui)} className="h-5 w-5 shrink-0 p-0">
|
|
<Trash2 className="h-2.5 w-2.5 text-muted-foreground" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
{(!btn.updates || btn.updates.length === 0) && (
|
|
<p className="text-[8px] text-muted-foreground">변경 항목을 추가하면 버튼 클릭 시 DB가 변경됩니다.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => addButton(ri)} className="ml-2 h-5 text-[8px]">
|
|
<Plus className="mr-0.5 h-2.5 w-2.5" />버튼 추가
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 하단 상태 에디터 =====
|
|
|
|
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";
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 카드 선택 시 */}
|
|
<div>
|
|
<Label className="text-xs">카드 선택 시 동작</Label>
|
|
<div className="mt-1 space-y-1">
|
|
{(["none", "publish", "navigate"] 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" && "화면 이동"}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 스크롤 방향 */}
|
|
<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>
|
|
);
|
|
}
|