263 lines
9.8 KiB
TypeScript
263 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* TableProperties.tsx — 테이블 컴포넌트 설정
|
|
*
|
|
* - section="data": TableLayoutTabs (컬럼 구성 / 요약 설정 탭)
|
|
* - section="style": StyleAccordion 패턴 (프리셋 + 헤더 / 셀 / 테두리)
|
|
*/
|
|
|
|
import { useState, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ChevronRight } from "lucide-react";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import { TableLayoutTabs } from "../modals/TableLayoutTabs";
|
|
import type { ComponentConfig, GridCell } from "@/types/report";
|
|
|
|
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
|
|
|
interface Props {
|
|
component: ComponentConfig;
|
|
section?: "style" | "data";
|
|
}
|
|
|
|
// ─── 프리셋 ────────────────────────────────────────────────────────────────────
|
|
|
|
const TABLE_STYLE_PRESETS = {
|
|
default: {
|
|
headerBackgroundColor: "#f3f4f6",
|
|
headerTextColor: "#111827",
|
|
showBorder: true,
|
|
rowHeight: 32,
|
|
},
|
|
dark: {
|
|
headerBackgroundColor: "#1e293b",
|
|
headerTextColor: "#ffffff",
|
|
showBorder: true,
|
|
rowHeight: 32,
|
|
},
|
|
blue: {
|
|
headerBackgroundColor: "#1d4ed8",
|
|
headerTextColor: "#ffffff",
|
|
showBorder: true,
|
|
rowHeight: 32,
|
|
},
|
|
minimal: {
|
|
headerBackgroundColor: "#f8fafc",
|
|
headerTextColor: "#374151",
|
|
showBorder: false,
|
|
rowHeight: 28,
|
|
},
|
|
} as const;
|
|
|
|
// ─── StyleAccordion ────────────────────────────────────────────────────────────
|
|
|
|
function StyleAccordion({
|
|
label,
|
|
isOpen,
|
|
onToggle,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="border-b border-gray-100">
|
|
<button
|
|
onClick={onToggle}
|
|
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
|
isOpen
|
|
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
|
: "bg-white text-gray-900 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<span className="text-xs font-bold">{label}</span>
|
|
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`} />
|
|
</button>
|
|
{isOpen && (
|
|
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── ColorInput ────────────────────────────────────────────────────────────────
|
|
|
|
function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
|
<input
|
|
type="color"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
|
/>
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 컴포넌트 ──────────────────────────────────────────────────────────────────
|
|
|
|
export function TableProperties({ component, section }: Props) {
|
|
const { updateComponent } = useReportDesigner();
|
|
|
|
const showStyle = !section || section === "style";
|
|
const showData = !section || section === "data";
|
|
|
|
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["preset"]));
|
|
const toggleSection = (id: string) => {
|
|
setOpenSections((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const update = useCallback(
|
|
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
|
[component.id, updateComponent],
|
|
);
|
|
|
|
const applyPreset = useCallback(
|
|
(key: keyof typeof TABLE_STYLE_PRESETS) => {
|
|
const preset = TABLE_STYLE_PRESETS[key];
|
|
const updates: Partial<ComponentConfig> = { ...preset };
|
|
|
|
if (component.gridMode && component.gridCells) {
|
|
const headerRows = component.gridHeaderRows ?? 1;
|
|
const headerCols = component.gridHeaderCols ?? 1;
|
|
const newCells = component.gridCells.map((cell: GridCell) => {
|
|
if (cell.merged) return cell;
|
|
const isHeader = cell.row < headerRows || cell.col < headerCols;
|
|
if (!isHeader) return cell;
|
|
return {
|
|
...cell,
|
|
backgroundColor: preset.headerBackgroundColor,
|
|
textColor: preset.headerTextColor,
|
|
};
|
|
});
|
|
updates.gridCells = newCells;
|
|
}
|
|
|
|
update(updates);
|
|
},
|
|
[update, component.gridMode, component.gridCells, component.gridHeaderRows, component.gridHeaderCols],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{showData && <TableLayoutTabs component={component} />}
|
|
|
|
{showStyle && (
|
|
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
|
{/* 프리셋 */}
|
|
<StyleAccordion label="프리셋" isOpen={openSections.has("preset")} onToggle={() => toggleSection("preset")}>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{(["default", "dark", "blue", "minimal"] as const).map((key) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => applyPreset(key)}
|
|
className="flex flex-col items-center gap-1.5 rounded-lg border border-gray-200 bg-white p-2 text-[10px] font-medium text-gray-700 transition-all hover:border-blue-300 hover:bg-blue-50/50"
|
|
>
|
|
<div
|
|
className="h-5 w-full rounded"
|
|
style={{
|
|
backgroundColor: TABLE_STYLE_PRESETS[key].headerBackgroundColor,
|
|
border: TABLE_STYLE_PRESETS[key].showBorder ? "1px solid #d1d5db" : "none",
|
|
}}
|
|
/>
|
|
{{ default: "기본", dark: "다크", blue: "블루", minimal: "미니멀" }[key]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 헤더 스타일 */}
|
|
<StyleAccordion label="헤더 스타일" isOpen={openSections.has("header")} onToggle={() => toggleSection("header")}>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
|
<ColorInput
|
|
value={component.headerBackgroundColor || "#f3f4f6"}
|
|
onChange={(v) => update({ headerBackgroundColor: v })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">텍스트 색상</Label>
|
|
<ColorInput
|
|
value={component.headerTextColor || "#111827"}
|
|
onChange={(v) => update({ headerTextColor: v })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 셀 스타일 */}
|
|
<StyleAccordion label="셀 스타일" isOpen={openSections.has("cell")} onToggle={() => toggleSection("cell")}>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">행 높이 (px)</Label>
|
|
<Input
|
|
type="number"
|
|
min={20}
|
|
max={100}
|
|
value={component.rowHeight || 32}
|
|
onChange={(e) => update({ rowHeight: parseInt(e.target.value) || 32 })}
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">글자 크기</Label>
|
|
<Select
|
|
value={String(component.fontSize || 12)}
|
|
onValueChange={(v) => update({ fontSize: parseInt(v) })}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="10">10px</SelectItem>
|
|
<SelectItem value="11">11px</SelectItem>
|
|
<SelectItem value="12">12px (기본)</SelectItem>
|
|
<SelectItem value="13">13px</SelectItem>
|
|
<SelectItem value="14">14px</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 테두리 */}
|
|
<StyleAccordion label="테두리" isOpen={openSections.has("border")} onToggle={() => toggleSection("border")}>
|
|
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2">
|
|
<Label className="text-xs text-gray-500">테두리 표시</Label>
|
|
<Switch
|
|
checked={component.showBorder !== false}
|
|
onCheckedChange={(checked) => update({ showBorder: checked })}
|
|
/>
|
|
</div>
|
|
</StyleAccordion>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|