257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* CardProperties.tsx — 카드 컴포넌트 설정
|
|
*
|
|
* - section="data": 모달 내 기능 설정 탭에서 CardLayoutTabs 직접 렌더링
|
|
* - section="style": 우측 패널에서 프리셋 + 8개 스타일 섹션 제공
|
|
* - onConfigChange: Draft 모드 — 모달에서 저장 전 로컬 변경용
|
|
*/
|
|
|
|
import { useMemo, useCallback, useState } 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 type { ComponentConfig, CardLayoutConfig } from "@/types/report";
|
|
import { CardLayoutTabs } from "../modals/CardLayoutTabs";
|
|
|
|
interface Props {
|
|
component: ComponentConfig;
|
|
section?: "style" | "data";
|
|
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
|
}
|
|
|
|
const generateId = () =>
|
|
`row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
const DEFAULT_CONFIG: CardLayoutConfig = {
|
|
tableName: "",
|
|
primaryKey: "",
|
|
rows: [{ id: generateId(), gridColumns: 2, elements: [] }],
|
|
padding: "12px",
|
|
gap: "8px",
|
|
borderStyle: "solid",
|
|
borderColor: "#e5e7eb",
|
|
backgroundColor: "#ffffff",
|
|
headerTitleFontSize: 14,
|
|
headerTitleColor: "#1e40af",
|
|
labelFontSize: 13,
|
|
labelColor: "#374151",
|
|
valueFontSize: 13,
|
|
valueColor: "#000000",
|
|
dividerThickness: 1,
|
|
dividerColor: "#e5e7eb",
|
|
};
|
|
|
|
const CARD_STYLE_PRESETS = {
|
|
info: {
|
|
backgroundColor: "#ffffff",
|
|
borderStyle: "solid",
|
|
borderColor: "#e5e7eb",
|
|
borderWidth: 1,
|
|
accentBorderWidth: 0,
|
|
borderRadius: "12px",
|
|
headerFontWeight: "bold" as const,
|
|
headerTitleColor: "#111827",
|
|
labelColor: "#6b7280",
|
|
valueFontWeight: "normal" as const,
|
|
valueColor: "#111827",
|
|
dividerColor: "#3b82f6",
|
|
dividerThickness: 1,
|
|
},
|
|
compact: {
|
|
backgroundColor: "#eff6ff",
|
|
borderStyle: "none",
|
|
borderWidth: 0,
|
|
accentBorderColor: "#3b82f6",
|
|
accentBorderWidth: 4,
|
|
borderRadius: "8px",
|
|
headerFontWeight: "normal" as const,
|
|
headerTitleColor: "#6b7280",
|
|
labelColor: "#6b7280",
|
|
valueFontWeight: "bold" as const,
|
|
valueColor: "#111827",
|
|
dividerColor: "#e5e7eb",
|
|
dividerThickness: 1,
|
|
},
|
|
} as const;
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function CardProperties({ component, section, onConfigChange }: Props) {
|
|
const { updateComponent } = useReportDesigner();
|
|
|
|
const showStyle = !section || section === "style";
|
|
const showData = !section || section === "data";
|
|
|
|
const [openStyleSections, setOpenStyleSections] = useState<Set<string>>(new Set(["preset"]));
|
|
const toggleStyleSection = (id: string) => {
|
|
setOpenStyleSections((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const config = useMemo(() => {
|
|
return component.cardLayoutConfig || DEFAULT_CONFIG;
|
|
}, [component.cardLayoutConfig]);
|
|
|
|
const handleConfigChange = useCallback(
|
|
(newConfig: CardLayoutConfig) => {
|
|
const updates = { cardLayoutConfig: newConfig };
|
|
if (onConfigChange) onConfigChange(updates);
|
|
else updateComponent(component.id, updates);
|
|
},
|
|
[component.id, onConfigChange, updateComponent],
|
|
);
|
|
|
|
const updateDesignConfig = useCallback(
|
|
(updates: Partial<CardLayoutConfig>) => {
|
|
handleConfigChange({ ...config, ...updates });
|
|
},
|
|
[config, handleConfigChange],
|
|
);
|
|
|
|
const applyPreset = useCallback(
|
|
(presetKey: keyof typeof CARD_STYLE_PRESETS) => {
|
|
updateDesignConfig(CARD_STYLE_PRESETS[presetKey]);
|
|
},
|
|
[updateDesignConfig],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{showData && (
|
|
<CardLayoutTabs config={config} onConfigChange={handleConfigChange} component={component} onComponentChange={onConfigChange} />
|
|
)}
|
|
|
|
{showStyle && (
|
|
<div className="mt-2 rounded-lg border border-gray-200 overflow-hidden">
|
|
<StyleAccordion label="프리셋" isOpen={openStyleSections.has("preset")} onToggle={() => toggleStyleSection("preset")}>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => applyPreset("info")} className="text-xs h-8">인포 카드</Button>
|
|
<Button variant="outline" size="sm" onClick={() => applyPreset("compact")} className="text-xs h-8">컴팩트 카드</Button>
|
|
<Button variant="ghost" size="sm" className="text-xs h-8 text-muted-foreground" disabled>커스텀</Button>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
<StyleAccordion label="카드 외형" isOpen={openStyleSections.has("appearance")} onToggle={() => toggleStyleSection("appearance")}>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
|
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
|
<input type="color" value={config.backgroundColor || "#ffffff"} onChange={(e) => updateDesignConfig({ backgroundColor: e.target.value })} className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0" />
|
|
<Input value={config.backgroundColor || "#ffffff"} onChange={(e) => updateDesignConfig({ backgroundColor: e.target.value })} className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">모서리</Label>
|
|
<Select value={config.borderRadius || "0"} onValueChange={(v) => updateDesignConfig({ borderRadius: v })}>
|
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0">없음</SelectItem>
|
|
<SelectItem value="8px">보통</SelectItem>
|
|
<SelectItem value="20px">둥글게</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">안쪽 여백</Label>
|
|
<Select value={config.padding || "12px"} onValueChange={(v) => updateDesignConfig({ padding: v })}>
|
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="2px">보통</SelectItem>
|
|
<SelectItem value="14px">넓게</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">행 간격</Label>
|
|
<Select value={config.gap || "8px"} onValueChange={(v) => updateDesignConfig({ gap: v })}>
|
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="1px">보통</SelectItem>
|
|
<SelectItem value="10px">넓게</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
<StyleAccordion label="좌측 액센트 보더" isOpen={openStyleSections.has("accent")} onToggle={() => toggleStyleSection("accent")}>
|
|
<div className="space-y-3">
|
|
<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={(config.accentBorderWidth ?? 0) > 0} onCheckedChange={(checked) => updateDesignConfig({ accentBorderWidth: checked ? 4 : 0, accentBorderColor: config.accentBorderColor || "#3b82f6" })} />
|
|
</div>
|
|
{(config.accentBorderWidth ?? 0) > 0 && (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">두께</Label>
|
|
<Input type="number" min={1} max={10} value={config.accentBorderWidth || 4} onChange={(e) => updateDesignConfig({ accentBorderWidth: parseInt(e.target.value) || 4 })} className="h-9 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
|
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
|
<input type="color" value={config.accentBorderColor || "#3b82f6"} onChange={(e) => updateDesignConfig({ accentBorderColor: e.target.value })} className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0" />
|
|
<Input value={config.accentBorderColor || "#3b82f6"} onChange={(e) => updateDesignConfig({ accentBorderColor: e.target.value })} className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|