; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2025-12-24 09:25:29 +09:00
commit 6bd25c8a9e
14 changed files with 914 additions and 158 deletions

View File

@ -30,6 +30,7 @@ import {
Header,
Footer,
HeadingLevel,
TableLayoutType,
} from "docx";
import { WatermarkConfig } from "../types/report";
import bwipjs from "bwip-js";
@ -592,8 +593,12 @@ export class ReportController {
// mm를 twip으로 변환
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<
@ -726,6 +731,9 @@ export class ReportController {
const base64Data =
component.imageBase64.split(",")[1] || component.imageBase64;
const imageBuffer = Buffer.from(base64Data, "base64");
// 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정
const sigImageHeight = 30; // 고정 높이 (약 40px)
const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80;
result.push(
new ParagraphRef({
children: [
@ -733,8 +741,8 @@ export class ReportController {
new ImageRunRef({
data: imageBuffer,
transformation: {
width: Math.round(component.width * 0.75),
height: Math.round(component.height * 0.75),
width: sigImageWidth,
height: sigImageHeight,
},
type: "png",
}),
@ -1443,7 +1451,11 @@ export class ReportController {
try {
const barcodeType = component.barcodeType || "CODE128";
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";
@ -1739,6 +1751,7 @@ export class ReportController {
const rowTable = new Table({
rows: [new TableRow({ children: cells })],
width: { size: 100, type: WidthType.PERCENTAGE },
layout: TableLayoutType.FIXED, // 셀 너비 고정
borders: {
top: { 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({
rows: [new TableRow({ children: [textCell] })],
width: { size: pxToTwip(component.width), type: WidthType.DXA },
layout: TableLayoutType.FIXED, // 셀 너비 고정
indent: { size: indentLeft, type: WidthType.DXA },
borders: {
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
@ -1970,6 +1984,10 @@ export class ReportController {
component.imageBase64.split(",")[1] || component.imageBase64;
const imageBuffer = Buffer.from(base64Data, "base64");
// 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정
const sigImageHeight = 30; // 고정 높이
const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80;
const paragraph = new Paragraph({
spacing: { before: spacingBefore, after: 0 },
indent: { left: indentLeft },
@ -1978,8 +1996,8 @@ export class ReportController {
new ImageRun({
data: imageBuffer,
transformation: {
width: Math.round(component.width * 0.75),
height: Math.round(component.height * 0.75),
width: sigImageWidth,
height: sigImageHeight,
},
type: "png",
}),

View File

@ -279,11 +279,90 @@ export class MenuCopyService {
logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`);
}
}
// 5) 모달 화면 ID (addModalScreenId, editModalScreenId, modalScreenId)
if (props?.componentConfig?.addModalScreenId) {
const addModalScreenId = props.componentConfig.addModalScreenId;
const numId =
typeof addModalScreenId === "number"
? addModalScreenId
: parseInt(addModalScreenId);
if (!isNaN(numId) && numId > 0) {
referenced.push(numId);
logger.debug(` 📋 추가 모달 화면 참조 발견: ${numId}`);
}
}
if (props?.componentConfig?.editModalScreenId) {
const editModalScreenId = props.componentConfig.editModalScreenId;
const numId =
typeof editModalScreenId === "number"
? editModalScreenId
: parseInt(editModalScreenId);
if (!isNaN(numId) && numId > 0) {
referenced.push(numId);
logger.debug(` 📝 수정 모달 화면 참조 발견: ${numId}`);
}
}
if (props?.componentConfig?.modalScreenId) {
const modalScreenId = props.componentConfig.modalScreenId;
const numId =
typeof modalScreenId === "number"
? modalScreenId
: parseInt(modalScreenId);
if (!isNaN(numId) && numId > 0) {
referenced.push(numId);
logger.debug(` 🔲 모달 화면 참조 발견: ${numId}`);
}
}
// 6) 재귀적으로 모든 properties에서 화면 ID 추출 (깊은 탐색)
this.extractScreenIdsFromObject(props, referenced);
}
return referenced;
}
/**
* ID를
*/
private extractScreenIdsFromObject(obj: any, referenced: number[]): void {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) {
this.extractScreenIdsFromObject(item, referenced);
}
return;
}
for (const key of Object.keys(obj)) {
const value = obj[key];
// 화면 ID 키 패턴 확인
if (
key === "screenId" ||
key === "targetScreenId" ||
key === "leftScreenId" ||
key === "rightScreenId" ||
key === "addModalScreenId" ||
key === "editModalScreenId" ||
key === "modalScreenId"
) {
const numId = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numId) && numId > 0 && !referenced.includes(numId)) {
referenced.push(numId);
}
}
// 재귀 탐색
if (typeof value === "object" && value !== null) {
this.extractScreenIdsFromObject(value, referenced);
}
}
}
/**
* ( , )
*/
@ -483,7 +562,8 @@ export class MenuCopyService {
properties: any,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>,
numberingRuleIdMap?: Map<string, string>
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
): any {
if (!properties) return properties;
@ -496,7 +576,8 @@ export class MenuCopyService {
screenIdMap,
flowIdMap,
"",
numberingRuleIdMap
numberingRuleIdMap,
menuIdMap
);
return updated;
@ -510,7 +591,8 @@ export class MenuCopyService {
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>,
path: string = "",
numberingRuleIdMap?: Map<string, string>
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
): void {
if (!obj || typeof obj !== "object") return;
@ -522,7 +604,8 @@ export class MenuCopyService {
screenIdMap,
flowIdMap,
`${path}[${index}]`,
numberingRuleIdMap
numberingRuleIdMap,
menuIdMap
);
});
return;
@ -533,13 +616,16 @@ export class MenuCopyService {
const value = obj[key];
const currentPath = path ? `${path}.${key}` : key;
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열)
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId, addModalScreenId, editModalScreenId, modalScreenId 매핑 (숫자 또는 숫자 문자열)
if (
key === "screen_id" ||
key === "screenId" ||
key === "targetScreenId" ||
key === "leftScreenId" ||
key === "rightScreenId"
key === "rightScreenId" ||
key === "addModalScreenId" ||
key === "editModalScreenId" ||
key === "modalScreenId"
) {
const numValue = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
@ -549,6 +635,11 @@ export class MenuCopyService {
logger.info(
` 🔗 화면 참조 업데이트 (${currentPath}): ${value}${newId}`
);
} else {
// 매핑이 없으면 경고 로그 (복사되지 않은 화면 참조)
logger.warn(
` ⚠️ 화면 매핑 없음 (${currentPath}): ${value} - 원본 화면이 복사되지 않았을 수 있음`
);
}
}
}
@ -573,9 +664,9 @@ export class MenuCopyService {
}
}
// numberingRuleId 매핑 (문자열)
// numberingRuleId, ruleId 매핑 (문자열) - 채번규칙 참조
if (
key === "numberingRuleId" &&
(key === "numberingRuleId" || key === "ruleId") &&
numberingRuleIdMap &&
typeof value === "string" &&
value
@ -595,6 +686,25 @@ export class MenuCopyService {
}
}
// selectedMenuObjid 매핑 (메뉴 objid 참조)
if (key === "selectedMenuObjid" && menuIdMap) {
const numValue = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
const newId = menuIdMap.get(numValue);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId);
logger.info(
` 🔗 메뉴 참조 업데이트 (${currentPath}): ${value}${newId}`
);
} else {
// 매핑이 없으면 경고 로그 (복사되지 않은 메뉴 참조)
logger.warn(
` ⚠️ 메뉴 매핑 없음 (${currentPath}): ${value} - 원본 메뉴가 복사되지 않았을 수 있음`
);
}
}
}
// 재귀 호출
if (typeof value === "object" && value !== null) {
this.recursiveUpdateReferences(
@ -602,7 +712,8 @@ export class MenuCopyService {
screenIdMap,
flowIdMap,
currentPath,
numberingRuleIdMap
numberingRuleIdMap,
menuIdMap
);
}
}
@ -981,7 +1092,8 @@ export class MenuCopyService {
userId,
client,
screenNameConfig,
numberingRuleIdMap
numberingRuleIdMap,
menuIdMap
);
// === 6단계: 화면-메뉴 할당 ===
@ -1315,7 +1427,8 @@ export class MenuCopyService {
removeText?: string;
addPrefix?: string;
},
numberingRuleIdMap?: Map<string, string>
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
): Promise<Map<number, number>> {
const screenIdMap = new Map<number, number>();
@ -1601,7 +1714,8 @@ export class MenuCopyService {
layout.properties,
screenIdMap,
flowIdMap,
numberingRuleIdMap
numberingRuleIdMap,
menuIdMap
);
layoutValues.push(

View File

@ -234,10 +234,23 @@ export class ReportService {
`;
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 {
report,
layout,
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;
});
}

View File

@ -71,11 +71,12 @@ export interface ReportQuery {
updated_by: string | null;
}
// 리포트 상세 (마스터 + 레이아웃 + 쿼리)
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
export interface ReportDetail {
report: ReportMaster;
layout: ReportLayout | null;
queries: ReportQuery[];
menuObjids?: number[]; // 연결된 메뉴 ID 목록
}
// 리포트 목록 조회 파라미터
@ -166,6 +167,17 @@ export interface SaveLayoutRequest {
parameters: string[];
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;
}
// 템플릿 목록 응답

View File

@ -357,11 +357,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
height: snappedSize,
});
} else {
// Grid Snap 적용
updateComponent(component.id, {
width: snapValueToGrid(boundedWidth),
height: snapValueToGrid(boundedHeight),
});
// Grid Snap 적용
updateComponent(component.id, {
width: snapValueToGrid(boundedWidth),
height: snapValueToGrid(boundedHeight),
});
}
}
};
@ -444,17 +444,17 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
case "text":
case "label":
return (
<div
<div
className="h-full w-full"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}}
>
{displayValue}
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}}
>
{displayValue}
</div>
);
@ -534,7 +534,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// 기본 테이블 (데이터 없을 때)
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>
);
@ -606,7 +606,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const sigLabelPos = component.labelPosition || "left";
const sigShowLabel = component.showLabel !== false;
const sigLabelText = component.labelText || "서명:";
const sigShowUnderline = component.showUnderline !== false;
return (
<div className="h-full w-full">
@ -653,14 +652,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
</div>
)}
{sigShowUnderline && (
<div
className="absolute right-0 bottom-0 left-0"
style={{
borderBottom: "2px solid #000000",
}}
/>
)}
</div>
</div>
</div>
@ -867,12 +858,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
const calculateResult = (): number => {
if (calcItems.length === 0) return 0;
// 첫 번째 항목은 기준값
let result = getCalcItemValue(
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
);
// 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i];
@ -908,30 +899,30 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
item: { label: string; value: number | string; operator: string; fieldName?: string },
index: number,
) => {
const itemValue = getCalcItemValue(item);
return (
<div key={index} className="flex items-center justify-between py-1">
<span
className="flex-shrink-0"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
className="text-right"
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
const itemValue = getCalcItemValue(item);
return (
<div key={index} className="flex items-center justify-between py-1">
<span
className="flex-shrink-0"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
className="text-right"
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
},
)}
</div>

View File

@ -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>
);
}

View File

@ -319,7 +319,6 @@ export function ReportDesignerCanvas() {
showLabel: true,
labelText: "서명:",
labelPosition: "left" as const,
showUnderline: true,
borderWidth: 0,
borderColor: "#cccccc",
}),

View File

@ -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" && (
<div>
@ -2502,10 +2482,11 @@ export function ReportDesignerRightPanel() {
<Label className="text-xs"> (mm)</Label>
<Input
type="number"
min={1}
value={currentPage.width}
onChange={(e) =>
updatePageSettings(currentPageId, {
width: Number(e.target.value),
width: Math.max(1, Number(e.target.value)),
})
}
className="mt-1"
@ -2515,10 +2496,11 @@ export function ReportDesignerRightPanel() {
<Label className="text-xs"> (mm)</Label>
<Input
type="number"
min={1}
value={currentPage.height}
onChange={(e) =>
updatePageSettings(currentPageId, {
height: Number(e.target.value),
height: Math.max(1, Number(e.target.value)),
})
}
className="mt-1"
@ -2589,12 +2571,13 @@ export function ReportDesignerRightPanel() {
<Label className="text-xs"></Label>
<Input
type="number"
min={0}
value={currentPage.margins.top}
onChange={(e) =>
updatePageSettings(currentPageId, {
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>
<Input
type="number"
min={0}
value={currentPage.margins.bottom}
onChange={(e) =>
updatePageSettings(currentPageId, {
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>
<Input
type="number"
min={0}
value={currentPage.margins.left}
onChange={(e) =>
updatePageSettings(currentPageId, {
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>
<Input
type="number"
min={0}
value={currentPage.margins.right}
onChange={(e) =>
updatePageSettings(currentPageId, {
margins: {
...currentPage.margins,
right: Number(e.target.value),
right: Math.max(0, Number(e.target.value)),
},
})
}

View File

@ -42,8 +42,19 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
import { MenuSelectModal } from "./MenuSelectModal";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { ReportPreviewModal } from "./ReportPreviewModal";
@ -52,7 +63,7 @@ export function ReportDesignerToolbar() {
const router = useRouter();
const {
reportDetail,
saveLayout,
saveLayoutWithMenus,
isSaving,
loadLayout,
components,
@ -90,9 +101,14 @@ export function ReportDesignerToolbar() {
setShowRuler,
groupComponents,
ungroupComponents,
menuObjids,
} = useReportDesigner();
const [showPreview, setShowPreview] = 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();
// 버튼 활성화 조건
@ -111,27 +127,33 @@ export function ReportDesignerToolbar() {
setShowGrid(newValue);
};
const handleSave = async () => {
await saveLayout();
const handleSave = () => {
setPendingSaveAndClose(false);
setShowMenuSelect(true);
};
const handleSaveAndClose = async () => {
await saveLayout();
router.push("/admin/report");
const handleSaveAndClose = () => {
setPendingSaveAndClose(true);
setShowMenuSelect(true);
};
const handleReset = async () => {
if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) {
await loadLayout();
}
};
const handleBack = () => {
if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) {
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
await saveLayoutWithMenus(selectedMenuObjids);
if (pendingSaveAndClose) {
router.push("/admin/report");
}
};
const handleResetConfirm = async () => {
setShowResetConfirm(false);
await loadLayout();
};
const handleBackConfirm = () => {
setShowBackConfirm(false);
router.push("/admin/report");
};
const handleSaveAsTemplate = async (data: {
templateNameKor: 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 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" />
</Button>
@ -437,7 +459,7 @@ export function ReportDesignerToolbar() {
</DropdownMenuContent>
</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" />
</Button>
@ -491,6 +513,46 @@ export function ReportDesignerToolbar() {
onClose={() => setShowSaveAsTemplate(false)}
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>
</>
);
}

View File

@ -17,6 +17,9 @@ import { getFullImageUrl } from "@/lib/api/client";
import JsBarcode from "jsbarcode";
import QRCode from "qrcode";
// mm -> px 변환 상수
const MM_TO_PX = 4;
interface ReportPreviewModalProps {
isOpen: boolean;
onClose: () => void;
@ -149,8 +152,8 @@ function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWate
// 타일 스타일
if (watermark.style === "tile") {
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2;
const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2;
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
return (
<div style={baseStyle}>
@ -514,7 +517,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
printWindow.document.write(printHtml);
printWindow.document.close();
printWindow.print();
// print()는 HTML 내 스크립트에서 이미지 로드 완료 후 자동 호출됨
};
// 워터마크 HTML 생성 헬퍼 함수
@ -554,8 +557,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
if (watermark.style === "tile") {
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2;
const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2;
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
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>`)
.join("");
@ -624,7 +627,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
${showLabel ? `<div style="font-size: 12px; white-space: nowrap;">${labelText}</div>` : ""}
<div style="flex: 1; position: relative;">
${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>`;
} else {
@ -633,7 +635,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
${showLabel && labelPosition === "top" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
<div style="flex: 1; width: 100%; position: relative;">
${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>
${showLabel && labelPosition === "bottom" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
</div>`;
@ -652,9 +653,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
: "";
content = `
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
${personName ? `<div style="font-size: 12px;">${personName}</div>` : ""}
<div style="position: relative; width: ${component.width}px; height: ${component.height}px;">
<div style="display: flex; align-items: center; gap: 8px; width: 100%; height: 100%;">
${personName ? `<div style="font-size: 12px; white-space: nowrap;">${personName}</div>` : ""}
<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%;" />` : ""}
${showLabel ? `<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 12px; font-weight: bold; color: #dc2626;">${labelText}</div>` : ""}
</div>
@ -893,8 +894,15 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</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 `
<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}
</div>`;
})
@ -903,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight);
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}
${componentsHTML}
</div>`;
@ -935,20 +943,18 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
<meta charset="UTF-8">
<title> </title>
<style>
* { box-sizing: border-box; }
* { box-sizing: border-box; margin: 0; padding: 0; }
@page {
size: A4;
margin: 10mm;
margin: 0;
}
@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:last-child { page-break-after: auto; }
}
body {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
margin: 0;
padding: 20px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@ -1052,7 +1058,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
description: "WORD 파일을 생성하고 있습니다...",
});
// 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함
// 이미지 및 바코드를 Base64로 변환하여 컴포넌트 데이터에 포함
const pagesWithBase64 = await Promise.all(
layoutConfig.pages.map(async (page) => {
const componentsWithBase64 = await Promise.all(
@ -1066,11 +1072,20 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
return component;
}
}
// 바코드/QR코드 컴포넌트는 이미지로 변환
if (component.type === "barcode") {
try {
const barcodeImage = await generateBarcodeImage(component);
return { ...component, barcodeImageBase64: barcodeImage };
} catch {
return component;
}),
}
}
return component;
})
);
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>
)}

View File

@ -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[] = [];
// 동기적으로 하나씩 생성

View File

@ -3,6 +3,16 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
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 { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
@ -19,6 +29,7 @@ export function TemplatePalette() {
const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const { toast } = useToast();
const fetchTemplates = async () => {
@ -49,14 +60,18 @@ export function TemplatePalette() {
await applyTemplate(templateId);
};
const handleDeleteTemplate = async (templateId: string, templateName: string) => {
if (!confirm(`"${templateName}" 템플릿을 삭제하시겠습니까?`)) {
return;
}
const handleDeleteClick = (templateId: string, templateName: string) => {
setDeleteTarget({ id: templateId, name: templateName });
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setDeletingId(deleteTarget.id);
setDeleteTarget(null);
setDeletingId(templateId);
try {
const response = await reportApi.deleteTemplate(templateId);
const response = await reportApi.deleteTemplate(deleteTarget.id);
if (response.success) {
toast({
title: "성공",
@ -108,7 +123,7 @@ export function TemplatePalette() {
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteTemplate(template.template_id, template.template_name_kor);
handleDeleteClick(template.template_id, template.template_name_kor);
}}
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"
@ -123,6 +138,29 @@ export function TemplatePalette() {
))
)}
</div>
{/* 삭제 확인 모달 */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent className="max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle>릿 </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.name}&quot; 릿 ?
<br />
릿 .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -138,10 +138,49 @@ interface ReportDesignerContextType {
// 그룹화
groupComponents: () => void;
ungroupComponents: () => void;
// 메뉴 연결
menuObjids: number[];
setMenuObjids: (menuObjids: number[]) => void;
saveLayoutWithMenus: (menuObjids: number[]) => Promise<void>;
}
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 }) {
const [reportDetail, setReportDetail] = useState<ReportDetail | 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 [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [menuObjids, setMenuObjids] = useState<number[]>([]); // 연결된 메뉴 ID 목록
const { toast } = useToast();
// 현재 페이지 계산
@ -988,10 +1028,42 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
}, []);
const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => {
setLayoutConfig((prev) => ({
...prev,
pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)),
}));
setLayoutConfig((prev) => {
const targetPage = prev.pages.find((p) => p.page_id === pageId);
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);
}
// 연결된 메뉴 로드
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,
externalConnectionId: q.externalConnectionId || undefined,
})),
menuObjids, // 연결된 메뉴 목록
});
toast({
@ -1352,7 +1432,68 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
} finally {
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(
@ -1553,6 +1694,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 그룹화
groupComponents,
ungroupComponents,
// 메뉴 연결
menuObjids,
setMenuObjids,
saveLayoutWithMenus,
};
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;

View File

@ -162,7 +162,6 @@ export interface ComponentConfig {
showLabel?: boolean; // 레이블 표시 여부 ("서명:", "(인)")
labelText?: string; // 커스텀 레이블 텍스트
labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치
showUnderline?: boolean; // 서명란 밑줄 표시 여부
personName?: string; // 도장란 이름 (예: "홍길동")
// 테이블 전용
tableColumns?: Array<{
@ -237,6 +236,7 @@ export interface ReportDetail {
report: ReportMaster;
layout: ReportLayout | null;
queries: ReportQuery[];
menuObjids?: number[]; // 연결된 메뉴 ID 목록
}
// 리포트 목록 응답
@ -288,6 +288,7 @@ export interface SaveLayoutRequest {
parameters: string[];
externalConnectionId?: number;
}>;
menuObjids?: number[]; // 연결할 메뉴 ID 목록
// 하위 호환성 (deprecated)
canvasWidth?: number;