ERP-node/frontend/components/report/designer/properties/TableProperties.tsx

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>
)}
</>
);
}