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

450 lines
16 KiB
TypeScript

"use client";
/**
* TextProperties.tsx — 텍스트/레이블 컴포넌트 설정
*
* - section="data": TextLayoutTabs (데이터 바인딩 / 텍스트 서식 / 표시 조건)
* - 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronRight, Bold, Italic, Underline, Strikethrough, AlignLeft, AlignCenter, AlignRight } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { TextLayoutTabs } from "../modals/TextLayoutTabs";
import type { ComponentConfig } from "@/types/report";
const FONT_FAMILIES = [
"Malgun Gothic",
"NanumGothic",
"NanumMyeongjo",
"굴림",
"돋움",
"바탕",
"Times New Roman",
"Arial",
];
const TEXT_STYLE_PRESETS = {
title: {
label: "제목",
fontSize: 24,
fontWeight: "bold",
fontColor: "#111827",
lineHeight: 1.3,
},
subtitle: {
label: "부제목",
fontSize: 16,
fontWeight: "600",
fontColor: "#374151",
lineHeight: 1.4,
},
body: {
label: "본문",
fontSize: 12,
fontWeight: "normal",
fontColor: "#374151",
lineHeight: 1.6,
},
caption: {
label: "캡션",
fontSize: 10,
fontWeight: "normal",
fontColor: "#6b7280",
fontStyle: "italic" as const,
lineHeight: 1.4,
},
header: {
label: "헤더",
fontSize: 14,
fontWeight: "bold",
fontColor: "#1e40af",
lineHeight: 1.5,
backgroundColor: "#eff6ff",
borderWidth: 1,
borderColor: "#bfdbfe",
padding: 8,
},
footer: {
label: "푸터",
fontSize: 9,
fontWeight: "normal",
fontColor: "#9ca3af",
lineHeight: 1.4,
},
} as const;
interface Props {
component: ComponentConfig;
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
section?: "style" | "data";
}
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>
);
}
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>
);
}
function ToggleBtn({
active,
onClick,
children,
title,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
title?: string;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className={`flex h-8 w-8 items-center justify-center rounded-md border transition-colors ${
active
? "border-blue-300 bg-blue-50 text-blue-700"
: "border-gray-200 bg-white text-gray-500 hover:bg-gray-50"
}`}
>
{children}
</button>
);
}
export function TextProperties({ 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 TEXT_STYLE_PRESETS) => {
const { label, ...values } = TEXT_STYLE_PRESETS[key];
update({ ...values, textPreset: key });
},
[update],
);
return (
<>
{/* 모달(section="data")에서 표시: TextLayoutTabs 3탭 구조 */}
{showData && <TextLayoutTabs component={component} />}
{/* 우측 패널(section="style")에서 표시: StyleAccordion 패턴 */}
{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-3 gap-2">
{(Object.entries(TEXT_STYLE_PRESETS) as [keyof typeof TEXT_STYLE_PRESETS, typeof TEXT_STYLE_PRESETS[keyof typeof TEXT_STYLE_PRESETS]][]).map(
([key, preset]) => (
<button
key={key}
onClick={() => applyPreset(key)}
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-[10px] font-medium transition-all hover:border-blue-300 hover:bg-blue-50/50 ${
component.textPreset === key
? "border-blue-400 bg-blue-50 text-blue-700"
: "border-gray-200 bg-white text-gray-700"
}`}
>
<span
className="flex w-full items-center justify-center rounded px-1 py-0.5"
style={{
fontSize: `${Math.min(preset.fontSize, 16)}px`,
fontWeight: preset.fontWeight,
color: preset.fontColor,
lineHeight: "1.2",
backgroundColor: ("backgroundColor" in preset) ? (preset as Record<string, unknown>).backgroundColor as string : undefined,
}}
>
Aa
</span>
{preset.label}
</button>
),
)}
</div>
</StyleAccordion>
{/* 폰트 */}
<StyleAccordion label="폰트" isOpen={openSections.has("font")} onToggle={() => toggleSection("font")}>
<div className="space-y-3">
<div>
<Label className="mb-1.5 block text-xs text-gray-500"> </Label>
<Select
value={component.fontFamily || "Malgun Gothic"}
onValueChange={(v) => update({ fontFamily: v })}
>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{FONT_FAMILIES.map((f) => (
<SelectItem key={f} value={f} style={{ fontFamily: f }}>{f}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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={6}
max={120}
value={component.fontSize || 12}
onChange={(e) => update({ fontSize: parseInt(e.target.value) || 12 })}
className="h-9 text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs text-gray-500"> </Label>
<Select
value={String(component.lineHeight || 1.5)}
onValueChange={(v) => update({ lineHeight: parseFloat(v) })}
>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1.0</SelectItem>
<SelectItem value="1.2">1.2</SelectItem>
<SelectItem value="1.5">1.5</SelectItem>
<SelectItem value="1.8">1.8</SelectItem>
<SelectItem value="2">2.0</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="mb-1.5 block text-xs text-gray-500"></Label>
<div className="flex gap-1.5">
<ToggleBtn
active={component.fontWeight === "bold" || component.fontWeight === "700"}
onClick={() => update({ fontWeight: component.fontWeight === "bold" || component.fontWeight === "700" ? "normal" : "bold" })}
title="굵게"
>
<Bold className="h-3.5 w-3.5" />
</ToggleBtn>
<ToggleBtn
active={component.fontStyle === "italic"}
onClick={() => update({ fontStyle: component.fontStyle === "italic" ? "normal" : "italic" })}
title="기울임"
>
<Italic className="h-3.5 w-3.5" />
</ToggleBtn>
<ToggleBtn
active={component.textDecoration === "underline"}
onClick={() => update({ textDecoration: component.textDecoration === "underline" ? "none" : "underline" })}
title="밑줄"
>
<Underline className="h-3.5 w-3.5" />
</ToggleBtn>
<ToggleBtn
active={component.textDecoration === "line-through"}
onClick={() => update({ textDecoration: component.textDecoration === "line-through" ? "none" : "line-through" })}
title="취소선"
>
<Strikethrough className="h-3.5 w-3.5" />
</ToggleBtn>
</div>
</div>
</div>
</StyleAccordion>
{/* 색상 */}
<StyleAccordion label="색상" isOpen={openSections.has("color")} onToggle={() => toggleSection("color")}>
<div className="space-y-3">
<div>
<Label className="mb-1.5 block text-xs text-gray-500"> </Label>
<ColorInput
value={component.fontColor || "#000000"}
onChange={(v) => update({ fontColor: v })}
/>
</div>
<div>
<Label className="mb-1.5 block text-xs text-gray-500"></Label>
<div className="flex items-center gap-2">
<ColorInput
value={component.backgroundColor || "#ffffff"}
onChange={(v) => update({ backgroundColor: v })}
/>
<Button
variant="ghost"
size="sm"
className="h-8 shrink-0 text-xs text-gray-400"
onClick={() => update({ backgroundColor: undefined })}
>
</Button>
</div>
</div>
</div>
</StyleAccordion>
{/* 정렬 & 간격 */}
<StyleAccordion label="정렬 & 간격" isOpen={openSections.has("align")} onToggle={() => toggleSection("align")}>
<div className="space-y-3">
<div>
<Label className="mb-1.5 block text-xs text-gray-500"> </Label>
<div className="flex gap-1.5">
<ToggleBtn active={(component.textAlign || "left") === "left"} onClick={() => update({ textAlign: "left" })} title="왼쪽">
<AlignLeft className="h-3.5 w-3.5" />
</ToggleBtn>
<ToggleBtn active={component.textAlign === "center"} onClick={() => update({ textAlign: "center" })} title="가운데">
<AlignCenter className="h-3.5 w-3.5" />
</ToggleBtn>
<ToggleBtn active={component.textAlign === "right"} onClick={() => update({ textAlign: "right" })} title="오른쪽">
<AlignRight className="h-3.5 w-3.5" />
</ToggleBtn>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="mb-1.5 block text-xs text-gray-500"> (px)</Label>
<Input
type="number"
min={-5}
max={20}
step={0.5}
value={component.letterSpacing ?? 0}
onChange={(e) => update({ letterSpacing: parseFloat(e.target.value) || 0 })}
className="h-9 text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs text-gray-500"> </Label>
<Input
type="number"
min={0}
max={50}
value={component.padding ?? 0}
onChange={(e) => update({ padding: parseInt(e.target.value) || 0 })}
className="h-9 text-xs"
/>
</div>
</div>
</div>
</StyleAccordion>
{/* 테두리 */}
<StyleAccordion label="테두리" isOpen={openSections.has("border")} onToggle={() => toggleSection("border")}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="mb-1.5 block text-xs text-gray-500"></Label>
<Select
value={String(component.borderWidth ?? 0)}
onValueChange={(v) => update({ borderWidth: parseInt(v) })}
>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="1">1px</SelectItem>
<SelectItem value="2">2px</SelectItem>
<SelectItem value="3">3px</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-xs text-gray-500"></Label>
<Select
value={String(component.borderRadius ?? 0)}
onValueChange={(v) => update({ borderRadius: parseInt(v) })}
>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="4">4px</SelectItem>
<SelectItem value="8">8px</SelectItem>
<SelectItem value="12">12px</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{(component.borderWidth ?? 0) > 0 && (
<div>
<Label className="mb-1.5 block text-xs text-gray-500"></Label>
<ColorInput
value={component.borderColor || "#d1d5db"}
onChange={(v) => update({ borderColor: v })}
/>
</div>
)}
</div>
</StyleAccordion>
</div>
)}
</>
);
}