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

607 lines
23 KiB
TypeScript

"use client";
/**
* ComponentStylePanel.tsx — 우측 패널 전용 디자인 설정 컨테이너
*
* 컴포넌트 타입별 전용 디자인 아코디언이 최상단에 동적 생성되고,
* 공통 속성(배치/글꼴/배경/테두리)이 그 아래에 위치한다.
*/
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import {
ChevronRight,
Move,
Type as TypeIcon,
Square,
CreditCard,
Table2,
ImageIcon,
Minus,
PenTool,
Stamp,
Hash,
QrCode,
CheckSquare,
Tag,
BarChart3,
Lock,
Unlock,
Layers,
ArrowUpToLine,
ArrowDownToLine,
ArrowUp,
ArrowDown,
Group,
Ungroup,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ComponentConfig } from "@/types/report";
import { CardProperties } from "./CardProperties";
import { DividerProperties } from "./DividerProperties";
import { ImageProperties } from "./ImageProperties";
import { TableProperties } from "./TableProperties";
import { TextProperties } from "./TextProperties";
const TYPE_LABELS: Record<string, string> = {
text: "텍스트",
label: "레이블",
table: "테이블",
image: "이미지",
divider: "구분선",
signature: "서명",
stamp: "도장",
pageNumber: "페이지 번호",
card: "카드",
calculation: "계산",
barcode: "바코드",
checkbox: "체크박스",
};
const TYPE_ICONS: Record<string, React.ReactNode> = {
text: <TypeIcon className="h-4 w-4" />,
label: <Tag className="h-4 w-4" />,
table: <Table2 className="h-4 w-4" />,
image: <ImageIcon className="h-4 w-4" />,
divider: <Minus className="h-4 w-4" />,
signature: <PenTool className="h-4 w-4" />,
stamp: <Stamp className="h-4 w-4" />,
pageNumber: <Hash className="h-4 w-4" />,
card: <CreditCard className="h-4 w-4" />,
calculation: <BarChart3 className="h-4 w-4" />,
barcode: <QrCode className="h-4 w-4" />,
checkbox: <CheckSquare className="h-4 w-4" />,
};
interface AccordionSectionProps {
id: string;
label: string;
icon: React.ReactNode;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function AccordionSection({ label, icon, isExpanded, onToggle, children }: AccordionSectionProps) {
return (
<div className="border-b border-gray-100">
<button
onClick={onToggle}
className={`flex h-11 w-full items-center justify-between px-4 transition-colors ${
isExpanded
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
: "bg-white text-gray-900 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2.5">
<span className={isExpanded ? "" : "text-blue-600"}>{icon}</span>
<span className="text-sm font-bold">{label}</span>
</div>
<ChevronRight
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-90" : "text-gray-400"}`}
/>
</button>
{isExpanded && (
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-4">
{children}
</div>
)}
</div>
);
}
function TypeSpecificDesign({
component,
update,
}: {
component: ComponentConfig;
update: (changes: Partial<ComponentConfig>) => void;
}) {
const type = component.type;
if (type === "text" || type === "label") {
return (
<TextProperties component={component} section="style" />
);
}
if (type === "table") {
return (
<TableProperties component={component} section="style" />
);
}
if (type === "image") {
return (
<ImageProperties component={component} section="style" />
);
}
if (type === "divider") {
return (
<DividerProperties component={component} section="style" />
);
}
if (type === "signature" || type === "stamp") {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<Select value={component.objectFit || "contain"} onValueChange={(v) => update({ objectFit: v as ComponentConfig["objectFit"] })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="contain"> </SelectItem>
<SelectItem value="cover"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={component.labelPosition || "left"} onValueChange={(v) => update({ labelPosition: v as ComponentConfig["labelPosition"] })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" checked={component.showLabel !== false} onChange={(e) => update({ showLabel: e.target.checked })} className="h-4 w-4 rounded" />
<Label className="text-xs"> </Label>
</div>
{component.showLabel !== false && (
<div>
<Label className="text-xs"> </Label>
<Input value={component.labelText || (type === "stamp" ? "(인)" : "서명:")} onChange={(e) => update({ labelText: e.target.value })} className="h-9 text-sm" />
</div>
)}
</div>
);
}
if (type === "pageNumber") {
return (
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select value={component.pageNumberFormat || "number"} onValueChange={(v) => update({ pageNumberFormat: v as ComponentConfig["pageNumberFormat"] })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="number">1</SelectItem>
<SelectItem value="numberTotal">1 / 3</SelectItem>
<SelectItem value="koreanNumber">1 </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<Select value={component.textAlign || "center"} onValueChange={(v) => update({ textAlign: v })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={component.fontWeight || "normal"} onValueChange={(v) => update({ fontWeight: v })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal"></SelectItem>
<SelectItem value="bold"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
if (type === "calculation") {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<Select value={component.numberFormat || "currency"} onValueChange={(v) => update({ numberFormat: v as ComponentConfig["numberFormat"] })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="comma"> (1,000)</SelectItem>
<SelectItem value="currency"></SelectItem>
</SelectContent>
</Select>
</div>
{component.numberFormat === "currency" && (
<div>
<Label className="text-xs"> </Label>
<Input value={component.currencySuffix || "원"} onChange={(e) => update({ currencySuffix: e.target.value })} className="h-9 text-sm" />
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2">
<input type="color" value={component.resultColor || "#2563eb"} onChange={(e) => update({ resultColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
<Input value={component.resultColor || "#2563eb"} onChange={(e) => update({ resultColor: e.target.value })} className="h-9 text-sm flex-1" />
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<Input type="number" min={10} max={30} value={component.resultFontSize || 16} onChange={(e) => update({ resultFontSize: parseInt(e.target.value) || 16 })} className="h-9 text-sm" />
</div>
</div>
</div>
);
}
if (type === "barcode") {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2">
<input type="color" value={component.barcodeColor || "#000000"} onChange={(e) => update({ barcodeColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2">
<input type="color" value={component.barcodeBackground || "#ffffff"} onChange={(e) => update({ barcodeBackground: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" checked={component.showBarcodeText !== false} onChange={(e) => update({ showBarcodeText: e.target.checked })} className="h-4 w-4 rounded" />
<Label className="text-xs"> </Label>
</div>
<div>
<Label className="text-xs"> (px)</Label>
<Input type="number" min={0} max={30} value={component.barcodeMargin ?? 10} onChange={(e) => update({ barcodeMargin: parseInt(e.target.value) || 0 })} className="h-9 text-sm" />
</div>
</div>
);
}
if (type === "checkbox") {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2">
<input type="color" value={component.checkboxColor || "#2563eb"} onChange={(e) => update({ checkboxColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
<Input value={component.checkboxColor || "#2563eb"} onChange={(e) => update({ checkboxColor: e.target.value })} className="h-9 text-sm flex-1" />
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2">
<input type="color" value={component.checkboxBorderColor || "#6b7280"} onChange={(e) => update({ checkboxBorderColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> (px)</Label>
<Input type="number" min={12} max={40} value={component.checkboxSize || 18} onChange={(e) => update({ checkboxSize: parseInt(e.target.value) || 18 })} className="h-9 text-sm" />
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={component.checkboxLabelPosition || "right"} onValueChange={(v) => update({ checkboxLabelPosition: v as "left" | "right" })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
if (type === "statusBadge") {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground"> () .</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<Select value={component.textAlign || "center"} onValueChange={(v) => update({ textAlign: v })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={component.fontWeight || "bold"} onValueChange={(v) => update({ fontWeight: v })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal"></SelectItem>
<SelectItem value="bold"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
return null;
}
export function ComponentStylePanel() {
const {
selectedComponentId,
selectedComponentIds,
components,
updateComponent,
bringToFront,
sendToBack,
bringForward,
sendBackward,
toggleLock,
groupComponents,
ungroupComponents,
} = useReportDesigner();
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(["typeDesign"]));
const component =
components.find((c) => c.id === selectedComponentId) ?? null;
if (!component) return null;
const typeLabel = TYPE_LABELS[component.type] ?? component.type;
const update = (changes: Partial<ComponentConfig>) =>
updateComponent(component.id, changes);
const toggleSection = (id: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const hasTypeDesign = component.type !== "card";
const typeIcon = TYPE_ICONS[component.type] || <Square className="h-4 w-4" />;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto">
{/* 카드 전용: CardProperties 하위 아코디언 포함 */}
{component.type === "card" && (
<AccordionSection
id="cardDesign"
label="카드 디자인"
icon={<CreditCard className="h-4 w-4" />}
isExpanded={expandedSections.has("cardDesign")}
onToggle={() => toggleSection("cardDesign")}
>
<CardProperties component={component} section="style" />
</AccordionSection>
)}
{/* 카드 외 모든 타입: 타입별 전용 디자인 */}
{hasTypeDesign && (
<AccordionSection
id="typeDesign"
label={`${typeLabel} 디자인`}
icon={typeIcon}
isExpanded={expandedSections.has("typeDesign")}
onToggle={() => toggleSection("typeDesign")}
>
<TypeSpecificDesign component={component} update={update} />
</AccordionSection>
)}
{/* 배치 (X/Y/W/H) */}
<AccordionSection
id="layout"
label="배치"
icon={<Move className="h-4 w-4" />}
isExpanded={expandedSections.has("layout")}
onToggle={() => toggleSection("layout")}
>
<div className="grid grid-cols-2 gap-2">
{([
{ label: "X", key: "x" as const, min: undefined },
{ label: "Y", key: "y" as const, min: undefined },
{ label: "너비", key: "width" as const, min: 50 },
{ label: "높이", key: "height" as const, min: 30 },
] as const).map(({ label, key, min }) => (
<div key={key}>
<Label className="text-xs">{label}</Label>
<Input
type="number"
value={Math.round(component[key] as number)}
onChange={(e) =>
update({ [key]: parseInt(e.target.value) || min || 0 })
}
className="h-9 text-sm"
/>
</div>
))}
</div>
</AccordionSection>
{/* 잠금 */}
<AccordionSection
id="lock"
label="잠금"
icon={<Lock className="h-4 w-4" />}
isExpanded={expandedSections.has("lock")}
onToggle={() => toggleSection("lock")}
>
<div className="space-y-2">
<Button
variant={component.locked ? "destructive" : "outline"}
size="sm"
className="w-full h-9 gap-2 text-sm"
onClick={() => toggleLock()}
>
{component.locked ? (
<>
<Lock className="h-3.5 w-3.5" />
</>
) : (
<>
<Unlock className="h-3.5 w-3.5" />
</>
)}
</Button>
<p className="text-[10px] text-muted-foreground">
/
</p>
</div>
</AccordionSection>
{/* 레이어 */}
<AccordionSection
id="layer"
label="레이어"
icon={<Layers className="h-4 w-4" />}
isExpanded={expandedSections.has("layer")}
onToggle={() => toggleSection("layer")}
>
<div className="grid grid-cols-2 gap-1.5">
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => bringToFront()} title="맨 앞으로">
<ArrowUpToLine className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => sendToBack()} title="맨 뒤로">
<ArrowDownToLine className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => bringForward()} title="한 단계 앞으로">
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => sendBackward()} title="한 단계 뒤로">
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</div>
</AccordionSection>
{/* 그룹 */}
<AccordionSection
id="group"
label={`그룹${component.groupId ? " (그룹됨)" : ""}`}
icon={<Group className="h-4 w-4" />}
isExpanded={expandedSections.has("group")}
onToggle={() => toggleSection("group")}
>
{component.groupId ? (
<div className="space-y-2.5">
<div className="rounded-md border border-purple-200 bg-purple-50 px-3 py-2">
<p className="text-xs text-purple-700">
.
</p>
<p className="text-[10px] text-purple-500 mt-1">
: {components.filter((c) => c.groupId === component.groupId).length}
</p>
</div>
<Button
variant="outline"
size="sm"
className="w-full h-8 gap-1.5 text-xs border-purple-300 text-purple-700 hover:bg-purple-50"
onClick={() => ungroupComponents()}
>
<Ungroup className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="space-y-2">
<Button
variant="outline"
size="sm"
className="w-full h-8 gap-1.5 text-xs"
onClick={() => groupComponents()}
disabled={selectedComponentIds.length < 2}
>
<Group className="h-3.5 w-3.5" />
</Button>
<p className="text-[10px] text-muted-foreground">
{selectedComponentIds.length < 2
? "드래그 또는 Ctrl+클릭으로 2개 이상 선택 후 그룹화"
: `${selectedComponentIds.length}개 선택됨 - 그룹화 가능`}
</p>
</div>
)}
</AccordionSection>
{/* 테두리 */}
<AccordionSection
id="border"
label="테두리"
icon={<Square className="h-4 w-4" />}
isExpanded={expandedSections.has("border")}
onToggle={() => toggleSection("border")}
>
<div>
<Label className="text-xs"></Label>
<div className="flex gap-2 mt-1">
<button onClick={() => update({ borderRadius: 0 })} className={`flex-1 h-9 rounded-md border text-xs font-medium transition-colors ${!component.borderRadius || component.borderRadius === 0 ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"}`}></button>
<button onClick={() => update({ borderRadius: 16 })} className={`flex-1 h-9 rounded-md border text-xs font-medium transition-colors ${component.borderRadius && component.borderRadius > 0 ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"}`}></button>
</div>
</div>
</AccordionSection>
<div className="h-6 shrink-0" />
</div>
</div>
);
}