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,
|
||||
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",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 템플릿 목록 응답
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
labelText: "서명:",
|
||||
labelPosition: "left" as const,
|
||||
showUnderline: true,
|
||||
borderWidth: 0,
|
||||
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" && (
|
||||
<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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,25 +127,31 @@ export function ReportDesignerToolbar() {
|
|||
setShowGrid(newValue);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await saveLayout();
|
||||
const handleSave = () => {
|
||||
setPendingSaveAndClose(false);
|
||||
setShowMenuSelect(true);
|
||||
};
|
||||
|
||||
const handleSaveAndClose = async () => {
|
||||
await saveLayout();
|
||||
const handleSaveAndClose = () => {
|
||||
setPendingSaveAndClose(true);
|
||||
setShowMenuSelect(true);
|
||||
};
|
||||
|
||||
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
|
||||
await saveLayoutWithMenus(selectedMenuObjids);
|
||||
if (pendingSaveAndClose) {
|
||||
router.push("/admin/report");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) {
|
||||
const handleResetConfirm = async () => {
|
||||
setShowResetConfirm(false);
|
||||
await loadLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) {
|
||||
const handleBackConfirm = () => {
|
||||
setShowBackConfirm(false);
|
||||
router.push("/admin/report");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsTemplate = async (data: {
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
// 동기적으로 하나씩 생성
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
"{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
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 } : page)),
|
||||
}));
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue