레포트관리에 그리드 시스템 1차 적용(2차적인 개선 필요)

This commit is contained in:
dohyeons
2025-10-13 15:08:31 +09:00
parent 71eb308bba
commit 32024a6d70
9 changed files with 1341 additions and 453 deletions

View File

@@ -0,0 +1,48 @@
"use client";
import { GridConfig } from "@/types/report";
interface GridLayerProps {
gridConfig: GridConfig;
pageWidth: number;
pageHeight: number;
}
export function GridLayer({ gridConfig, pageWidth, pageHeight }: GridLayerProps) {
if (!gridConfig.visible) return null;
const { cellWidth, cellHeight, columns, rows, gridColor, gridOpacity } = gridConfig;
// SVG로 그리드 선 렌더링
return (
<svg className="pointer-events-none absolute inset-0" width={pageWidth} height={pageHeight} style={{ zIndex: 0 }}>
{/* 세로 선 */}
{Array.from({ length: columns + 1 }).map((_, i) => (
<line
key={`v-${i}`}
x1={i * cellWidth}
y1={0}
x2={i * cellWidth}
y2={pageHeight}
stroke={gridColor}
strokeOpacity={gridOpacity}
strokeWidth={1}
/>
))}
{/* 가로 선 */}
{Array.from({ length: rows + 1 }).map((_, i) => (
<line
key={`h-${i}`}
x1={0}
y1={i * cellHeight}
x2={pageWidth}
y2={i * cellHeight}
stroke={gridColor}
strokeOpacity={gridOpacity}
strokeWidth={1}
/>
))}
</svg>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
export function GridSettingsPanel() {
const { gridConfig, updateGridConfig } = useReportDesigner();
return (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 그리드 표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch checked={gridConfig.visible} onCheckedChange={(visible) => updateGridConfig({ visible })} />
</div>
{/* 스냅 활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch checked={gridConfig.snapToGrid} onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })} />
</div>
{/* 프리셋 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
onValueChange={(value) => {
const presets: Record<string, { cellWidth: number; cellHeight: number }> = {
fine: { cellWidth: 10, cellHeight: 10 },
medium: { cellWidth: 20, cellHeight: 20 },
coarse: { cellWidth: 50, cellHeight: 50 },
};
updateGridConfig(presets[value]);
}}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="그리드 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fine"> (10x10)</SelectItem>
<SelectItem value="medium"> (20x20)</SelectItem>
<SelectItem value="coarse"> (50x50)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 셀 너비 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<span className="text-xs text-gray-500">{gridConfig.cellWidth}px</span>
</div>
<Slider
value={[gridConfig.cellWidth]}
onValueChange={([value]) => updateGridConfig({ cellWidth: value })}
min={5}
max={100}
step={5}
className="w-full"
/>
</div>
{/* 셀 높이 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<span className="text-xs text-gray-500">{gridConfig.cellHeight}px</span>
</div>
<Slider
value={[gridConfig.cellHeight]}
onValueChange={([value]) => updateGridConfig({ cellHeight: value })}
min={5}
max={100}
step={5}
className="w-full"
/>
</div>
{/* 그리드 투명도 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<span className="text-xs text-gray-500">{Math.round(gridConfig.gridOpacity * 100)}%</span>
</div>
<Slider
value={[gridConfig.gridOpacity * 100]}
onValueChange={([value]) => updateGridConfig({ gridOpacity: value / 100 })}
min={10}
max={100}
step={10}
className="w-full"
/>
</div>
{/* 그리드 색상 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
type="color"
value={gridConfig.gridColor}
onChange={(e) => updateGridConfig({ gridColor: e.target.value })}
className="h-8 w-16 cursor-pointer"
/>
<Input
type="text"
value={gridConfig.gridColor}
onChange={(e) => updateGridConfig({ gridColor: e.target.value })}
className="h-8 flex-1 font-mono text-xs"
placeholder="#e5e7eb"
/>
</div>
</div>
{/* 그리드 정보 */}
<div className="rounded border bg-gray-50 p-2 text-xs text-gray-600">
<div className="flex justify-between">
<span>:</span>
<span className="font-mono">{gridConfig.rows}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-mono">{gridConfig.columns}</span>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -6,6 +6,7 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig } from "@/types/report";
import { CanvasComponent } from "./CanvasComponent";
import { Ruler } from "./Ruler";
import { GridLayer } from "./GridLayer";
import { v4 as uuidv4 } from "uuid";
export function ReportDesignerCanvas() {
@@ -32,6 +33,7 @@ export function ReportDesignerCanvas() {
undo,
redo,
showRuler,
gridConfig,
} = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({
@@ -331,16 +333,16 @@ export function ReportDesignerCanvas() {
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
backgroundImage: showGrid
? `
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
}}
onClick={handleCanvasClick}
>
{/* 그리드 레이어 */}
<GridLayer
gridConfig={gridConfig}
pageWidth={canvasWidth * 3.7795} // mm to px
pageHeight={canvasHeight * 3.7795}
/>
{/* 페이지 여백 가이드 */}
{currentPage && (
<div

View File

@@ -13,6 +13,7 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { QueryManager } from "./QueryManager";
import { SignaturePad } from "./SignaturePad";
import { SignatureGenerator } from "./SignatureGenerator";
import { GridSettingsPanel } from "./GridSettingsPanel";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
@@ -102,7 +103,7 @@ export function ReportDesignerRightPanel() {
<div className="w-[450px] border-l bg-white">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
<div className="border-b p-2">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="page" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
@@ -111,6 +112,10 @@ export function ReportDesignerRightPanel() {
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="grid" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="queries" className="gap-1 text-xs">
<Database className="h-3 w-3" />
@@ -1396,6 +1401,15 @@ export function ReportDesignerRightPanel() {
</TabsContent>
{/* 쿼리 탭 */}
{/* 그리드 탭 */}
<TabsContent value="grid" className="mt-0 h-[calc(100vh-120px)]">
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<GridSettingsPanel />
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
<QueryManager />
</TabsContent>

View File

@@ -13,7 +13,21 @@ import { Printer, FileDown, FileText } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { Document, Packer, Paragraph, TextRun, Table, TableCell, TableRow, WidthType } from "docx";
// @ts-ignore - docx 라이브러리 타입 이슈
import {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableCell,
TableRow,
WidthType,
ImageRun,
AlignmentType,
VerticalAlign,
convertInchesToTwip,
} from "docx";
import { getFullImageUrl } from "@/lib/api/client";
interface ReportPreviewModalProps {
@@ -268,82 +282,263 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
});
};
// Base64를 Uint8Array로 변환
const base64ToUint8Array = (base64: string): Uint8Array => {
const base64Data = base64.split(",")[1] || base64;
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
// 컴포넌트를 TableCell로 변환
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
const cellWidth = widthPercent || 100;
if (component.type === "text" || component.type === "label") {
const value = getComponentValue(component);
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: value,
size: (component.fontSize || 13) * 2,
color: component.fontColor?.replace("#", "") || "000000",
bold: component.fontWeight === "bold",
}),
],
alignment:
component.textAlign === "center"
? AlignmentType.CENTER
: component.textAlign === "right"
? AlignmentType.RIGHT
: AlignmentType.LEFT,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} else if (component.type === "signature" || component.type === "stamp") {
if (component.imageUrl) {
try {
const imageData = base64ToUint8Array(component.imageUrl);
return new TableCell({
children: [
new Paragraph({
children: [
new ImageRun({
data: imageData,
transformation: {
width: component.width || 150,
height: component.height || 50,
},
}),
],
alignment: AlignmentType.CENTER,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} catch {
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
size: 24,
}),
],
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
}
} else if (component.type === "table" && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
const headerCells = queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: field })],
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
}),
);
const dataRows = queryResult.rows.map(
(row) =>
new TableRow({
children: queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: String(row[field] ?? "") })],
}),
),
}),
);
const table = new Table({
rows: [new TableRow({ children: headerCells }), ...dataRows],
width: { size: 100, type: WidthType.PERCENTAGE },
});
return new TableCell({
children: [table],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
}
return null;
};
// WORD 다운로드
const handleDownloadWord = async () => {
setIsExporting(true);
try {
// 컴포넌트를 Paragraph로 변환
const paragraphs: (Paragraph | Table)[] = [];
// 모든 페이지의 컴포넌트 수집
const allComponents = layoutConfig.pages
// 페이지별로 섹션 생성
const sections = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.flatMap((page) => page.components);
.map((page) => {
// 페이지 크기 설정 (A4 기준)
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
const marginLeft = convertInchesToTwip(page.margins.left / 96);
const marginRight = convertInchesToTwip(page.margins.right / 96);
// Y 좌표로 정렬
const sortedComponents = [...allComponents].sort((a, b) => a.y - b.y);
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
const sortedComponents = [...page.components].sort((a, b) => {
// Y좌표 우선, 같으면 X좌표
if (Math.abs(a.y - b.y) < 5) {
return a.x - b.x;
}
return a.y - b.y;
});
for (const component of sortedComponents) {
if (component.type === "text" || component.type === "label") {
const value = getComponentValue(component);
paragraphs.push(
new Paragraph({
children: [
new TextRun({
text: value,
size: (component.fontSize || 13) * 2, // pt to half-pt
color: component.fontColor?.replace("#", "") || "000000",
bold: component.fontWeight === "bold",
}),
],
spacing: {
after: 200,
},
}),
);
} else if (component.type === "table" && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
// 테이블 헤더
const headerCells = queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: field })],
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
}),
);
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
const rowTolerance = 20; // Y 좌표 허용 오차
// 테이블 행
const dataRows = queryResult.rows.map(
(row) =>
new TableRow({
children: queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: String(row[field] ?? "") })],
}),
),
}),
);
const table = new Table({
rows: [new TableRow({ children: headerCells }), ...dataRows],
width: { size: 100, type: WidthType.PERCENTAGE },
});
paragraphs.push(table);
for (const component of sortedComponents) {
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
if (existingRow) {
existingRow.push(component);
} else {
rows.push([component]);
}
}
}
}
// 각 행 내에서 X좌표로 정렬
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
const tableRows: TableRow[] = [];
for (const row of rows) {
if (row.length === 1) {
// 단일 컴포넌트 - 전체 너비 사용
const component = row[0];
const cell = createTableCell(component, pageWidth);
if (cell) {
tableRows.push(
new TableRow({
children: [cell],
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
}),
);
}
} else {
// 여러 컴포넌트 - 가로 배치
const cells: TableCell[] = [];
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
for (const component of row) {
const widthPercent = (component.width / totalWidth) * 100;
const cell = createTableCell(component, pageWidth, widthPercent);
if (cell) {
cells.push(cell);
}
}
if (cells.length > 0) {
const maxHeight = Math.max(...row.map((c) => c.height));
tableRows.push(
new TableRow({
children: cells,
height: { value: maxHeight * 15, rule: 1 },
}),
);
}
}
}
return {
properties: {
page: {
width: pageWidth,
height: pageHeight,
margin: {
top: marginTop,
bottom: marginBottom,
left: marginLeft,
right: marginRight,
},
},
},
children:
tableRows.length > 0
? [
new Table({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
},
}),
]
: [new Paragraph({ text: "" })],
};
});
// 문서 생성
const doc = new Document({
sections: [
{
properties: {},
children: paragraphs,
},
],
sections,
});
// Blob 생성 및 다운로드