ERP-node/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx

2716 lines
93 KiB
TypeScript
Raw Normal View History

"use client";
/**
* pop-string-list (Stepper/Wizard )
*
* 6 :
* 1) (/)
* 2)
* 3)
* 4) ( + )
* 5) ()
* 6-A) ( )
* 6-B) ( )
*/
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, ChevronLeft, ChevronRight, 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 {
PopStringListConfig,
StringListDisplayMode,
ListColumnConfig,
CardGridConfig,
CardCellDefinition,
} from "./types";
import type { CardListDataSource, CardColumnJoin } from "../types";
import {
fetchTableList,
fetchTableColumns,
type TableInfo,
type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher";
// ===== Props =====
interface ConfigPanelProps {
config: PopStringListConfig | undefined;
onUpdate: (config: PopStringListConfig) => void;
}
// ===== 기본 설정값 =====
const DEFAULT_CONFIG: PopStringListConfig = {
displayMode: "list",
header: { enabled: true, label: "" },
overflow: { visibleRows: 5, showExpandButton: true, maxExpandRows: 20 },
dataSource: { tableName: "" },
listColumns: [],
cardGrid: undefined,
};
// Stepper 단계 정의
const STEP_LABELS = [
"모드 선택",
"헤더 설정",
"오버플로우",
"데이터 선택",
"조인 설정",
"레이아웃",
] as const;
const TOTAL_STEPS = STEP_LABELS.length;
// ===== 메인 컴포넌트 =====
export function PopStringListConfigPanel({ config, onUpdate }: ConfigPanelProps) {
const [step, setStep] = useState(0);
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
// 설정값 (undefined 대비 기본값 병합)
const cfg: PopStringListConfig = {
...DEFAULT_CONFIG,
...config,
header: { ...DEFAULT_CONFIG.header, ...config?.header },
overflow: { ...DEFAULT_CONFIG.overflow, ...config?.overflow },
dataSource: { ...DEFAULT_CONFIG.dataSource, ...config?.dataSource },
};
// 설정 업데이트 헬퍼
const update = (partial: Partial<PopStringListConfig>) => {
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]);
// 선택된 컬럼 복원 (config에 저장된 값 우선)
useEffect(() => {
if (cfg.selectedColumns && cfg.selectedColumns.length > 0) {
setSelectedColumns(cfg.selectedColumns);
} else if (cfg.displayMode === "list" && cfg.listColumns) {
setSelectedColumns(cfg.listColumns.map((c) => c.columnName));
} else if (cfg.displayMode === "card" && cfg.cardGrid) {
setSelectedColumns((cfg.cardGrid.cells || []).map((c) => c.columnName));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cfg.dataSource.tableName]); // 테이블 변경 시에만 복원
// 다음/이전 단계
const canGoNext = (): boolean => {
switch (step) {
case 0: return true; // 모드 선택 (기본값 있음)
case 1: return true; // 헤더 (선택사항)
case 2: return true; // 오버플로우 (기본값 있음)
case 3: return !!cfg.dataSource.tableName && selectedColumns.length > 0; // 테이블 + 컬럼
case 4: return true; // 조인 (선택사항)
case 5: return true; // 레이아웃
default: return false;
}
};
const goNext = () => {
if (step < TOTAL_STEPS - 1 && canGoNext()) setStep(step + 1);
};
const goPrev = () => {
if (step > 0) setStep(step - 1);
};
return (
<div className="flex flex-col gap-3">
{/* Stepper 인디케이터 */}
<div className="flex items-center gap-1">
{STEP_LABELS.map((label, i) => (
<button
key={i}
onClick={() => setStep(i)}
className={`flex-1 cursor-pointer rounded-sm py-0.5 text-center text-[10px] transition-colors ${
i === step
? "bg-primary text-primary-foreground font-medium"
: i < step
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
title={label}
>
{i + 1}
</button>
))}
</div>
<div className="text-xs font-medium">{STEP_LABELS[step]}</div>
{/* 단계별 컨텐츠 */}
<div className="min-h-[120px]">
{step === 0 && (
<StepModeSelect
displayMode={cfg.displayMode}
onChange={(displayMode) => update({ displayMode })}
/>
)}
{step === 1 && (
<StepHeader
header={cfg.header}
onChange={(header) => update({ header })}
/>
)}
{step === 2 && (
<StepOverflow
overflow={cfg.overflow}
onChange={(overflow) => update({ overflow })}
/>
)}
{step === 3 && (
<StepDataSelect
tables={tables}
tableName={cfg.dataSource.tableName}
onTableChange={(tableName) => {
setSelectedColumns([]);
update({
dataSource: { ...cfg.dataSource, tableName },
selectedColumns: [],
listColumns: [],
cardGrid: undefined,
});
}}
columns={columns}
selectedColumns={selectedColumns}
onColumnsChange={(cols) => {
setSelectedColumns(cols);
if (cfg.displayMode === "list") {
const currentList = cfg.listColumns || [];
// 기존 리스트에서: 체크 해제된 메인 컬럼만 제거
// 조인 컬럼 (이름에 "."이 포함)은 항상 보존
const preserved = currentList.filter(
(lc) => cols.includes(lc.columnName) || lc.columnName.includes(".")
);
// 새로 체크된 메인 컬럼만 리스트 끝에 추가
const existingNames = new Set(preserved.map((lc) => lc.columnName));
const added = cols
.filter((colName) => !existingNames.has(colName))
.map((colName) => ({ columnName: colName, label: colName } as ListColumnConfig));
update({ selectedColumns: cols, listColumns: [...preserved, ...added] });
} else {
update({ selectedColumns: cols });
}
}}
/>
)}
{step === 4 && (
<StepJoinConfig
dataSource={cfg.dataSource}
tables={tables}
mainColumns={columns}
onChange={(dataSource) => {
// 조인 변경 후: 유효한 조인 컬럼명 셋 계산
const validJoinColNames = new Set(
(dataSource.joins || []).flatMap((j) =>
(j.selectedTargetColumns || []).map((col) => `${j.targetTable}.${col}`)
)
);
// listColumns에서 고아 조인 컬럼 제거 + alternateColumns 정리
const currentList = cfg.listColumns || [];
const cleanedList = currentList
.filter((lc) => {
if (!lc.columnName.includes(".")) return true; // 메인 컬럼: 유지
return validJoinColNames.has(lc.columnName); // 조인 컬럼: 유효한 것만
})
.map((lc) => {
const alts = lc.alternateColumns;
if (!alts) return lc;
const cleanedAlts = alts.filter((a) => {
if (!a.includes(".")) return true; // 메인 컬럼: 유지
return validJoinColNames.has(a); // 조인 컬럼: 유효한 것만
});
return {
...lc,
alternateColumns: cleanedAlts.length > 0 ? cleanedAlts : undefined,
};
});
update({ dataSource, listColumns: cleanedList });
}}
/>
)}
{step === 5 &&
(cfg.displayMode === "list" ? (
<StepListLayout
listColumns={cfg.listColumns || []}
availableColumns={columns.filter((c) =>
selectedColumns.includes(c.name)
)}
joinedColumns={
// 조인에서 선택된 대상 컬럼들을 {테이블명.컬럼명} 형태로 수집
(cfg.dataSource.joins || []).flatMap((j) =>
(j.selectedTargetColumns || []).map((col) => ({
name: `${j.targetTable}.${col}`,
displayName: col,
sourceTable: j.targetTable,
}))
)
}
onChange={(listColumns) => update({ listColumns })}
/>
) : (
<StepCardDesigner
cardGrid={cfg.cardGrid}
columns={columns}
selectedColumns={selectedColumns}
onChange={(cardGrid) => update({ cardGrid })}
/>
))}
</div>
{/* 이전/다음 버튼 */}
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
onClick={goPrev}
disabled={step === 0}
className="h-7 text-xs"
>
<ChevronLeft className="mr-1 h-3 w-3" />
</Button>
<span className="text-[10px] text-muted-foreground self-center">
{step + 1} / {TOTAL_STEPS}
</span>
<Button
variant="outline"
size="sm"
onClick={goNext}
disabled={step === TOTAL_STEPS - 1 || !canGoNext()}
className="h-7 text-xs"
>
<ChevronRight className="ml-1 h-3 w-3" />
</Button>
</div>
</div>
);
}
// ===== STEP 0: 모드 선택 =====
function StepModeSelect({
displayMode,
onChange,
}: {
displayMode: StringListDisplayMode;
onChange: (mode: StringListDisplayMode) => void;
}) {
return (
<div className="flex gap-2">
<button
onClick={() => onChange("list")}
className={`flex-1 rounded-md border p-3 text-center transition-colors ${
displayMode === "list"
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-primary/50"
}`}
>
<div className="text-sm font-medium"></div>
<div className="text-[10px] text-muted-foreground mt-1">
/
</div>
</button>
<button
onClick={() => onChange("card")}
className={`flex-1 rounded-md border p-3 text-center transition-colors ${
displayMode === "card"
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-primary/50"
}`}
>
<div className="text-sm font-medium"></div>
<div className="text-[10px] text-muted-foreground mt-1">
</div>
</button>
</div>
);
}
// ===== STEP 1: 헤더 설정 =====
function StepHeader({
header,
onChange,
}: {
header: PopStringListConfig["header"];
onChange: (header: PopStringListConfig["header"]) => void;
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={header.enabled}
onCheckedChange={(enabled) => onChange({ ...header, enabled })}
/>
</div>
{header.enabled && (
<div>
<Label className="text-xs"> </Label>
<Input
value={header.label || ""}
onChange={(e) => onChange({ ...header, label: e.target.value })}
placeholder="리스트 제목 입력"
className="mt-1 h-8 text-xs"
/>
</div>
)}
</div>
);
}
// ===== STEP 2: 오버플로우 설정 =====
function StepOverflow({
overflow,
onChange,
}: {
overflow: PopStringListConfig["overflow"];
onChange: (overflow: PopStringListConfig["overflow"]) => void;
}) {
return (
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.visibleRows}
onChange={(e) =>
onChange({ ...overflow, visibleRows: Number(e.target.value) || 5 })
}
className="mt-1 h-8 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={overflow.showExpandButton}
onCheckedChange={(showExpandButton) =>
onChange({ ...overflow, showExpandButton })
}
/>
</div>
{overflow.showExpandButton && (
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min={overflow.visibleRows}
max={200}
value={overflow.maxExpandRows}
onChange={(e) =>
onChange({
...overflow,
maxExpandRows: Number(e.target.value) || 20,
})
}
className="mt-1 h-8 text-xs"
/>
</div>
)}
</div>
);
}
// ===== STEP 3: 데이터 선택 (테이블 + 컬럼 통합) =====
function StepDataSelect({
tables,
tableName,
onTableChange,
columns,
selectedColumns,
onColumnsChange,
}: {
tables: TableInfo[];
tableName: string;
onTableChange: (tableName: string) => void;
columns: ColumnInfo[];
selectedColumns: string[];
onColumnsChange: (selected: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const selectedDisplay = tableName
? tables.find((t) => t.tableName === tableName)?.displayName || tableName
: "";
const toggleColumn = (colName: string) => {
if (selectedColumns.includes(colName)) {
onColumnsChange(selectedColumns.filter((c) => c !== colName));
} else {
onColumnsChange([...selectedColumns, colName]);
}
};
return (
<div className="space-y-3">
{/* 테이블 선택 */}
<div>
<Label className="text-xs"> </Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs font-normal"
>
{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("");
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!tableName ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.tableName} ${t.displayName || ""}`}
onSelect={() => {
onTableChange(t.tableName);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
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>
{/* 컬럼 선택 (테이블 선택 후 표시) */}
{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">
{col.type}
</span>
</label>
))}
</div>
</div>
)}
{tableName && columns.length === 0 && (
<p className="text-[10px] text-muted-foreground">
...
</p>
)}
</div>
);
}
// ===== STEP 4: 조인 설정 (UX 개선 - 자동매칭 + 타입필터링) =====
// DB 타입을 짧은 약어로 변환
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 "timestamp";
if (lower === "integer" || lower === "int4") return "int";
if (lower === "bigint" || lower === "int8") return "bigint";
if (lower === "numeric" || lower === "decimal") return "numeric";
if (lower === "boolean" || lower === "bool") return "bool";
if (lower === "date") return "date";
if (lower === "uuid") return "uuid";
if (lower === "jsonb" || lower === "json") return "json";
return t.length > 12 ? t.slice(0, 10) + ".." : t;
};
// 조인 항목 하나를 관리하는 서브 컴포넌트
function JoinItem({
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);
const [loading, setLoading] = useState(false);
// 대상 테이블 변경 시 컬럼 로딩
useEffect(() => {
if (!join.targetTable) {
setTargetColumns([]);
return;
}
setLoading(true);
fetchTableColumns(join.targetTable)
.then(setTargetColumns)
.catch(() => setTargetColumns([]))
.finally(() => setLoading(false));
}, [join.targetTable]);
// 자동 매칭: 이름 + 타입이 모두 같은 컬럼 쌍 찾기
const autoMatches = mainColumns.filter((mc) =>
targetColumns.some((tc) => tc.name === mc.name && tc.type === mc.type)
);
// 현재 연결된 쌍이 자동매칭 항목인지 확인
const isAutoMatch =
join.sourceColumn !== "" &&
join.sourceColumn === join.targetColumn &&
autoMatches.some((m) => m.name === join.sourceColumn);
// 수동 매칭: 소스 컬럼 선택 시 같은 타입의 대상 컬럼만 필터
const compatibleTargetCols = join.sourceColumn
? targetColumns.filter((tc) => {
const srcCol = mainColumns.find((mc) => mc.name === join.sourceColumn);
return srcCol ? tc.type === srcCol.type : true;
})
: targetColumns;
// 메인 테이블 제외한 테이블 목록
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 prev = selectedTargetCols;
const next = prev.includes(colName)
? prev.filter((c) => c !== colName)
: [...prev, 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>
{/* 대상 테이블 선택 (검색 가능 Combobox) */}
<div>
<span className="text-[9px] text-muted-foreground"> </span>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableOpen}
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 || ""} ${t.description || ""}`}
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"
)}
/>
<div className="flex flex-col">
<span>{t.tableName}</span>
{(t.displayName || t.description) && (
<span className="text-[8px] text-muted-foreground">
{t.displayName || t.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 대상 테이블 선택 후 컬럼 매칭 영역 */}
{join.targetTable && (
<>
{loading ? (
<p className="text-[9px] text-muted-foreground"> ...</p>
) : (
<>
{/* 자동 매칭 결과 - 테이블 헤더 + 컬럼명만 표시 */}
{autoMatches.length > 0 && (
<div>
<span className="text-[9px] font-medium text-muted-foreground">
</span>
{/* 테이블명 헤더 */}
<div className="mt-1 flex items-center gap-1 px-1.5 text-[8px] font-medium text-muted-foreground">
<div className="w-3.5 shrink-0" />
<span className="flex-1 truncate">{mainTableName}</span>
<span className="w-3 shrink-0" />
<span className="flex-1 truncate">{join.targetTable}</span>
<span className="w-12 shrink-0" />
</div>
{/* 매칭 행 */}
<div className="space-y-0.5">
{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-left 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="w-3 shrink-0 text-center text-muted-foreground">=</span>
<span className="flex-1 truncate">{mc.name}</span>
<span className="w-12 shrink-0 text-right text-[8px] text-muted-foreground">
{shortType(mc.type)}
</span>
</button>
);
})}
</div>
</div>
)}
{autoMatches.length === 0 && (
<p className="text-[9px] text-muted-foreground">
. .
</p>
)}
{/* 수동 매칭 (고급) */}
{!isAutoMatch && (
<div>
<span className="text-[9px] font-medium text-muted-foreground">
</span>
<div className="mt-1 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}
<span className="ml-1 text-[8px] text-muted-foreground">
({shortType(mc.type)})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 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={
!join.sourceColumn ? "먼저 메인 선택" : "대상 컬럼"
}
/>
</SelectTrigger>
<SelectContent>
{compatibleTargetCols.length > 0 ? (
compatibleTargetCols.map((tc) => (
<SelectItem key={tc.name} value={tc.name} className="text-[10px]">
{tc.name}
<span className="ml-1 text-[8px] text-muted-foreground">
({shortType(tc.type)})
</span>
</SelectItem>
))
) : (
<SelectItem value="__none__" disabled className="text-[10px]">
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
)}
</>
)}
{/* 표시 방식 (JOIN 타입) - 자연어 + 설명 */}
<div>
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<div className="mt-1 flex gap-1">
<button
type="button"
onClick={() => onUpdate({ joinType: "LEFT" })}
className={cn(
"flex-1 rounded border px-2 py-1 transition-colors",
join.joinType === "LEFT"
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
<div className="text-[10px] font-medium"> </div>
<div className="text-[7px] text-muted-foreground">
</div>
</button>
<button
type="button"
onClick={() => onUpdate({ joinType: "INNER" })}
className={cn(
"flex-1 rounded border px-2 py-1 transition-colors",
join.joinType === "INNER"
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
<div className="text-[10px] font-medium"> </div>
<div className="text-[7px] text-muted-foreground">
</div>
</button>
</div>
</div>
{/* 가져올 컬럼 선택 (연결 조건 설정 후 활성화) */}
{hasJoinCondition && !loading && (
<div>
<span className="text-[9px] font-medium text-muted-foreground">
({selectedTargetCols.length} )
</span>
{pickableTargetCols.length > 0 ? (
<div className="mt-1 max-h-[120px] 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-left 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="shrink-0 text-[8px] text-muted-foreground">
{shortType(tc.type)}
</span>
</button>
);
})}
</div>
) : (
<p className="mt-1 text-[9px] text-muted-foreground">
</p>
)}
</div>
)}
</>
)}
</div>
);
}
function StepJoinConfig({
dataSource,
tables,
mainColumns,
onChange,
}: {
dataSource: CardListDataSource;
tables: TableInfo[];
mainColumns: ColumnInfo[];
onChange: (dataSource: CardListDataSource) => void;
}) {
const joins = dataSource.joins || [];
const addJoin = () => {
const newJoin: CardColumnJoin = {
targetTable: "",
joinType: "LEFT",
sourceColumn: "",
targetColumn: "",
};
onChange({ ...dataSource, joins: [...joins, newJoin] });
};
const removeJoin = (index: number) => {
const next = joins.filter((_, i) => i !== index);
onChange({ ...dataSource, joins: next });
};
const updateJoin = (index: number, partial: Partial<CardColumnJoin>) => {
const next = joins.map((j, i) =>
i === index ? { ...j, ...partial } : j
);
onChange({ ...dataSource, joins: next });
};
return (
<div className="space-y-2">
<p className="text-[10px] text-muted-foreground">
()
</p>
{joins.map((join, i) => (
<JoinItem
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>
);
}
// ===== STEP 6-A: 리스트 컬럼 배치 =====
// 조인 테이블 컬럼 정보
interface JoinedColumnInfo {
name: string; // "테이블명.컬럼명" 형태
displayName: string; // 컬럼명만
sourceTable: string; // 테이블명
}
function StepListLayout({
listColumns,
availableColumns,
joinedColumns,
onChange,
}: {
listColumns: ListColumnConfig[];
availableColumns: ColumnInfo[];
joinedColumns: JoinedColumnInfo[];
onChange: (listColumns: ListColumnConfig[]) => void;
}) {
const widthBarRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const columnsRef = useRef(listColumns);
columnsRef.current = listColumns;
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
// 드래그 핸들에서만 draggable 활성화 (Select/Input 충돌 방지)
const [draggableRow, setDraggableRow] = useState<number | null>(null);
// 컬럼 전환 설정 펼침 인덱스
const [expandedAltIdx, setExpandedAltIdx] = useState<number | null>(null);
// 리스트에 현재 포함된 컬럼명 셋
const listColumnNames = new Set(listColumns.map((c) => c.columnName));
// 추가 가능한 컬럼: (메인 + 조인) 중 현재 리스트에 없는 것
const addableColumns = [
...availableColumns
.filter((c) => !listColumnNames.has(c.name))
.map((c) => ({ value: c.name, label: c.name, source: "main" as const })),
...joinedColumns
.filter((c) => !listColumnNames.has(c.name))
.map((c) => ({
value: c.name,
label: `${c.displayName} (${c.sourceTable})`,
source: "join" as const,
})),
];
// 컬럼 추가 (독립 헤더로 추가 시 다른 컬럼의 alternateColumns에서 제거)
const addColumn = (columnValue: string) => {
const joinCol = joinedColumns.find((c) => c.name === columnValue);
const newCol: ListColumnConfig = {
columnName: columnValue,
label: joinCol?.displayName || columnValue,
};
// 다른 컬럼의 alternateColumns에서 이 컬럼 제거 (독립 헤더가 되므로)
const cleaned = listColumns.map((col) => {
const alts = col.alternateColumns;
if (!alts || !alts.includes(columnValue)) return col;
const newAlts = alts.filter((a) => a !== columnValue);
return { ...col, alternateColumns: newAlts.length > 0 ? newAlts : undefined };
});
onChange([...cleaned, newCol]);
};
// 컬럼 삭제 (리스트에서만 삭제, STEP 3 체크 유지)
const removeColumn = (index: number) => {
const next = listColumns.filter((_, i) => i !== index);
onChange(next);
// 펼침 인덱스 초기화 (삭제로 인덱스가 밀리므로)
setExpandedAltIdx(null);
};
// 전환 후보: (메인 + 조인) - 자기 자신 - 리스트에 독립 헤더로 있는 것
const getAlternateCandidates = (currentColumnName: string) => {
return [
...availableColumns
.filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name))
.map((c) => ({ value: c.name, label: c.name, source: "main" as const })),
...joinedColumns
.filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name))
.map((c) => ({
value: c.name,
label: c.displayName,
source: "join" as const,
sourceTable: c.sourceTable,
})),
];
};
const updateColumn = (index: number, partial: Partial<ListColumnConfig>) => {
const next = listColumns.map((col, i) =>
i === index ? { ...col, ...partial } : col
);
onChange(next);
};
// 너비 드래그 핸들러
const handleWidthDrag = 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 cols = columnsRef.current;
const startFrs = cols.map((c) => {
const num = parseFloat(c.width || "1");
return isNaN(num) || num <= 0 ? 1 : num;
});
const totalFr = startFrs.reduce((a, b) => a + b, 0);
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
const frDelta = (delta / barWidth) * totalFr;
const newFrs = [...startFrs];
newFrs[dividerIndex] = Math.max(0.3, startFrs[dividerIndex] + frDelta);
newFrs[dividerIndex + 1] = Math.max(
0.3,
startFrs[dividerIndex + 1] - frDelta
);
const next = columnsRef.current.map((col, i) => ({
...col,
width: `${Math.round(newFrs[i] * 10) / 10}fr`,
}));
onChange(next);
};
const onUp = () => {
isDraggingRef.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[onChange]
);
// 순서 드래그앤드롭 - 핸들에서 mousedown 시에만 draggable 활성화
const handleDragStart = (e: React.DragEvent, idx: number) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(idx));
setDragIdx(idx);
};
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIdx(idx);
};
const handleDrop = (e: React.DragEvent, idx: number) => {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) {
setDragIdx(null);
setDragOverIdx(null);
setDraggableRow(null);
return;
}
const next = [...listColumns];
const [moved] = next.splice(dragIdx, 1);
next.splice(idx, 0, moved);
onChange(next);
setDragIdx(null);
setDragOverIdx(null);
setDraggableRow(null);
};
const handleDragEnd = () => {
setDragIdx(null);
setDragOverIdx(null);
setDraggableRow(null);
};
if (listColumns.length === 0 && addableColumns.length === 0) {
return (
<p className="text-xs text-muted-foreground">
</p>
);
}
return (
<div className="space-y-2">
{/* 컬럼 너비 드래그 바 */}
<div
ref={widthBarRef}
className="flex h-6 select-none overflow-hidden rounded border"
>
{listColumns.map((col, i) => {
const fr = parseFloat(col.width || "1") || 1;
return (
<Fragment key={col.columnName}>
<div
className="flex items-center justify-center bg-muted/30 text-[7px] text-muted-foreground"
style={{ flex: fr }}
>
{col.label || col.columnName}
</div>
{i < listColumns.length - 1 && (
<div
className="w-1 shrink-0 cursor-col-resize bg-border transition-colors hover:bg-primary"
onMouseDown={(e) => handleWidthDrag(e, i)}
title="드래그하여 너비 조정"
/>
)}
</Fragment>
);
})}
</div>
{/* 컬럼별 설정 (드래그 순서 + 컬럼 선택 + 라벨 + 정렬) */}
<div className="space-y-1">
{listColumns.map((col, i) => (
<Fragment key={col.columnName}>
<div
draggable={draggableRow === i}
onDragStart={(e) => handleDragStart(e, i)}
onDragOver={(e) => handleDragOver(e, i)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd}
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 transition-colors",
dragIdx === i && "opacity-40",
dragOverIdx === i && dragIdx !== i && "bg-primary/10 border-t-2 border-primary"
)}
>
{/* 드래그 핸들 - mousedown 시에만 행 draggable 활성화 */}
<div
className="flex w-4 shrink-0 cursor-grab flex-col items-center justify-center active:cursor-grabbing"
onMouseDown={() => setDraggableRow(i)}
onMouseUp={() => setDraggableRow(null)}
>
<div className="flex flex-col gap-[2px]">
<div className="h-[1.5px] w-2.5 rounded bg-muted-foreground/40" />
<div className="h-[1.5px] w-2.5 rounded bg-muted-foreground/40" />
<div className="h-[1.5px] w-2.5 rounded bg-muted-foreground/40" />
</div>
</div>
{/* 컬럼 선택 드롭다운 (메인 + 조인 테이블 컬럼) */}
<Select
value={col.columnName}
onValueChange={(v) => {
const colInfo = availableColumns.find((c) => c.name === v);
const joinInfo = joinedColumns.find((c) => c.name === v);
updateColumn(i, {
columnName: v,
label: colInfo?.name || joinInfo?.displayName || v,
alternateColumns: undefined, // 컬럼 변경 시 전환 옵션 초기화
});
}}
>
<SelectTrigger className="h-7 w-24 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableColumns.length > 0 && (
<>
{availableColumns.map((ac) => (
<SelectItem
key={ac.name}
value={ac.name}
className="text-[10px]"
>
{ac.name}
</SelectItem>
))}
</>
)}
{joinedColumns.length > 0 && (
<>
{joinedColumns.map((jc) => (
<SelectItem
key={jc.name}
value={jc.name}
className="text-[10px]"
>
<span>{jc.displayName}</span>
<span className="ml-1 text-[8px] text-muted-foreground">
({jc.sourceTable})
</span>
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
{/* 라벨 */}
<Input
value={col.label}
onChange={(e) => updateColumn(i, { label: e.target.value })}
placeholder="라벨"
className="h-7 flex-1 text-[10px]"
/>
{/* 정렬 */}
<Select
value={col.align || "left"}
onValueChange={(v) =>
updateColumn(i, { align: v as ListColumnConfig["align"] })
}
>
<SelectTrigger className="h-7 w-14 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>
{/* 컬럼 전환 버튼 (전환 후보가 있을 때만) */}
{getAlternateCandidates(col.columnName).length > 0 && (
<button
type="button"
onClick={() =>
setExpandedAltIdx(expandedAltIdx === i ? null : i)
}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded text-[10px] transition-colors",
(col.alternateColumns || []).length > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted"
)}
title="컬럼 전환 설정"
>
<ChevronsUpDown className="h-3 w-3" />
</button>
)}
{/* 컬럼 삭제 버튼 */}
<button
type="button"
onClick={() => removeColumn(i)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
title="컬럼 삭제"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* 전환 가능 컬럼 (펼침 시만 표시, 메인+조인 중 리스트 미포함분) */}
{expandedAltIdx === i && (() => {
const candidates = getAlternateCandidates(col.columnName);
if (candidates.length === 0) return null;
return (
<div className="ml-5 flex flex-wrap items-center gap-1 pb-1">
<span className="shrink-0 text-[8px] text-muted-foreground">:</span>
{candidates.map((cand) => {
const alts = col.alternateColumns || [];
const isAlt = alts.includes(cand.value);
return (
<button
key={cand.value}
type="button"
onClick={() => {
const newAlts = isAlt
? alts.filter((a) => a !== cand.value)
: [...alts, cand.value];
updateColumn(i, {
alternateColumns: newAlts.length > 0 ? newAlts : undefined,
});
}}
className={cn(
"rounded border px-1.5 py-0.5 text-[8px] transition-colors",
isAlt
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{cand.label}
{cand.source === "join" && (
<span className="ml-0.5 text-[7px] text-muted-foreground">*</span>
)}
</button>
);
})}
</div>
);
})()}
</Fragment>
))}
</div>
{/* 컬럼 추가 */}
{addableColumns.length > 0 && (
<Select onValueChange={addColumn}>
<SelectTrigger className="h-7 w-full text-[10px]">
<SelectValue placeholder="+ 컬럼 추가..." />
</SelectTrigger>
<SelectContent>
{addableColumns.some((c) => c.source === "main") && (
<>
<SelectItem value="__main_header__" disabled className="text-[8px] font-medium text-muted-foreground">
</SelectItem>
{addableColumns
.filter((c) => c.source === "main")
.map((c) => (
<SelectItem key={c.value} value={c.value} className="text-[10px]">
{c.label}
</SelectItem>
))}
</>
)}
{addableColumns.some((c) => c.source === "join") && (
<>
<SelectItem value="__join_header__" disabled className="text-[8px] font-medium text-muted-foreground">
</SelectItem>
{addableColumns
.filter((c) => c.source === "join")
.map((c) => (
<SelectItem key={c.value} value={c.value} className="text-[10px]">
{c.label}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
)}
<p className="text-[8px] text-muted-foreground">
|
</p>
</div>
);
}
// ===== STEP 6-B: 시각적 카드 그리드 디자이너 =====
// fr 문자열을 숫자로 파싱 (예: "2fr" -> 2, "1fr" -> 1)
const parseFr = (v: string): number => {
const num = parseFloat(v);
return isNaN(num) || num <= 0 ? 1 : num;
};
// 카드 그리드 반응형 안전 제약
// - 6열 초과: 모바일(320px)에서 셀 30px 미만 → 텍스트 깨짐
// - 6행 초과: 카드 1장 높이 과도 → 스크롤 과다
// - gap 16px 초과: 셀 공간 부족
// - fr 0.3 미만: 셀 보이지 않음
const GRID_LIMITS = {
cols: { min: 1, max: 6 },
rows: { min: 1, max: 6 },
gap: { min: 0, max: 16 },
minFr: 0.3,
} as const;
// 행 높이 기본값 (px 기반 고정 높이)
const DEFAULT_ROW_HEIGHT = 32;
const MIN_ROW_HEIGHT = 24;
// px 문자열에서 숫자 추출 (예: "32px" → 32)
const parsePx = (v: string): number => {
const num = parseInt(v);
return isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num;
};
// fr → px 마이그레이션 (기존 저장 데이터 호환)
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`;
};
function StepCardDesigner({
cardGrid,
columns,
selectedColumns,
onChange,
}: {
cardGrid: CardGridConfig | undefined;
columns: ColumnInfo[];
selectedColumns: string[];
onChange: (cardGrid: CardGridConfig) => void;
}) {
// 셀에서 컬럼 선택 시 사용자가 선택한 컬럼만 표시
const availableColumns = columns.filter((c) =>
selectedColumns.includes(c.name)
);
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 rowBarRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const gridConfigRef = useRef<CardGridConfig | undefined>(undefined);
const isDraggingRef = useRef(false);
const [gridLines, setGridLines] = useState<{
colLines: number[];
rowLines: number[];
}>({ colLines: [], rowLines: [] });
// 기본 카드 그리드 (rowHeights는 px 기반 고정 높이)
const rawGrid: CardGridConfig = cardGrid || {
rows: 1,
cols: 1,
colWidths: ["1fr"],
rowHeights: [`${DEFAULT_ROW_HEIGHT}px`],
gap: 4,
showBorder: true,
cells: [],
};
// 기존 fr 데이터 → px 자동 마이그레이션 + 길이 정규화
const migratedRowHeights = (
rawGrid.rowHeights || Array(rawGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)
).map(migrateRowHeight);
// colWidths/rowHeights 배열 길이와 cols/rows 수 불일치 보정
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: CardGridConfig = {
...rawGrid,
colWidths: normalizedColWidths,
rowHeights: normalizedRowHeights,
};
gridConfigRef.current = grid;
const updateGrid = (partial: Partial<CardGridConfig>) => {
onChange({ ...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: CardCellDefinition = {
id: `cell-${Date.now()}`,
row,
col,
rowSpan: 1,
colSpan: 1,
columnName: "",
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<CardCellDefinition>) => {
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 validateMergeSelection = (): {
minRow: number;
maxRow: number;
minCol: number;
maxCol: number;
} | null => {
if (mergeCellKeys.size < 2) return null;
const positions = Array.from(mergeCellKeys).map((key) => {
const [r, c] = key.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));
const expectedCount = (maxRow - minRow + 1) * (maxCol - minCol + 1);
if (mergeCellKeys.size !== expectedCount) return null;
for (const key of mergeCellKeys) {
if (occupationMap[key]) return null;
}
return { minRow, maxRow, minCol, maxCol };
};
const confirmMerge = () => {
const bbox = validateMergeSelection();
if (!bbox) return;
const newCell: CardCellDefinition = {
id: `cell-${Date.now()}`,
row: bbox.minRow,
col: bbox.minCol,
rowSpan: bbox.maxRow - bbox.minRow + 1,
colSpan: bbox.maxCol - bbox.minCol + 1,
columnName: "",
type: "text",
};
updateGrid({ cells: [...grid.cells, newCell] });
setSelectedCellId(newCell.id);
setMergeMode(false);
setMergeCellKeys(new Set());
};
const cancelMerge = () => {
setMergeMode(false);
setMergeCellKeys(new Set());
};
const mergeValid = validateMergeSelection();
// ---- 셀 분할 ----
// 칸 나누기 (좌/우 분할 = 열 방향)
const splitCellHorizontally = (cell: CardCellDefinition) => {
const cs = Number(cell.colSpan) || 1;
const rs = Number(cell.rowSpan) || 1;
if (cs >= 2) {
// colSpan 2 이상: 그리드 변경 없이 셀만 분할
const leftSpan = Math.ceil(cs / 2);
const rightSpan = cs - leftSpan;
const newCell: CardCellDefinition = {
id: `cell-${Date.now()}`,
row: cell.row,
col: cell.col + leftSpan,
rowSpan: rs,
colSpan: rightSpan,
columnName: "",
type: "text",
};
const updatedCells = grid.cells.map((c) =>
c.id === cell.id ? { ...c, colSpan: leftSpan } : c
);
updateGrid({ cells: [...updatedCells, newCell] });
setSelectedCellId(newCell.id);
} else {
// colSpan 1: 새 열 삽입하여 분할
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: CardCellDefinition = {
id: `cell-${Date.now()}`,
row: cell.row,
col: insertPos,
rowSpan: rs,
colSpan: 1,
columnName: "",
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: CardCellDefinition) => {
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) {
// rowSpan 2 이상: 그리드 변경 없이 셀만 분할
const topSpan = Math.ceil(rs / 2);
const bottomSpan = rs - topSpan;
const newCell: CardCellDefinition = {
id: `cell-${Date.now()}`,
row: cell.row + topSpan,
col: cell.col,
rowSpan: bottomSpan,
colSpan: cs,
columnName: "",
type: "text",
};
const updatedCells = grid.cells.map((c) =>
c.id === cell.id ? { ...c, rowSpan: topSpan } : c
);
updateGrid({ cells: [...updatedCells, newCell] });
setSelectedCellId(newCell.id);
} else {
// rowSpan 1: 새 행 삽입하여 분할 (기존 행 높이 유지, 새 행은 기본 높이)
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: CardCellDefinition = {
id: `cell-${Date.now()}`,
row: insertPos,
col: cell.col,
rowSpan: 1,
colSpan: cs,
columnName: "",
type: "text",
};
// 기존 행 높이 유지, 새 행은 기본 px 높이로 삽입
const rowIdx = cell.row - 1;
const newHeights = [...heights];
newHeights.splice(rowIdx + 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: CardCellDefinition) => {
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; // 0으로 나누기 방어
const currentGrid = gridConfigRef.current;
if (!currentGrid) return;
const startFrs = (currentGrid.colWidths || []).map(parseFr);
const totalFr = startFrs.reduce((a, b) => a + b, 0);
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.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
);
const newWidths = newFrs.map(
(fr) => `${Math.round(fr * 10) / 10}fr`
);
onChange({ ...currentGrid, colWidths: newWidths });
};
const onUp = () => {
isDraggingRef.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[onChange]
);
// ---- 행 높이 드래그 (좌측 바 - 일괄) ----
const handleRowDragStart = useCallback(
(e: React.MouseEvent, dividerIndex: number) => {
e.preventDefault();
isDraggingRef.current = true;
const startY = e.clientY;
const currentGrid = gridConfigRef.current;
if (!currentGrid) return;
// px 기반: 픽셀 델타를 직접 적용 (fr 변환 불필요 → 안정적)
const heights = (
currentGrid.rowHeights ||
Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)
).map(parsePx);
if (dividerIndex < 0 || dividerIndex + 1 >= heights.length) return;
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientY - startY;
const newHeights = [...heights];
newHeights[dividerIndex] = Math.max(
MIN_ROW_HEIGHT,
heights[dividerIndex] + delta
);
newHeights[dividerIndex + 1] = Math.max(
MIN_ROW_HEIGHT,
heights[dividerIndex + 1] - delta
);
const newRowHeights = newHeights.map((h) => `${Math.round(h)}px`);
onChange({ ...currentGrid, rowHeights: newRowHeights });
};
const onUp = () => {
isDraggingRef.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[onChange]
);
// ---- 내부 셀 경계 드래그 (개별) ----
// 그리드 라인 위치 측정 (ResizeObserver)
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) || parseFloat(style.columnGap) || 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();
// 배열 참조가 매 렌더 변경되므로, join으로 안정적인 값 비교
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [grid.colWidths.join(","), grid.rowHeights?.join(","), grid.gap, grid.cols, grid.rows]);
// 경계선 가시성 (병합 셀 내부는 숨김)
const isColLineVisible = (lineIdx: number): boolean => {
const leftCol = lineIdx + 1;
const rightCol = lineIdx + 2;
for (let r = 1; r <= grid.rows; r++) {
const left = occupationMap[`${r}-${leftCol}`];
const right = occupationMap[`${r}-${rightCol}`];
if (left !== right) return true;
if (!left && !right) return true;
}
return false;
};
const isRowLineVisible = (lineIdx: number): boolean => {
const topRow = lineIdx + 1;
const bottomRow = lineIdx + 2;
for (let c = 1; c <= grid.cols; c++) {
const top = occupationMap[`${topRow}-${c}`];
const bottom = occupationMap[`${bottomRow}-${c}`];
if (top !== bottom) return true;
if (!top && !bottom) return true;
}
return false;
};
// 내부 열 경계 드래그
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; // 0으로 나누기 방어
const currentGrid = gridConfigRef.current;
if (!currentGrid) return;
const startFrs = (currentGrid.colWidths || []).map(parseFr);
const totalFr = startFrs.reduce((a, b) => a + b, 0);
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.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
);
const newWidths = newFrs.map(
(fr) => `${Math.round(fr * 10) / 10}fr`
);
onChange({ ...currentGrid, colWidths: newWidths });
};
const onUp = () => {
isDraggingRef.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[onChange]
);
// 내부 행 경계 드래그 (px 기반 직접 조정)
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;
// px 기반: 픽셀 델타를 직접 적용
const heights = (
currentGrid.rowHeights ||
Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`)
).map(parsePx);
if (lineIdx < 0 || lineIdx + 1 >= heights.length) return;
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientY - startY;
const newHeights = [...heights];
newHeights[lineIdx] = Math.max(
MIN_ROW_HEIGHT,
heights[lineIdx] + delta
);
newHeights[lineIdx + 1] = Math.max(
MIN_ROW_HEIGHT,
heights[lineIdx + 1] - delta
);
const newRowHeights = newHeights.map((h) => `${Math.round(h)}px`);
onChange({ ...currentGrid, rowHeights: newRowHeights });
};
const onUp = () => {
isDraggingRef.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[onChange]
);
// ---- 선택된 셀 ----
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 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; // 빈 배열 방어
// totalFr 필드를 px 값의 합산으로 사용 (flex 비율로 활용)
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 flex-wrap items-center gap-1.5 rounded border bg-muted/30 px-2 py-1">
<div className="flex items-center gap-1.5">
<button
onClick={() => updateGrid({ showBorder: !grid.showBorder })}
className={cn(
"h-5 rounded border px-1.5 text-[9px] transition-colors",
grid.showBorder
? "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>
</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) return;
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) return;
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={cancelMerge}
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)
}
title="드래그하여 열 너비 일괄 조정"
/>
)}
</Fragment>
))}
</div>
</div>
{/* 메인: 행 높이 바 (왼쪽) + 그리드 (오른쪽) */}
<div className="flex gap-1">
{/* 행 높이 드래그 바 (일괄 조정, 병합 트랙 묶음) */}
<div
ref={rowBarRef}
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 }}
title={
group.count > 1
? `${Math.round(group.totalFr)}px`
: rowHeightsArr[group.startIdx]
}
>
{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)
}
title="드래그하여 행 높이 일괄 조정"
/>
)}
</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}`);
// span으로 점유된 위치
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.showBorder
? "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 || "미지정"}
</span>
<span className="text-[6px] text-muted-foreground">
{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 hover:text-primary"
: "border-muted-foreground/30 text-muted-foreground/40 hover:border-primary/50 hover:bg-primary/5 hover:text-primary/60"
)}
style={{
gridColumn: `${col} / span 1`,
gridRow: `${row} / span 1`,
}}
onClick={() => handleEmptyCellClick(row, col)}
title={
mergeMode ? "클릭하여 병합 선택" : "클릭하여 셀 추가"
}
>
{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={`col-h-${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)}
title="드래그하여 열 너비 개별 조정"
/>
);
})}
{gridLines.rowLines.map((y, i) => {
if (!isRowLineVisible(i)) return null;
return (
<div
key={`row-h-${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)}
title="드래그하여 행 높이 개별 조정"
/>
);
})}
</div>
</div>
</div>
{/* 선택된 셀 설정 패널 */}
{selectedCell && !mergeMode && (
<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">
({selectedCell.row} {selectedCell.col}
{((Number(selectedCell.colSpan) || 1) > 1 ||
(Number(selectedCell.rowSpan) || 1) > 1) &&
`, ${Number(selectedCell.colSpan) || 1}x${Number(selectedCell.rowSpan) || 1}`}
)
</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeCell(selectedCell.id)}
className="h-5 w-5 p-0"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
{/* 컬럼 + 타입 */}
<div className="flex gap-1">
<Select
value={selectedCell.columnName || "none"}
onValueChange={(v) =>
updateCell(selectedCell.id, {
columnName: v === "none" ? "" : v,
})
}
>
<SelectTrigger className="h-7 flex-1 text-[10px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-[10px]">
</SelectItem>
{availableColumns.map((col) => (
<SelectItem
key={col.name}
value={col.name}
className="text-[10px]"
>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedCell.type}
onValueChange={(v) =>
updateCell(selectedCell.id, {
type: v as CardCellDefinition["type"],
})
}
>
<SelectTrigger className="h-7 w-20 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text" className="text-[10px]">
</SelectItem>
<SelectItem value="image" className="text-[10px]">
</SelectItem>
<SelectItem value="badge" className="text-[10px]">
</SelectItem>
<SelectItem value="button" className="text-[10px]">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 라벨 + 라벨 위치 */}
<div className="flex gap-1">
<Input
value={selectedCell.label || ""}
onChange={(e) =>
updateCell(selectedCell.id, { label: e.target.value })
}
placeholder="라벨 (선택)"
className="h-7 flex-1 text-[10px]"
/>
<Select
value={selectedCell.labelPosition || "top"}
onValueChange={(v) =>
updateCell(selectedCell.id, {
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={selectedCell.fontSize || "md"}
onValueChange={(v) =>
updateCell(selectedCell.id, {
fontSize: v as "sm" | "md" | "lg",
})
}
>
<SelectTrigger className="h-7 flex-1 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<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={selectedCell.align || "left"}
onValueChange={(v) =>
updateCell(selectedCell.id, {
align: v as "left" | "center" | "right",
})
}
>
<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={selectedCell.verticalAlign || "top"}
onValueChange={(v) =>
updateCell(selectedCell.id, {
verticalAlign: v as "top" | "middle" | "bottom",
})
}
>
<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>
</div>
)}
{/* 반응형 안내 */}
<p className="text-[8px] text-muted-foreground">
{grid.cols} x {grid.rows} ( {GRID_LIMITS.cols.max}x
{GRID_LIMITS.rows.max}) | /좌측: 일괄 | 경계: 개별
</p>
</div>
);
}