Merge pull request '수정사항 반영' (#313) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/313
This commit is contained in:
commit
ed2e0a1c6b
|
|
@ -30,6 +30,7 @@ import {
|
||||||
Header,
|
Header,
|
||||||
Footer,
|
Footer,
|
||||||
HeadingLevel,
|
HeadingLevel,
|
||||||
|
TableLayoutType,
|
||||||
} from "docx";
|
} from "docx";
|
||||||
import { WatermarkConfig } from "../types/report";
|
import { WatermarkConfig } from "../types/report";
|
||||||
import bwipjs from "bwip-js";
|
import bwipjs from "bwip-js";
|
||||||
|
|
@ -592,8 +593,12 @@ export class ReportController {
|
||||||
|
|
||||||
// mm를 twip으로 변환
|
// mm를 twip으로 변환
|
||||||
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
||||||
// px를 twip으로 변환 (1px = 15twip at 96DPI)
|
|
||||||
const pxToTwip = (px: number) => Math.round(px * 15);
|
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
|
||||||
|
const MM_TO_PX = 4;
|
||||||
|
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
|
||||||
|
// px를 twip으로 변환: px -> mm -> twip
|
||||||
|
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
|
||||||
|
|
||||||
// 쿼리 결과 맵
|
// 쿼리 결과 맵
|
||||||
const queryResultsMap: Record<
|
const queryResultsMap: Record<
|
||||||
|
|
@ -726,6 +731,9 @@ export class ReportController {
|
||||||
const base64Data =
|
const base64Data =
|
||||||
component.imageBase64.split(",")[1] || component.imageBase64;
|
component.imageBase64.split(",")[1] || component.imageBase64;
|
||||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||||
|
// 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정
|
||||||
|
const sigImageHeight = 30; // 고정 높이 (약 40px)
|
||||||
|
const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80;
|
||||||
result.push(
|
result.push(
|
||||||
new ParagraphRef({
|
new ParagraphRef({
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -733,8 +741,8 @@ export class ReportController {
|
||||||
new ImageRunRef({
|
new ImageRunRef({
|
||||||
data: imageBuffer,
|
data: imageBuffer,
|
||||||
transformation: {
|
transformation: {
|
||||||
width: Math.round(component.width * 0.75),
|
width: sigImageWidth,
|
||||||
height: Math.round(component.height * 0.75),
|
height: sigImageHeight,
|
||||||
},
|
},
|
||||||
type: "png",
|
type: "png",
|
||||||
}),
|
}),
|
||||||
|
|
@ -1443,7 +1451,11 @@ export class ReportController {
|
||||||
try {
|
try {
|
||||||
const barcodeType = component.barcodeType || "CODE128";
|
const barcodeType = component.barcodeType || "CODE128";
|
||||||
const barcodeColor = (component.barcodeColor || "#000000").replace("#", "");
|
const barcodeColor = (component.barcodeColor || "#000000").replace("#", "");
|
||||||
const barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", "");
|
// transparent는 bwip-js에서 지원하지 않으므로 흰색으로 변환
|
||||||
|
let barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", "");
|
||||||
|
if (barcodeBackground === "transparent" || barcodeBackground === "") {
|
||||||
|
barcodeBackground = "ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
// 바코드 값 결정 (쿼리 바인딩 또는 고정값)
|
// 바코드 값 결정 (쿼리 바인딩 또는 고정값)
|
||||||
let barcodeValue = component.barcodeValue || "SAMPLE123";
|
let barcodeValue = component.barcodeValue || "SAMPLE123";
|
||||||
|
|
@ -1739,6 +1751,7 @@ export class ReportController {
|
||||||
const rowTable = new Table({
|
const rowTable = new Table({
|
||||||
rows: [new TableRow({ children: cells })],
|
rows: [new TableRow({ children: cells })],
|
||||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||||
|
layout: TableLayoutType.FIXED, // 셀 너비 고정
|
||||||
borders: {
|
borders: {
|
||||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||||
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||||
|
|
@ -1821,6 +1834,7 @@ export class ReportController {
|
||||||
const textTable = new Table({
|
const textTable = new Table({
|
||||||
rows: [new TableRow({ children: [textCell] })],
|
rows: [new TableRow({ children: [textCell] })],
|
||||||
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
width: { size: pxToTwip(component.width), type: WidthType.DXA },
|
||||||
|
layout: TableLayoutType.FIXED, // 셀 너비 고정
|
||||||
indent: { size: indentLeft, type: WidthType.DXA },
|
indent: { size: indentLeft, type: WidthType.DXA },
|
||||||
borders: {
|
borders: {
|
||||||
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
||||||
|
|
@ -1970,6 +1984,10 @@ export class ReportController {
|
||||||
component.imageBase64.split(",")[1] || component.imageBase64;
|
component.imageBase64.split(",")[1] || component.imageBase64;
|
||||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||||
|
|
||||||
|
// 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정
|
||||||
|
const sigImageHeight = 30; // 고정 높이
|
||||||
|
const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80;
|
||||||
|
|
||||||
const paragraph = new Paragraph({
|
const paragraph = new Paragraph({
|
||||||
spacing: { before: spacingBefore, after: 0 },
|
spacing: { before: spacingBefore, after: 0 },
|
||||||
indent: { left: indentLeft },
|
indent: { left: indentLeft },
|
||||||
|
|
@ -1978,8 +1996,8 @@ export class ReportController {
|
||||||
new ImageRun({
|
new ImageRun({
|
||||||
data: imageBuffer,
|
data: imageBuffer,
|
||||||
transformation: {
|
transformation: {
|
||||||
width: Math.round(component.width * 0.75),
|
width: sigImageWidth,
|
||||||
height: Math.round(component.height * 0.75),
|
height: sigImageHeight,
|
||||||
},
|
},
|
||||||
type: "png",
|
type: "png",
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -234,10 +234,23 @@ export class ReportService {
|
||||||
`;
|
`;
|
||||||
const queries = await query<ReportQuery>(queriesQuery, [reportId]);
|
const queries = await query<ReportQuery>(queriesQuery, [reportId]);
|
||||||
|
|
||||||
|
// 메뉴 매핑 조회
|
||||||
|
const menuMappingQuery = `
|
||||||
|
SELECT menu_objid
|
||||||
|
FROM report_menu_mapping
|
||||||
|
WHERE report_id = $1
|
||||||
|
ORDER BY created_at
|
||||||
|
`;
|
||||||
|
const menuMappings = await query<{ menu_objid: number }>(menuMappingQuery, [
|
||||||
|
reportId,
|
||||||
|
]);
|
||||||
|
const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
report,
|
report,
|
||||||
layout,
|
layout,
|
||||||
queries: queries || [],
|
queries: queries || [],
|
||||||
|
menuObjids,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -696,6 +709,43 @@ export class ReportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 메뉴 매핑 저장 (있는 경우)
|
||||||
|
if (data.menuObjids !== undefined) {
|
||||||
|
// 기존 메뉴 매핑 모두 삭제
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM report_menu_mapping WHERE report_id = $1`,
|
||||||
|
[reportId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 메뉴 매핑 삽입
|
||||||
|
if (data.menuObjids.length > 0) {
|
||||||
|
// 리포트의 company_code 조회
|
||||||
|
const reportResult = await client.query(
|
||||||
|
`SELECT company_code FROM report_master WHERE report_id = $1`,
|
||||||
|
[reportId]
|
||||||
|
);
|
||||||
|
const companyCode = reportResult.rows[0]?.company_code || "*";
|
||||||
|
|
||||||
|
const insertMappingSql = `
|
||||||
|
INSERT INTO report_menu_mapping (
|
||||||
|
report_id,
|
||||||
|
menu_objid,
|
||||||
|
company_code,
|
||||||
|
created_by
|
||||||
|
) VALUES ($1, $2, $3, $4)
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const menuObjid of data.menuObjids) {
|
||||||
|
await client.query(insertMappingSql, [
|
||||||
|
reportId,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,12 @@ export interface ReportQuery {
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 상세 (마스터 + 레이아웃 + 쿼리)
|
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
|
||||||
export interface ReportDetail {
|
export interface ReportDetail {
|
||||||
report: ReportMaster;
|
report: ReportMaster;
|
||||||
layout: ReportLayout | null;
|
layout: ReportLayout | null;
|
||||||
queries: ReportQuery[];
|
queries: ReportQuery[];
|
||||||
|
menuObjids?: number[]; // 연결된 메뉴 ID 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 목록 조회 파라미터
|
// 리포트 목록 조회 파라미터
|
||||||
|
|
@ -166,6 +167,17 @@ export interface SaveLayoutRequest {
|
||||||
parameters: string[];
|
parameters: string[];
|
||||||
externalConnectionId?: number;
|
externalConnectionId?: number;
|
||||||
}>;
|
}>;
|
||||||
|
menuObjids?: number[]; // 연결할 메뉴 ID 목록
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리포트-메뉴 매핑
|
||||||
|
export interface ReportMenuMapping {
|
||||||
|
mapping_id: number;
|
||||||
|
report_id: string;
|
||||||
|
menu_objid: number;
|
||||||
|
company_code: string;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 템플릿 목록 응답
|
// 템플릿 목록 응답
|
||||||
|
|
|
||||||
|
|
@ -357,11 +357,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
height: snappedSize,
|
height: snappedSize,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Grid Snap 적용
|
// Grid Snap 적용
|
||||||
updateComponent(component.id, {
|
updateComponent(component.id, {
|
||||||
width: snapValueToGrid(boundedWidth),
|
width: snapValueToGrid(boundedWidth),
|
||||||
height: snapValueToGrid(boundedHeight),
|
height: snapValueToGrid(boundedHeight),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -444,17 +444,17 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
case "text":
|
case "text":
|
||||||
case "label":
|
case "label":
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${component.fontSize}px`,
|
fontSize: `${component.fontSize}px`,
|
||||||
color: component.fontColor,
|
color: component.fontColor,
|
||||||
fontWeight: component.fontWeight,
|
fontWeight: component.fontWeight,
|
||||||
textAlign: component.textAlign as "left" | "center" | "right",
|
textAlign: component.textAlign as "left" | "center" | "right",
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -534,7 +534,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
// 기본 테이블 (데이터 없을 때)
|
// 기본 테이블 (데이터 없을 때)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||||
쿼리를 연결하세요
|
쿼리를 연결하세요
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -606,7 +606,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
const sigLabelPos = component.labelPosition || "left";
|
const sigLabelPos = component.labelPosition || "left";
|
||||||
const sigShowLabel = component.showLabel !== false;
|
const sigShowLabel = component.showLabel !== false;
|
||||||
const sigLabelText = component.labelText || "서명:";
|
const sigLabelText = component.labelText || "서명:";
|
||||||
const sigShowUnderline = component.showUnderline !== false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
|
|
@ -653,14 +652,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
서명 이미지
|
서명 이미지
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sigShowUnderline && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 bottom-0 left-0"
|
|
||||||
style={{
|
|
||||||
borderBottom: "2px solid #000000",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -867,12 +858,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
|
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
|
||||||
const calculateResult = (): number => {
|
const calculateResult = (): number => {
|
||||||
if (calcItems.length === 0) return 0;
|
if (calcItems.length === 0) return 0;
|
||||||
|
|
||||||
// 첫 번째 항목은 기준값
|
// 첫 번째 항목은 기준값
|
||||||
let result = getCalcItemValue(
|
let result = getCalcItemValue(
|
||||||
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
|
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 두 번째 항목부터 연산자 적용
|
// 두 번째 항목부터 연산자 적용
|
||||||
for (let i = 1; i < calcItems.length; i++) {
|
for (let i = 1; i < calcItems.length; i++) {
|
||||||
const item = calcItems[i];
|
const item = calcItems[i];
|
||||||
|
|
@ -908,30 +899,30 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
item: { label: string; value: number | string; operator: string; fieldName?: string },
|
item: { label: string; value: number | string; operator: string; fieldName?: string },
|
||||||
index: number,
|
index: number,
|
||||||
) => {
|
) => {
|
||||||
const itemValue = getCalcItemValue(item);
|
const itemValue = getCalcItemValue(item);
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center justify-between py-1">
|
<div key={index} className="flex items-center justify-between py-1">
|
||||||
<span
|
<span
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
width: `${calcLabelWidth}px`,
|
width: `${calcLabelWidth}px`,
|
||||||
fontSize: `${calcLabelFontSize}px`,
|
fontSize: `${calcLabelFontSize}px`,
|
||||||
color: calcLabelColor,
|
color: calcLabelColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-right"
|
className="text-right"
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${calcValueFontSize}px`,
|
fontSize: `${calcValueFontSize}px`,
|
||||||
color: calcValueColor,
|
color: calcValueColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatNumber(itemValue)}
|
{formatNumber(itemValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Loader2, Search, ChevronRight, ChevronDown, FolderOpen, FileText } from "lucide-react";
|
||||||
|
import { menuApi } from "@/lib/api/menu";
|
||||||
|
import { MenuItem } from "@/types/menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MenuSelectModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (menuObjids: number[]) => void;
|
||||||
|
selectedMenuObjids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트리 구조의 메뉴 노드
|
||||||
|
interface MenuTreeNode {
|
||||||
|
objid: string;
|
||||||
|
menuNameKor: string;
|
||||||
|
menuUrl: string;
|
||||||
|
level: number;
|
||||||
|
children: MenuTreeNode[];
|
||||||
|
parentObjId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuSelectModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
selectedMenuObjids = [],
|
||||||
|
}: MenuSelectModalProps) {
|
||||||
|
const [menus, setMenus] = useState<MenuItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set(selectedMenuObjids));
|
||||||
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 초기 선택 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedIds(new Set(selectedMenuObjids));
|
||||||
|
}
|
||||||
|
}, [isOpen, selectedMenuObjids]);
|
||||||
|
|
||||||
|
// 메뉴 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchMenus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const fetchMenus = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await menuApi.getUserMenus();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setMenus(response.data);
|
||||||
|
// 처음 2레벨까지 자동 확장
|
||||||
|
const initialExpanded = new Set<string>();
|
||||||
|
response.data.forEach((menu) => {
|
||||||
|
const level = menu.lev || menu.LEV || 1;
|
||||||
|
if (level <= 2) {
|
||||||
|
initialExpanded.add(menu.objid || menu.OBJID || "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setExpandedIds(initialExpanded);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("메뉴 로드 오류:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메뉴 트리 구조 생성
|
||||||
|
const menuTree = useMemo(() => {
|
||||||
|
const menuMap = new Map<string, MenuTreeNode>();
|
||||||
|
const rootMenus: MenuTreeNode[] = [];
|
||||||
|
|
||||||
|
// 모든 메뉴를 노드로 변환
|
||||||
|
menus.forEach((menu) => {
|
||||||
|
const objid = menu.objid || menu.OBJID || "";
|
||||||
|
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
|
||||||
|
const menuNameKor = menu.menuNameKor || menu.MENU_NAME_KOR || menu.translated_name || menu.TRANSLATED_NAME || "";
|
||||||
|
const menuUrl = menu.menuUrl || menu.MENU_URL || "";
|
||||||
|
const level = menu.lev || menu.LEV || 1;
|
||||||
|
|
||||||
|
menuMap.set(objid, {
|
||||||
|
objid,
|
||||||
|
menuNameKor,
|
||||||
|
menuUrl,
|
||||||
|
level,
|
||||||
|
children: [],
|
||||||
|
parentObjId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 부모-자식 관계 설정
|
||||||
|
menus.forEach((menu) => {
|
||||||
|
const objid = menu.objid || menu.OBJID || "";
|
||||||
|
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
|
||||||
|
const node = menuMap.get(objid);
|
||||||
|
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
// 최상위 메뉴인지 확인 (parent가 없거나, 특정 루트 ID)
|
||||||
|
const parent = menuMap.get(parentObjId);
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
rootMenus.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자식 메뉴 정렬
|
||||||
|
const sortChildren = (nodes: MenuTreeNode[]) => {
|
||||||
|
nodes.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko"));
|
||||||
|
nodes.forEach((node) => sortChildren(node.children));
|
||||||
|
};
|
||||||
|
sortChildren(rootMenus);
|
||||||
|
|
||||||
|
return rootMenus;
|
||||||
|
}, [menus]);
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
const filteredTree = useMemo(() => {
|
||||||
|
if (!searchText.trim()) return menuTree;
|
||||||
|
|
||||||
|
const searchLower = searchText.toLowerCase();
|
||||||
|
|
||||||
|
// 검색어에 맞는 노드와 그 조상 노드를 포함
|
||||||
|
const filterNodes = (nodes: MenuTreeNode[]): MenuTreeNode[] => {
|
||||||
|
return nodes
|
||||||
|
.map((node) => {
|
||||||
|
const filteredChildren = filterNodes(node.children);
|
||||||
|
const matches = node.menuNameKor.toLowerCase().includes(searchLower);
|
||||||
|
|
||||||
|
if (matches || filteredChildren.length > 0) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: filteredChildren,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((node): node is MenuTreeNode => node !== null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return filterNodes(menuTree);
|
||||||
|
}, [menuTree, searchText]);
|
||||||
|
|
||||||
|
// 체크박스 토글
|
||||||
|
const toggleSelect = useCallback((objid: string) => {
|
||||||
|
const numericId = Number(objid);
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(numericId)) {
|
||||||
|
next.delete(numericId);
|
||||||
|
} else {
|
||||||
|
next.add(numericId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 확장/축소 토글
|
||||||
|
const toggleExpand = useCallback((objid: string) => {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(objid)) {
|
||||||
|
next.delete(objid);
|
||||||
|
} else {
|
||||||
|
next.add(objid);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 확인 버튼 클릭
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(Array.from(selectedIds));
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메뉴 노드 렌더링
|
||||||
|
const renderMenuNode = (node: MenuTreeNode, depth: number = 0) => {
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const isExpanded = expandedIds.has(node.objid);
|
||||||
|
const isSelected = selectedIds.has(Number(node.objid));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={node.objid}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/50 cursor-pointer",
|
||||||
|
isSelected && "bg-primary/10",
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
||||||
|
onClick={() => toggleSelect(node.objid)}
|
||||||
|
>
|
||||||
|
{/* 확장/축소 버튼 */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpand(node.objid);
|
||||||
|
}}
|
||||||
|
className="p-0.5 hover:bg-muted rounded"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-5" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 체크박스 - 모든 메뉴에서 선택 가능 */}
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => toggleSelect(node.objid)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 아이콘 */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<FolderOpen className="h-4 w-4 text-amber-500" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 메뉴명 */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm flex-1 truncate",
|
||||||
|
isSelected && "font-medium text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{node.menuNameKor}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자식 메뉴 */}
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<div>{node.children.map((child) => renderMenuNode(child, depth + 1))}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-[600px] max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>사용 메뉴 선택</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
이 리포트를 사용할 메뉴를 선택하세요. 선택한 메뉴에서 이 리포트를 사용할 수 있습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="메뉴 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 메뉴 수 */}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{selectedIds.size}개 메뉴 선택됨
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메뉴 트리 */}
|
||||||
|
<ScrollArea className="flex-1 border rounded-md">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">메뉴 로드 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredTree.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "표시할 메뉴가 없습니다."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2">{filteredTree.map((node) => renderMenuNode(node))}</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm}>
|
||||||
|
확인 ({selectedIds.size}개 선택)
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -319,7 +319,6 @@ export function ReportDesignerCanvas() {
|
||||||
showLabel: true,
|
showLabel: true,
|
||||||
labelText: "서명:",
|
labelText: "서명:",
|
||||||
labelPosition: "left" as const,
|
labelPosition: "left" as const,
|
||||||
showUnderline: true,
|
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderColor: "#cccccc",
|
borderColor: "#cccccc",
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -947,26 +947,6 @@ export function ReportDesignerRightPanel() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 밑줄 표시 (서명란만) */}
|
|
||||||
{selectedComponent.type === "signature" && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="showUnderline"
|
|
||||||
checked={selectedComponent.showUnderline !== false}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateComponent(selectedComponent.id, {
|
|
||||||
showUnderline: e.target.checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="showUnderline" className="text-xs">
|
|
||||||
밑줄 표시
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 이름 입력 (도장란만) */}
|
{/* 이름 입력 (도장란만) */}
|
||||||
{selectedComponent.type === "stamp" && (
|
{selectedComponent.type === "stamp" && (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -2502,10 +2482,11 @@ export function ReportDesignerRightPanel() {
|
||||||
<Label className="text-xs">너비 (mm)</Label>
|
<Label className="text-xs">너비 (mm)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min={1}
|
||||||
value={currentPage.width}
|
value={currentPage.width}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updatePageSettings(currentPageId, {
|
updatePageSettings(currentPageId, {
|
||||||
width: Number(e.target.value),
|
width: Math.max(1, Number(e.target.value)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|
@ -2515,10 +2496,11 @@ export function ReportDesignerRightPanel() {
|
||||||
<Label className="text-xs">높이 (mm)</Label>
|
<Label className="text-xs">높이 (mm)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min={1}
|
||||||
value={currentPage.height}
|
value={currentPage.height}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updatePageSettings(currentPageId, {
|
updatePageSettings(currentPageId, {
|
||||||
height: Number(e.target.value),
|
height: Math.max(1, Number(e.target.value)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|
@ -2589,12 +2571,13 @@ export function ReportDesignerRightPanel() {
|
||||||
<Label className="text-xs">상단</Label>
|
<Label className="text-xs">상단</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min={0}
|
||||||
value={currentPage.margins.top}
|
value={currentPage.margins.top}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updatePageSettings(currentPageId, {
|
updatePageSettings(currentPageId, {
|
||||||
margins: {
|
margins: {
|
||||||
...currentPage.margins,
|
...currentPage.margins,
|
||||||
top: Number(e.target.value),
|
top: Math.max(0, Number(e.target.value)),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -2605,12 +2588,13 @@ export function ReportDesignerRightPanel() {
|
||||||
<Label className="text-xs">하단</Label>
|
<Label className="text-xs">하단</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min={0}
|
||||||
value={currentPage.margins.bottom}
|
value={currentPage.margins.bottom}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updatePageSettings(currentPageId, {
|
updatePageSettings(currentPageId, {
|
||||||
margins: {
|
margins: {
|
||||||
...currentPage.margins,
|
...currentPage.margins,
|
||||||
bottom: Number(e.target.value),
|
bottom: Math.max(0, Number(e.target.value)),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -2621,12 +2605,13 @@ export function ReportDesignerRightPanel() {
|
||||||
<Label className="text-xs">좌측</Label>
|
<Label className="text-xs">좌측</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min={0}
|
||||||
value={currentPage.margins.left}
|
value={currentPage.margins.left}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updatePageSettings(currentPageId, {
|
updatePageSettings(currentPageId, {
|
||||||
margins: {
|
margins: {
|
||||||
...currentPage.margins,
|
...currentPage.margins,
|
||||||
left: Number(e.target.value),
|
left: Math.max(0, Number(e.target.value)),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -2637,12 +2622,13 @@ export function ReportDesignerRightPanel() {
|
||||||
<Label className="text-xs">우측</Label>
|
<Label className="text-xs">우측</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min={0}
|
||||||
value={currentPage.margins.right}
|
value={currentPage.margins.right}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updatePageSettings(currentPageId, {
|
updatePageSettings(currentPageId, {
|
||||||
margins: {
|
margins: {
|
||||||
...currentPage.margins,
|
...currentPage.margins,
|
||||||
right: Number(e.target.value),
|
right: Math.max(0, Number(e.target.value)),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,19 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
|
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
|
||||||
|
import { MenuSelectModal } from "./MenuSelectModal";
|
||||||
import { reportApi } from "@/lib/api/reportApi";
|
import { reportApi } from "@/lib/api/reportApi";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ReportPreviewModal } from "./ReportPreviewModal";
|
import { ReportPreviewModal } from "./ReportPreviewModal";
|
||||||
|
|
@ -52,7 +63,7 @@ export function ReportDesignerToolbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
reportDetail,
|
reportDetail,
|
||||||
saveLayout,
|
saveLayoutWithMenus,
|
||||||
isSaving,
|
isSaving,
|
||||||
loadLayout,
|
loadLayout,
|
||||||
components,
|
components,
|
||||||
|
|
@ -90,9 +101,14 @@ export function ReportDesignerToolbar() {
|
||||||
setShowRuler,
|
setShowRuler,
|
||||||
groupComponents,
|
groupComponents,
|
||||||
ungroupComponents,
|
ungroupComponents,
|
||||||
|
menuObjids,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
|
const [showBackConfirm, setShowBackConfirm] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [showMenuSelect, setShowMenuSelect] = useState(false);
|
||||||
|
const [pendingSaveAndClose, setPendingSaveAndClose] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// 버튼 활성화 조건
|
// 버튼 활성화 조건
|
||||||
|
|
@ -111,27 +127,33 @@ export function ReportDesignerToolbar() {
|
||||||
setShowGrid(newValue);
|
setShowGrid(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = () => {
|
||||||
await saveLayout();
|
setPendingSaveAndClose(false);
|
||||||
|
setShowMenuSelect(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAndClose = async () => {
|
const handleSaveAndClose = () => {
|
||||||
await saveLayout();
|
setPendingSaveAndClose(true);
|
||||||
router.push("/admin/report");
|
setShowMenuSelect(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
|
||||||
if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) {
|
await saveLayoutWithMenus(selectedMenuObjids);
|
||||||
await loadLayout();
|
if (pendingSaveAndClose) {
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) {
|
|
||||||
router.push("/admin/report");
|
router.push("/admin/report");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetConfirm = async () => {
|
||||||
|
setShowResetConfirm(false);
|
||||||
|
await loadLayout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackConfirm = () => {
|
||||||
|
setShowBackConfirm(false);
|
||||||
|
router.push("/admin/report");
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveAsTemplate = async (data: {
|
const handleSaveAsTemplate = async (data: {
|
||||||
templateNameKor: string;
|
templateNameKor: string;
|
||||||
templateNameEng?: string;
|
templateNameEng?: string;
|
||||||
|
|
@ -193,7 +215,7 @@ export function ReportDesignerToolbar() {
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
|
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="sm" onClick={handleBack} className="gap-2">
|
<Button variant="ghost" size="sm" onClick={() => setShowBackConfirm(true)} className="gap-2">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
목록으로
|
목록으로
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -437,7 +459,7 @@ export function ReportDesignerToolbar() {
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
|
<Button variant="outline" size="sm" onClick={() => setShowResetConfirm(true)} className="gap-2">
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -491,6 +513,46 @@ export function ReportDesignerToolbar() {
|
||||||
onClose={() => setShowSaveAsTemplate(false)}
|
onClose={() => setShowSaveAsTemplate(false)}
|
||||||
onSave={handleSaveAsTemplate}
|
onSave={handleSaveAsTemplate}
|
||||||
/>
|
/>
|
||||||
|
<MenuSelectModal
|
||||||
|
isOpen={showMenuSelect}
|
||||||
|
onClose={() => setShowMenuSelect(false)}
|
||||||
|
onConfirm={handleMenuSelectConfirm}
|
||||||
|
selectedMenuObjids={menuObjids}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 목록으로 돌아가기 확인 모달 */}
|
||||||
|
<AlertDialog open={showBackConfirm} onOpenChange={setShowBackConfirm}>
|
||||||
|
<AlertDialogContent className="max-w-[400px]">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>목록으로 돌아가기</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
저장하지 않은 변경사항이 있을 수 있습니다.
|
||||||
|
<br />
|
||||||
|
목록으로 돌아가시겠습니까?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleBackConfirm}>확인</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 초기화 확인 모달 */}
|
||||||
|
<AlertDialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
|
<AlertDialogContent className="max-w-[400px]">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>초기화</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleResetConfirm}>확인</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import { getFullImageUrl } from "@/lib/api/client";
|
||||||
import JsBarcode from "jsbarcode";
|
import JsBarcode from "jsbarcode";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
// mm -> px 변환 상수
|
||||||
|
const MM_TO_PX = 4;
|
||||||
|
|
||||||
interface ReportPreviewModalProps {
|
interface ReportPreviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -149,8 +152,8 @@ function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWate
|
||||||
// 타일 스타일
|
// 타일 스타일
|
||||||
if (watermark.style === "tile") {
|
if (watermark.style === "tile") {
|
||||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||||
const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2;
|
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
|
||||||
const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2;
|
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
|
|
@ -514,7 +517,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
|
|
||||||
printWindow.document.write(printHtml);
|
printWindow.document.write(printHtml);
|
||||||
printWindow.document.close();
|
printWindow.document.close();
|
||||||
printWindow.print();
|
// print()는 HTML 내 스크립트에서 이미지 로드 완료 후 자동 호출됨
|
||||||
};
|
};
|
||||||
|
|
||||||
// 워터마크 HTML 생성 헬퍼 함수
|
// 워터마크 HTML 생성 헬퍼 함수
|
||||||
|
|
@ -554,8 +557,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
|
|
||||||
if (watermark.style === "tile") {
|
if (watermark.style === "tile") {
|
||||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||||
const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2;
|
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
|
||||||
const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2;
|
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
|
||||||
const tileItems = Array.from({ length: rows * cols })
|
const tileItems = Array.from({ length: rows * cols })
|
||||||
.map(() => `<div style="width: ${tileSize}px; height: ${tileSize}px; display: flex; align-items: center; justify-content: center;">${textContent}</div>`)
|
.map(() => `<div style="width: ${tileSize}px; height: ${tileSize}px; display: flex; align-items: center; justify-content: center;">${textContent}</div>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
@ -624,7 +627,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
${showLabel ? `<div style="font-size: 12px; white-space: nowrap;">${labelText}</div>` : ""}
|
${showLabel ? `<div style="font-size: 12px; white-space: nowrap;">${labelText}</div>` : ""}
|
||||||
<div style="flex: 1; position: relative;">
|
<div style="flex: 1; position: relative;">
|
||||||
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
|
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
|
||||||
${component.showUnderline ? '<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background-color: #000000;"></div>' : ""}
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -633,7 +635,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
${showLabel && labelPosition === "top" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
|
${showLabel && labelPosition === "top" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
|
||||||
<div style="flex: 1; width: 100%; position: relative;">
|
<div style="flex: 1; width: 100%; position: relative;">
|
||||||
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
|
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
|
||||||
${component.showUnderline ? '<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background-color: #000000;"></div>' : ""}
|
|
||||||
</div>
|
</div>
|
||||||
${showLabel && labelPosition === "bottom" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
|
${showLabel && labelPosition === "bottom" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -652,9 +653,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
content = `
|
content = `
|
||||||
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
|
<div style="display: flex; align-items: center; gap: 8px; width: 100%; height: 100%;">
|
||||||
${personName ? `<div style="font-size: 12px;">${personName}</div>` : ""}
|
${personName ? `<div style="font-size: 12px; white-space: nowrap;">${personName}</div>` : ""}
|
||||||
<div style="position: relative; width: ${component.width}px; height: ${component.height}px;">
|
<div style="position: relative; flex: 1; height: 100%;">
|
||||||
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"}; border-radius: 50%;" />` : ""}
|
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"}; border-radius: 50%;" />` : ""}
|
||||||
${showLabel ? `<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 12px; font-weight: bold; color: #dc2626;">${labelText}</div>` : ""}
|
${showLabel ? `<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 12px; font-weight: bold; color: #dc2626;">${labelText}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -893,8 +894,15 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
</table>`;
|
</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 값은 px로 저장됨 (캔버스는 pageWidth * MM_TO_PX px)
|
||||||
|
// 인쇄용 mm 단위로 변환: px / MM_TO_PX = mm
|
||||||
|
const xMm = component.x / MM_TO_PX;
|
||||||
|
const yMm = component.y / MM_TO_PX;
|
||||||
|
const widthMm = component.width / MM_TO_PX;
|
||||||
|
const heightMm = component.height / MM_TO_PX;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="position: absolute; left: ${component.x}px; top: ${component.y}px; width: ${component.width}px; height: ${component.height}px; background-color: ${component.backgroundColor || "transparent"}; border: ${component.borderWidth ? `${component.borderWidth}px solid ${component.borderColor}` : "none"}; padding: 8px; box-sizing: border-box;">
|
<div style="position: absolute; left: ${xMm}mm; top: ${yMm}mm; width: ${widthMm}mm; height: ${heightMm}mm; background-color: ${component.backgroundColor || "transparent"}; border: ${component.borderWidth ? `${component.borderWidth}px solid ${component.borderColor}` : "none"}; box-sizing: border-box; overflow: hidden;">
|
||||||
${content}
|
${content}
|
||||||
</div>`;
|
</div>`;
|
||||||
})
|
})
|
||||||
|
|
@ -903,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight);
|
const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor}; margin: 0 auto;">
|
<div class="print-page" style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor};">
|
||||||
${watermarkHTML}
|
${watermarkHTML}
|
||||||
${componentsHTML}
|
${componentsHTML}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -935,20 +943,18 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>리포트 인쇄</title>
|
<title>리포트 인쇄</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 10mm;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
body { margin: 0; padding: 0; }
|
html, body { width: 210mm; height: 297mm; }
|
||||||
.print-page { page-break-after: always; page-break-inside: avoid; }
|
.print-page { page-break-after: always; page-break-inside: avoid; }
|
||||||
.print-page:last-child { page-break-after: auto; }
|
.print-page:last-child { page-break-after: auto; }
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
|
|
@ -1052,7 +1058,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
description: "WORD 파일을 생성하고 있습니다...",
|
description: "WORD 파일을 생성하고 있습니다...",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함
|
// 이미지 및 바코드를 Base64로 변환하여 컴포넌트 데이터에 포함
|
||||||
const pagesWithBase64 = await Promise.all(
|
const pagesWithBase64 = await Promise.all(
|
||||||
layoutConfig.pages.map(async (page) => {
|
layoutConfig.pages.map(async (page) => {
|
||||||
const componentsWithBase64 = await Promise.all(
|
const componentsWithBase64 = await Promise.all(
|
||||||
|
|
@ -1066,11 +1072,20 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 바코드/QR코드 컴포넌트는 이미지로 변환
|
||||||
|
if (component.type === "barcode") {
|
||||||
|
try {
|
||||||
|
const barcodeImage = await generateBarcodeImage(component);
|
||||||
|
return { ...component, barcodeImageBase64: barcodeImage };
|
||||||
|
} catch {
|
||||||
return component;
|
return component;
|
||||||
}),
|
}
|
||||||
|
}
|
||||||
|
return component;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
return { ...page, components: componentsWithBase64 };
|
return { ...page, components: componentsWithBase64 };
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// 쿼리 결과 수집
|
// 쿼리 결과 수집
|
||||||
|
|
@ -1377,17 +1392,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{component.showUnderline !== false && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "0",
|
|
||||||
left: "0",
|
|
||||||
right: "0",
|
|
||||||
borderBottom: "2px solid #000000",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,22 @@ export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자가 입력한 텍스트로 각 폰트의 글리프를 미리 로드
|
||||||
|
const preloadCanvas = document.createElement("canvas");
|
||||||
|
preloadCanvas.width = 500;
|
||||||
|
preloadCanvas.height = 200;
|
||||||
|
const preloadCtx = preloadCanvas.getContext("2d");
|
||||||
|
|
||||||
|
if (preloadCtx) {
|
||||||
|
for (const font of fonts) {
|
||||||
|
preloadCtx.font = `${font.weight} 124px ${font.style}`;
|
||||||
|
preloadCtx.fillText(name, 0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 글리프 로드 대기 (중요: 첫 렌더링 후 폰트가 완전히 로드되도록)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
const newSignatures: string[] = [];
|
const newSignatures: string[] = [];
|
||||||
|
|
||||||
// 동기적으로 하나씩 생성
|
// 동기적으로 하나씩 생성
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Trash2, Loader2, RefreshCw } from "lucide-react";
|
import { Trash2, Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
import { reportApi } from "@/lib/api/reportApi";
|
import { reportApi } from "@/lib/api/reportApi";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
@ -19,6 +29,7 @@ export function TemplatePalette() {
|
||||||
const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
|
const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
|
|
@ -49,14 +60,18 @@ export function TemplatePalette() {
|
||||||
await applyTemplate(templateId);
|
await applyTemplate(templateId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTemplate = async (templateId: string, templateName: string) => {
|
const handleDeleteClick = (templateId: string, templateName: string) => {
|
||||||
if (!confirm(`"${templateName}" 템플릿을 삭제하시겠습니까?`)) {
|
setDeleteTarget({ id: templateId, name: templateName });
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
|
setDeletingId(deleteTarget.id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
|
||||||
setDeletingId(templateId);
|
|
||||||
try {
|
try {
|
||||||
const response = await reportApi.deleteTemplate(templateId);
|
const response = await reportApi.deleteTemplate(deleteTarget.id);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast({
|
toast({
|
||||||
title: "성공",
|
title: "성공",
|
||||||
|
|
@ -108,7 +123,7 @@ export function TemplatePalette() {
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteTemplate(template.template_id, template.template_name_kor);
|
handleDeleteClick(template.template_id, template.template_name_kor);
|
||||||
}}
|
}}
|
||||||
disabled={deletingId === template.template_id}
|
disabled={deletingId === template.template_id}
|
||||||
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
|
@ -123,6 +138,29 @@ export function TemplatePalette() {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent className="max-w-[400px]">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>템플릿 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{deleteTarget?.name}" 템플릿을 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
삭제된 템플릿은 복구할 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,10 +138,49 @@ interface ReportDesignerContextType {
|
||||||
// 그룹화
|
// 그룹화
|
||||||
groupComponents: () => void;
|
groupComponents: () => void;
|
||||||
ungroupComponents: () => void;
|
ungroupComponents: () => void;
|
||||||
|
|
||||||
|
// 메뉴 연결
|
||||||
|
menuObjids: number[];
|
||||||
|
setMenuObjids: (menuObjids: number[]) => void;
|
||||||
|
saveLayoutWithMenus: (menuObjids: number[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
|
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// 페이지 사이즈 변경 시 컴포넌트 위치 및 크기 재계산 유틸리티 함수
|
||||||
|
const recalculateComponentPositions = (
|
||||||
|
components: ComponentConfig[],
|
||||||
|
oldWidth: number,
|
||||||
|
oldHeight: number,
|
||||||
|
newWidth: number,
|
||||||
|
newHeight: number
|
||||||
|
): ComponentConfig[] => {
|
||||||
|
// 사이즈가 동일하면 그대로 반환
|
||||||
|
if (oldWidth === newWidth && oldHeight === newHeight) {
|
||||||
|
return components;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthRatio = newWidth / oldWidth;
|
||||||
|
const heightRatio = newHeight / oldHeight;
|
||||||
|
|
||||||
|
return components.map((comp) => {
|
||||||
|
// 위치와 크기 모두 비율대로 재계산
|
||||||
|
// 소수점 2자리까지만 유지
|
||||||
|
const newX = Math.round(comp.x * widthRatio * 100) / 100;
|
||||||
|
const newY = Math.round(comp.y * heightRatio * 100) / 100;
|
||||||
|
const newCompWidth = Math.round(comp.width * widthRatio * 100) / 100;
|
||||||
|
const newCompHeight = Math.round(comp.height * heightRatio * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...comp,
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
width: newCompWidth,
|
||||||
|
height: newCompHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) {
|
export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) {
|
||||||
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
|
const [reportDetail, setReportDetail] = useState<ReportDetail | null>(null);
|
||||||
const [layout, setLayout] = useState<ReportLayout | null>(null);
|
const [layout, setLayout] = useState<ReportLayout | null>(null);
|
||||||
|
|
@ -158,6 +197,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
const [selectedComponentIds, setSelectedComponentIds] = useState<string[]>([]); // 다중 선택
|
const [selectedComponentIds, setSelectedComponentIds] = useState<string[]>([]); // 다중 선택
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [menuObjids, setMenuObjids] = useState<number[]>([]); // 연결된 메뉴 ID 목록
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// 현재 페이지 계산
|
// 현재 페이지 계산
|
||||||
|
|
@ -988,10 +1028,42 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => {
|
const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => {
|
||||||
setLayoutConfig((prev) => ({
|
setLayoutConfig((prev) => {
|
||||||
...prev,
|
const targetPage = prev.pages.find((p) => p.page_id === pageId);
|
||||||
pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)),
|
if (!targetPage) {
|
||||||
}));
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 사이즈 변경 감지
|
||||||
|
const isWidthChanging = settings.width !== undefined && settings.width !== targetPage.width;
|
||||||
|
const isHeightChanging = settings.height !== undefined && settings.height !== targetPage.height;
|
||||||
|
|
||||||
|
// 사이즈 변경 시 컴포넌트 위치 재계산
|
||||||
|
let updatedComponents = targetPage.components;
|
||||||
|
if (isWidthChanging || isHeightChanging) {
|
||||||
|
const oldWidth = targetPage.width;
|
||||||
|
const oldHeight = targetPage.height;
|
||||||
|
const newWidth = settings.width ?? targetPage.width;
|
||||||
|
const newHeight = settings.height ?? targetPage.height;
|
||||||
|
|
||||||
|
updatedComponents = recalculateComponentPositions(
|
||||||
|
targetPage.components,
|
||||||
|
oldWidth,
|
||||||
|
oldHeight,
|
||||||
|
newWidth,
|
||||||
|
newHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: prev.pages.map((page) =>
|
||||||
|
page.page_id === pageId
|
||||||
|
? { ...page, ...settings, components: updatedComponents }
|
||||||
|
: page
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 전체 페이지 공유 워터마크 업데이트
|
// 전체 페이지 공유 워터마크 업데이트
|
||||||
|
|
@ -1043,6 +1115,13 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
}));
|
}));
|
||||||
setQueries(loadedQueries);
|
setQueries(loadedQueries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 연결된 메뉴 로드
|
||||||
|
if (detailResponse.data.menuObjids && detailResponse.data.menuObjids.length > 0) {
|
||||||
|
setMenuObjids(detailResponse.data.menuObjids);
|
||||||
|
} else {
|
||||||
|
setMenuObjids([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레이아웃 조회
|
// 레이아웃 조회
|
||||||
|
|
@ -1331,6 +1410,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
...q,
|
...q,
|
||||||
externalConnectionId: q.externalConnectionId || undefined,
|
externalConnectionId: q.externalConnectionId || undefined,
|
||||||
})),
|
})),
|
||||||
|
menuObjids, // 연결된 메뉴 목록
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -1352,7 +1432,68 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [reportId, layoutConfig, queries, toast, loadLayout]);
|
}, [reportId, layoutConfig, queries, menuObjids, toast, loadLayout]);
|
||||||
|
|
||||||
|
// 메뉴를 선택하고 저장하는 함수
|
||||||
|
const saveLayoutWithMenus = useCallback(
|
||||||
|
async (selectedMenuObjids: number[]) => {
|
||||||
|
// 먼저 메뉴 상태 업데이트
|
||||||
|
setMenuObjids(selectedMenuObjids);
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
let actualReportId = reportId;
|
||||||
|
|
||||||
|
// 새 리포트인 경우 먼저 리포트 생성
|
||||||
|
if (reportId === "new") {
|
||||||
|
const createResponse = await reportApi.createReport({
|
||||||
|
reportNameKor: "새 리포트",
|
||||||
|
reportType: "BASIC",
|
||||||
|
description: "새로 생성된 리포트입니다.",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.success || !createResponse.data) {
|
||||||
|
throw new Error("리포트 생성에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
actualReportId = createResponse.data.reportId;
|
||||||
|
|
||||||
|
// URL 업데이트 (페이지 리로드 없이)
|
||||||
|
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 저장 (선택된 메뉴와 함께)
|
||||||
|
await reportApi.saveLayout(actualReportId, {
|
||||||
|
layoutConfig,
|
||||||
|
queries: queries.map((q) => ({
|
||||||
|
...q,
|
||||||
|
externalConnectionId: q.externalConnectionId || undefined,
|
||||||
|
})),
|
||||||
|
menuObjids: selectedMenuObjids,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 새 리포트였다면 데이터 다시 로드
|
||||||
|
if (reportId === "new") {
|
||||||
|
await loadLayout();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "저장에 실패했습니다.";
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: errorMessage,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[reportId, layoutConfig, queries, toast, loadLayout],
|
||||||
|
);
|
||||||
|
|
||||||
// 템플릿 적용
|
// 템플릿 적용
|
||||||
const applyTemplate = useCallback(
|
const applyTemplate = useCallback(
|
||||||
|
|
@ -1553,6 +1694,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 그룹화
|
// 그룹화
|
||||||
groupComponents,
|
groupComponents,
|
||||||
ungroupComponents,
|
ungroupComponents,
|
||||||
|
// 메뉴 연결
|
||||||
|
menuObjids,
|
||||||
|
setMenuObjids,
|
||||||
|
saveLayoutWithMenus,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,6 @@ export interface ComponentConfig {
|
||||||
showLabel?: boolean; // 레이블 표시 여부 ("서명:", "(인)")
|
showLabel?: boolean; // 레이블 표시 여부 ("서명:", "(인)")
|
||||||
labelText?: string; // 커스텀 레이블 텍스트
|
labelText?: string; // 커스텀 레이블 텍스트
|
||||||
labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치
|
labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치
|
||||||
showUnderline?: boolean; // 서명란 밑줄 표시 여부
|
|
||||||
personName?: string; // 도장란 이름 (예: "홍길동")
|
personName?: string; // 도장란 이름 (예: "홍길동")
|
||||||
// 테이블 전용
|
// 테이블 전용
|
||||||
tableColumns?: Array<{
|
tableColumns?: Array<{
|
||||||
|
|
@ -237,6 +236,7 @@ export interface ReportDetail {
|
||||||
report: ReportMaster;
|
report: ReportMaster;
|
||||||
layout: ReportLayout | null;
|
layout: ReportLayout | null;
|
||||||
queries: ReportQuery[];
|
queries: ReportQuery[];
|
||||||
|
menuObjids?: number[]; // 연결된 메뉴 ID 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 목록 응답
|
// 리포트 목록 응답
|
||||||
|
|
@ -288,6 +288,7 @@ export interface SaveLayoutRequest {
|
||||||
parameters: string[];
|
parameters: string[];
|
||||||
externalConnectionId?: number;
|
externalConnectionId?: number;
|
||||||
}>;
|
}>;
|
||||||
|
menuObjids?: number[]; // 연결할 메뉴 ID 목록
|
||||||
|
|
||||||
// 하위 호환성 (deprecated)
|
// 하위 호환성 (deprecated)
|
||||||
canvasWidth?: number;
|
canvasWidth?: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue