2716 lines
93 KiB
TypeScript
2716 lines
93 KiB
TypeScript
"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>
|
|
);
|
|
}
|