From 961e7e9a1409100b2fb829288e0b5aee16eacd2f Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 17:41:14 +0900 Subject: [PATCH 01/30] =?UTF-8?q?=EC=B1=84=EB=B2=88=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/menuCopyService.ts | 54 ++++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 26c8b779..c0fda30c 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -756,9 +756,10 @@ export class MenuCopyService { [menuScopedRuleIds] ); // 채번 규칙 삭제 - await client.query(`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, [ - menuScopedRuleIds, - ]); + await client.query( + `DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, + [menuScopedRuleIds] + ); logger.info( ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}개` ); @@ -2370,12 +2371,10 @@ export class MenuCopyService { return { copiedCount, ruleIdMap }; } - // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 - const ruleIds = allRulesResult.rows.map((r) => r.rule_id); + // 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요) const existingRulesResult = await client.query( - `SELECT rule_id FROM numbering_rules - WHERE rule_id = ANY($1) AND company_code = $2`, - [ruleIds, targetCompanyCode] + `SELECT rule_id FROM numbering_rules WHERE company_code = $1`, + [targetCompanyCode] ); const existingRuleIds = new Set( existingRulesResult.rows.map((r) => r.rule_id) @@ -2389,28 +2388,49 @@ export class MenuCopyService { const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; for (const rule of allRulesResult.rows) { + // 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가 + // 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123 + // 예: rule-123 -> rule-123 -> COMPANY_16_rule-123 + // 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드 + let baseName = rule.rule_id; + + // 회사코드 접두사 패턴들을 순서대로 제거 시도 + // 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_) + // 2. 일반 접두사_ 패턴 (예: WACE_) + if (baseName.match(/^COMPANY_\d+_/)) { + baseName = baseName.replace(/^COMPANY_\d+_/, ""); + } else if (baseName.includes("_")) { + baseName = baseName.replace(/^[^_]+_/, ""); + } + + const newRuleId = `${targetCompanyCode}_${baseName}`; + if (existingRuleIds.has(rule.rule_id)) { - // 기존 규칙은 동일한 ID로 매핑 + // 원본 ID가 이미 존재 (동일한 ID로 매핑) ruleIdMap.set(rule.rule_id, rule.rule_id); - // 새 메뉴 ID로 연결 업데이트 필요 const newMenuObjid = menuIdMap.get(rule.menu_objid); if (newMenuObjid) { rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid }); } + logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`); + } else if (existingRuleIds.has(newRuleId)) { + // 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑) + ruleIdMap.set(rule.rule_id, newRuleId); + + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (newMenuObjid) { + rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid }); + } logger.info( - ` ♻️ 채번규칙 이미 존재 (메뉴 연결 갱신): ${rule.rule_id}` + ` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}` ); } else { - // 새 rule_id 생성 - const originalSuffix = rule.rule_id.includes("_") - ? rule.rule_id.replace(/^[^_]*_/, "") - : rule.rule_id; - const newRuleId = `${targetCompanyCode}_${originalSuffix}`; - + // 새로 복사 필요 ruleIdMap.set(rule.rule_id, newRuleId); originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); rulesToCopy.push({ ...rule, newRuleId }); + logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`); } } From ea013091585953648c42f6700e2ff9ff3d5dba7f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 19 Dec 2025 17:59:54 +0900 Subject: [PATCH 02/30] =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=EC=97=90=20=EB=B0=94=EC=BD=94?= =?UTF-8?q?=EB=93=9C/QR=EC=BD=94=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/package-lock.json | 10 + backend-node/package.json | 1 + .../src/controllers/reportController.ts | 185 +++++++++++ backend-node/src/types/report.ts | 95 ++++++ .../report/designer/CanvasComponent.tsx | 200 +++++++++++- .../report/designer/ComponentPalette.tsx | 3 +- .../report/designer/ReportDesignerCanvas.tsx | 14 + .../designer/ReportDesignerRightPanel.tsx | 208 +++++++++++- frontend/package-lock.json | 298 +++++++++++++++++- frontend/package.json | 4 + frontend/types/report.ts | 9 + 11 files changed, 1017 insertions(+), 10 deletions(-) diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 5b5eb7d7..f826a86a 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -12,6 +12,7 @@ "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", + "bwip-js": "^4.8.0", "compression": "^1.7.4", "cors": "^2.8.5", "docx": "^9.5.1", @@ -4540,6 +4541,15 @@ "node": ">=10.16.0" } }, + "node_modules/bwip-js": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz", + "integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==", + "license": "MIT", + "bin": { + "bwip-js": "bin/bwip-js.js" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index e078043c..e9ce3729 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -26,6 +26,7 @@ "@types/mssql": "^9.1.8", "axios": "^1.11.0", "bcryptjs": "^2.4.3", + "bwip-js": "^4.8.0", "compression": "^1.7.4", "cors": "^2.8.5", "docx": "^9.5.1", diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index a2e8e8a9..438e02e1 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -28,6 +28,7 @@ import { PageOrientation, convertMillimetersToTwip, } from "docx"; +import bwipjs from "bwip-js"; export class ReportController { /** @@ -1326,6 +1327,43 @@ export class ReportController { ); } + // Barcode 컴포넌트 (바코드 이미지가 미리 생성되어 전달된 경우) + else if (component.type === "barcode" && component.barcodeImageBase64) { + try { + const base64Data = + component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + // 바코드 이미지 생성 실패 시 텍스트로 대체 + const barcodeValue = component.barcodeValue || "BARCODE"; + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: `[${barcodeValue}]`, + size: pxToHalfPtFn(12), + font: "맑은 고딕", + }), + ], + }) + ); + } + } + // Divider - 테이블 셀로 감싸서 정확한 너비 적용 else if ( component.type === "divider" && @@ -1354,6 +1392,82 @@ export class ReportController { return result; }; + // 바코드 이미지 생성 헬퍼 함수 + const generateBarcodeImage = async ( + component: any, + queryResultsMapRef: Record[] }> + ): Promise => { + try { + const barcodeType = component.barcodeType || "CODE128"; + const barcodeColor = (component.barcodeColor || "#000000").replace("#", ""); + const barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", ""); + + // 바코드 값 결정 (쿼리 바인딩 또는 고정값) + let barcodeValue = component.barcodeValue || "SAMPLE123"; + if (component.barcodeFieldName && component.queryId && queryResultsMapRef[component.queryId]) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[component.barcodeFieldName]; + if (val !== null && val !== undefined) { + barcodeValue = String(val); + } + } + } + + // bwip-js 바코드 타입 매핑 + const bcidMap: Record = { + "CODE128": "code128", + "CODE39": "code39", + "EAN13": "ean13", + "EAN8": "ean8", + "UPC": "upca", + "QR": "qrcode", + }; + + const bcid = bcidMap[barcodeType] || "code128"; + const isQR = barcodeType === "QR"; + + // 바코드 옵션 설정 + const options: any = { + bcid: bcid, + text: barcodeValue, + scale: 3, + includetext: !isQR && component.showBarcodeText !== false, + textxalign: "center", + barcolor: barcodeColor, + backgroundcolor: barcodeBackground, + }; + + // QR 코드 옵션 + if (isQR) { + options.eclevel = component.qrErrorCorrectionLevel || "M"; + } + + // 바코드 이미지 생성 + const png = await bwipjs.toBuffer(options); + const base64 = png.toString("base64"); + return `data:image/png;base64,${base64}`; + } catch (error) { + console.error("바코드 생성 오류:", error); + return null; + } + }; + + // 모든 페이지의 바코드 컴포넌트에 대해 이미지 생성 + for (const page of layoutConfig.pages) { + if (page.components) { + for (const component of page.components) { + if (component.type === "barcode") { + const barcodeImage = await generateBarcodeImage(component, queryResultsMap); + if (barcodeImage) { + component.barcodeImageBase64 = barcodeImage; + } + } + } + } + } + // 섹션 생성 (페이지별) const sortedPages = layoutConfig.pages.sort( (a: any, b: any) => a.page_order - b.page_order @@ -2624,6 +2738,77 @@ export class ReportController { lastBottomY = adjustedY + component.height; } + // Barcode 컴포넌트 + else if (component.type === "barcode") { + if (component.barcodeImageBase64) { + try { + const base64Data = + component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + + children.push( + new Paragraph({ + indent: { left: indentLeft }, + children: [ + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (imgError) { + console.error("바코드 이미지 오류:", imgError); + // 바코드 이미지 생성 실패 시 텍스트로 대체 + const barcodeValue = component.barcodeValue || "BARCODE"; + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + new TextRun({ + text: `[${barcodeValue}]`, + size: pxToHalfPt(12), + font: "맑은 고딕", + }), + ], + }) + ); + } + } else { + // 바코드 이미지가 없는 경우 텍스트로 대체 + const barcodeValue = component.barcodeValue || "BARCODE"; + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + new TextRun({ + text: `[${barcodeValue}]`, + size: pxToHalfPt(12), + font: "맑은 고딕", + }), + ], + }) + ); + } + lastBottomY = adjustedY + component.height; + } + // Table 컴포넌트 else if (component.type === "table" && component.queryId) { const queryResult = queryResultsMap[component.queryId]; diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index 2fe1cfd3..e622a65c 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -166,3 +166,98 @@ export interface CreateTemplateRequest { layoutConfig?: any; defaultQueries?: any; } + +// 컴포넌트 설정 (프론트엔드와 동기화) +export interface ComponentConfig { + id: string; + type: string; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + fontSize?: number; + fontFamily?: string; + fontWeight?: string; + fontColor?: string; + backgroundColor?: string; + borderWidth?: number; + borderColor?: string; + borderRadius?: number; + textAlign?: string; + padding?: number; + queryId?: string; + fieldName?: string; + defaultValue?: string; + format?: string; + visible?: boolean; + printable?: boolean; + conditional?: string; + locked?: boolean; + groupId?: string; + // 이미지 전용 + imageUrl?: string; + objectFit?: "contain" | "cover" | "fill" | "none"; + // 구분선 전용 + orientation?: "horizontal" | "vertical"; + lineStyle?: "solid" | "dashed" | "dotted" | "double"; + lineWidth?: number; + lineColor?: string; + // 서명/도장 전용 + showLabel?: boolean; + labelText?: string; + labelPosition?: "top" | "left" | "bottom" | "right"; + showUnderline?: boolean; + personName?: string; + // 테이블 전용 + tableColumns?: Array<{ + field: string; + header: string; + width?: number; + align?: "left" | "center" | "right"; + }>; + headerBackgroundColor?: string; + headerTextColor?: string; + showBorder?: boolean; + rowHeight?: number; + // 페이지 번호 전용 + pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; + // 카드 컴포넌트 전용 + cardTitle?: string; + cardItems?: Array<{ + label: string; + value: string; + fieldName?: string; + }>; + labelWidth?: number; + showCardBorder?: boolean; + showCardTitle?: boolean; + titleFontSize?: number; + labelFontSize?: number; + valueFontSize?: number; + titleColor?: string; + labelColor?: string; + valueColor?: string; + // 계산 컴포넌트 전용 + calcItems?: Array<{ + label: string; + value: number | string; + operator: "+" | "-" | "x" | "÷"; + fieldName?: string; + }>; + resultLabel?: string; + resultColor?: string; + resultFontSize?: number; + showCalcBorder?: boolean; + numberFormat?: "none" | "comma" | "currency"; + currencySuffix?: string; + // 바코드 컴포넌트 전용 + barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR"; + barcodeValue?: string; + barcodeFieldName?: string; + showBarcodeText?: boolean; + barcodeColor?: string; + barcodeBackground?: string; + barcodeMargin?: number; + qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; +} diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 01f0390b..e3f06e9f 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -1,9 +1,143 @@ "use client"; -import { useRef, useState, useEffect } from "react"; +import { useRef, useState, useEffect, useCallback } from "react"; import { ComponentConfig } from "@/types/report"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { getFullImageUrl } from "@/lib/api/client"; +import JsBarcode from "jsbarcode"; +import QRCode from "qrcode"; + +// 1D 바코드 렌더러 컴포넌트 +interface BarcodeRendererProps { + value: string; + format: string; + width: number; + height: number; + displayValue: boolean; + lineColor: string; + background: string; + margin: number; +} + +function BarcodeRenderer({ value, format, width, height, displayValue, lineColor, background, margin }: BarcodeRendererProps) { + const svgRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!svgRef.current || !value) return; + + // 매번 에러 상태 초기화 후 재검사 + setError(null); + + try { + // 바코드 형식에 따른 유효성 검사 + let isValid = true; + let errorMsg = ""; + const trimmedValue = value.trim(); + + if (format === "EAN13" && !/^\d{12,13}$/.test(trimmedValue)) { + isValid = false; + errorMsg = "EAN-13: 12~13자리 숫자 필요"; + } else if (format === "EAN8" && !/^\d{7,8}$/.test(trimmedValue)) { + isValid = false; + errorMsg = "EAN-8: 7~8자리 숫자 필요"; + } else if (format === "UPC" && !/^\d{11,12}$/.test(trimmedValue)) { + isValid = false; + errorMsg = "UPC: 11~12자리 숫자 필요"; + } + + if (!isValid) { + setError(errorMsg); + return; + } + + // JsBarcode는 format을 소문자로 받음 + const barcodeFormat = format.toLowerCase(); + + JsBarcode(svgRef.current, trimmedValue, { + format: barcodeFormat, + width: 2, + height: Math.max(30, height - (displayValue ? 30 : 10)), + displayValue: displayValue, + lineColor: lineColor, + background: background, + margin: margin, + fontSize: 12, + textMargin: 2, + }); + } catch (err: any) { + // JsBarcode 체크섬 오류 등 + setError(err?.message || "바코드 생성 실패"); + } + }, [value, format, width, height, displayValue, lineColor, background, margin]); + + return ( +
+ {/* SVG는 항상 렌더링 (에러 시 숨김) */} + + {/* 에러 메시지 오버레이 */} + {error && ( +
+ {error} + {value} +
+ )} +
+ ); +} + +// QR코드 렌더러 컴포넌트 +interface QRCodeRendererProps { + value: string; + size: number; + fgColor: string; + bgColor: string; + level: "L" | "M" | "Q" | "H"; +} + +function QRCodeRenderer({ value, size, fgColor, bgColor, level }: QRCodeRendererProps) { + const canvasRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (canvasRef.current && value) { + QRCode.toCanvas( + canvasRef.current, + value, + { + width: Math.max(50, size), + margin: 2, + color: { + dark: fgColor, + light: bgColor, + }, + errorCorrectionLevel: level, + }, + (err) => { + if (err) { + setError("QR코드 생성 실패"); + } else { + setError(null); + } + } + ); + } + }, [value, size, fgColor, bgColor, level]); + + if (error) { + return ( +
+ {error} + {value} +
+ ); + } + + return ; +} interface CanvasComponentProps { component: ComponentConfig; @@ -804,6 +938,70 @@ export function CanvasComponent({ component }: CanvasComponentProps) { ); + case "barcode": + // 바코드/QR코드 컴포넌트 렌더링 + const barcodeType = component.barcodeType || "CODE128"; + const showBarcodeText = component.showBarcodeText !== false; + const barcodeColor = component.barcodeColor || "#000000"; + const barcodeBackground = component.barcodeBackground || "#ffffff"; + const barcodeMargin = component.barcodeMargin ?? 10; + const qrErrorLevel = component.qrErrorCorrectionLevel || "M"; + + // 바코드 값 결정 (쿼리 바인딩 또는 고정값) + const getBarcodeValue = (): string => { + if (component.barcodeFieldName && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + const row = queryResult.rows[0]; + const val = row[component.barcodeFieldName]; + if (val !== null && val !== undefined) { + return String(val); + } + } + return `{${component.barcodeFieldName}}`; + } + return component.barcodeValue || "SAMPLE123"; + }; + + const barcodeValue = getBarcodeValue(); + const isQR = barcodeType === "QR"; + + return ( +
+
+ {isQR ? "QR코드" : `바코드 (${barcodeType})`} + {component.barcodeFieldName && component.queryId && ( + ● 연결됨 + )} +
+
+ {isQR ? ( + + ) : ( + + )} +
+
+ ); + default: return
알 수 없는 컴포넌트
; } diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx index 9dd0543f..b7bdcbb0 100644 --- a/frontend/components/report/designer/ComponentPalette.tsx +++ b/frontend/components/report/designer/ComponentPalette.tsx @@ -1,7 +1,7 @@ "use client"; import { useDrag } from "react-dnd"; -import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator } from "lucide-react"; +import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator, Barcode } from "lucide-react"; interface ComponentItem { type: string; @@ -19,6 +19,7 @@ const COMPONENTS: ComponentItem[] = [ { type: "pageNumber", label: "페이지번호", icon: }, { type: "card", label: "정보카드", icon: }, { type: "calculation", label: "계산", icon: }, + { type: "barcode", label: "바코드/QR", icon: }, ]; function DraggableComponentItem({ type, label, icon }: ComponentItem) { diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index bcf9d88f..c4ea2a27 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -68,6 +68,9 @@ export function ReportDesignerCanvas() { } else if (item.componentType === "pageNumber") { width = 100; height = 30; + } else if (item.componentType === "barcode") { + width = 200; + height = 80; } // 여백을 px로 변환 (1mm ≈ 3.7795px) @@ -204,6 +207,17 @@ export function ReportDesignerCanvas() { showBorder: true, rowHeight: 32, }), + // 바코드 컴포넌트 전용 + ...(item.componentType === "barcode" && { + barcodeType: "CODE128" as const, + barcodeValue: "SAMPLE123", + barcodeFieldName: "", + showBarcodeText: true, + barcodeColor: "#000000", + barcodeBackground: "#ffffff", + barcodeMargin: 10, + qrErrorCorrectionLevel: "M" as const, + }), }; addComponent(newComponent); diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index ff832e21..58dd5047 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -1631,10 +1631,214 @@ export function ReportDesignerRightPanel() { )} - {/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */} + {/* 바코드 컴포넌트 설정 */} + {selectedComponent.type === "barcode" && ( + + + 바코드 설정 + + + {/* 바코드 타입 */} +
+ + +
+ + {/* 바코드 값 입력 (쿼리 연결 없을 때) */} + {!selectedComponent.queryId && ( +
+ + + updateComponent(selectedComponent.id, { + barcodeValue: e.target.value, + }) + } + placeholder={ + selectedComponent.barcodeType === "EAN13" ? "13자리 숫자" : + selectedComponent.barcodeType === "EAN8" ? "8자리 숫자" : + selectedComponent.barcodeType === "UPC" ? "12자리 숫자" : + "바코드에 표시할 값" + } + className="h-8" + /> + {(selectedComponent.barcodeType === "EAN13" || + selectedComponent.barcodeType === "EAN8" || + selectedComponent.barcodeType === "UPC") && ( +

+ {selectedComponent.barcodeType === "EAN13" && "EAN-13: 12~13자리 숫자 필요"} + {selectedComponent.barcodeType === "EAN8" && "EAN-8: 7~8자리 숫자 필요"} + {selectedComponent.barcodeType === "UPC" && "UPC: 11~12자리 숫자 필요"} +

+ )} +
+ )} + + {/* 쿼리 연결 시 필드 선택 */} + {selectedComponent.queryId && ( +
+ + +
+ )} + + {/* 1D 바코드 전용 옵션 */} + {selectedComponent.barcodeType !== "QR" && ( +
+ + updateComponent(selectedComponent.id, { + showBarcodeText: e.target.checked, + }) + } + className="h-4 w-4 rounded border-gray-300" + /> + +
+ )} + + {/* QR 오류 보정 수준 */} + {selectedComponent.barcodeType === "QR" && ( +
+ + +

+ 높을수록 손상에 강하지만 크기 증가 +

+
+ )} + + {/* 색상 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + barcodeColor: e.target.value, + }) + } + className="h-8 w-full" + /> +
+
+ + + updateComponent(selectedComponent.id, { + barcodeBackground: e.target.value, + }) + } + className="h-8 w-full" + /> +
+
+ + {/* 여백 */} +
+ + + updateComponent(selectedComponent.id, { + barcodeMargin: Number(e.target.value), + }) + } + min={0} + max={50} + className="h-8" + /> +
+ + {/* 쿼리 연결 안내 */} + {!selectedComponent.queryId && ( +
+ 쿼리를 연결하면 데이터베이스 값으로 바코드를 생성할 수 있습니다. +
+ )} +
+
+ )} + + {/* 데이터 바인딩 (텍스트/라벨/테이블/바코드 컴포넌트) */} {(selectedComponent.type === "text" || selectedComponent.type === "label" || - selectedComponent.type === "table") && ( + selectedComponent.type === "table" || + selectedComponent.type === "barcode") && (
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a0929370..e4f5a1fd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,6 +46,7 @@ "@turf/union": "^7.2.0", "@types/d3": "^7.4.3", "@types/leaflet": "^1.9.21", + "@types/qrcode": "^1.5.6", "@types/react-window": "^1.8.8", "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", @@ -61,11 +62,13 @@ "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "isomorphic-dompurify": "^2.28.0", + "jsbarcode": "^3.12.1", "jspdf": "^3.0.3", "leaflet": "^1.9.4", "lucide-react": "^0.525.0", "mammoth": "^1.11.0", "next": "^15.4.8", + "qrcode": "^1.5.4", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dnd": "^16.0.1", @@ -91,6 +94,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@tanstack/react-query-devtools": "^5.86.0", + "@types/jsbarcode": "^3.11.4", "@types/node": "^20", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", @@ -6022,6 +6026,16 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, + "node_modules/@types/jsbarcode": { + "version": "3.11.4", + "resolved": "https://registry.npmjs.org/@types/jsbarcode/-/jsbarcode-3.11.4.tgz", + "integrity": "sha512-VBcpTAnEMH0Gbh8JpV14CgOtJjCYjsvR2FoDRyoYPE0gUxtApf8N4c+HKEOyz/iiIZkMzqrzBA3XX7+KgKxxsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6071,7 +6085,6 @@ "version": "20.19.24", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -6089,6 +6102,15 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -6917,11 +6939,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7387,6 +7417,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camera-controls": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.0.tgz", @@ -7529,6 +7568,17 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -7580,7 +7630,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -7593,7 +7642,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -8292,6 +8340,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -8420,6 +8477,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dingbat-to-unicode": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", @@ -9606,6 +9669,15 @@ "quickselect": "^1.0.1" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -10256,6 +10328,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -10593,6 +10674,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbarcode": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/jsbarcode/-/jsbarcode-3.12.1.tgz", + "integrity": "sha512-QZQSqIknC2Rr/YOUyOkCBqsoiBAOTYK+7yNN3JsqfoUtJtkazxNw1dmPpxuv7VVvqW13kA3/mKiLq+s/e3o9hQ==", + "license": "MIT" + }, "node_modules/jsdom": { "version": "27.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz", @@ -11700,6 +11787,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -11735,7 +11831,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11818,6 +11913,15 @@ "pathe": "^2.0.3" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/point-in-polygon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", @@ -12348,6 +12452,23 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12873,6 +12994,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -12882,6 +13012,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -13110,6 +13246,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -13451,6 +13593,26 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -13564,6 +13726,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -14191,7 +14365,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -14511,6 +14684,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -14561,6 +14740,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -14675,6 +14868,99 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 37ba05be..e9cf087c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,6 +54,7 @@ "@turf/union": "^7.2.0", "@types/d3": "^7.4.3", "@types/leaflet": "^1.9.21", + "@types/qrcode": "^1.5.6", "@types/react-window": "^1.8.8", "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", @@ -69,11 +70,13 @@ "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "isomorphic-dompurify": "^2.28.0", + "jsbarcode": "^3.12.1", "jspdf": "^3.0.3", "leaflet": "^1.9.4", "lucide-react": "^0.525.0", "mammoth": "^1.11.0", "next": "^15.4.8", + "qrcode": "^1.5.4", "react": "19.1.0", "react-day-picker": "^9.11.1", "react-dnd": "^16.0.1", @@ -99,6 +102,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@tanstack/react-query-devtools": "^5.86.0", + "@types/jsbarcode": "^3.11.4", "@types/node": "^20", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 46c8db89..bd4c8e68 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -189,6 +189,15 @@ export interface ComponentConfig { showCalcBorder?: boolean; // 테두리 표시 여부 numberFormat?: "none" | "comma" | "currency"; // 숫자 포맷 (없음, 천단위, 원화) currencySuffix?: string; // 통화 접미사 (예: "원") + // 바코드 컴포넌트 전용 + barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR"; // 바코드 타입 + barcodeValue?: string; // 고정값 + barcodeFieldName?: string; // 쿼리 필드 바인딩 + showBarcodeText?: boolean; // 바코드 아래 텍스트 표시 (1D만) + barcodeColor?: string; // 바코드 색상 + barcodeBackground?: string; // 배경 색상 + barcodeMargin?: number; // 여백 + qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; // QR 오류 보정 수준 } // 리포트 상세 From 8d34b73a456431788fec3c5181217c87b4090603 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 19 Dec 2025 18:06:25 +0900 Subject: [PATCH 03/30] =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 91 ++++++++++ backend-node/src/types/report.ts | 8 + .../report/designer/CanvasComponent.tsx | 83 +++++++++ .../report/designer/ComponentPalette.tsx | 3 +- .../report/designer/ReportDesignerCanvas.tsx | 12 ++ .../designer/ReportDesignerRightPanel.tsx | 163 +++++++++++++++++- frontend/types/report.ts | 8 + 7 files changed, 365 insertions(+), 3 deletions(-) diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index 438e02e1..4c6845fa 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -1364,6 +1364,45 @@ export class ReportController { } } + // Checkbox 컴포넌트 + else if (component.type === "checkbox") { + // 체크 상태 결정 (쿼리 바인딩 또는 고정값) + let isChecked = component.checkboxChecked === true; + if (component.checkboxFieldName && component.queryId && queryResultsMapRef[component.queryId]) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[component.checkboxFieldName]; + // truthy/falsy 값 판정 + if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") { + isChecked = true; + } else { + isChecked = false; + } + } + } + + const checkboxSymbol = isChecked ? "☑" : "☐"; + const checkboxLabel = component.checkboxLabel || ""; + const labelPosition = component.checkboxLabelPosition || "right"; + const displayText = labelPosition === "left" + ? `${checkboxLabel} ${checkboxSymbol}` + : `${checkboxSymbol} ${checkboxLabel}`; + + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: displayText.trim(), + size: pxToHalfPtFn(component.fontSize || 14), + font: "맑은 고딕", + color: (component.fontColor || "#374151").replace("#", ""), + }), + ], + }) + ); + } + // Divider - 테이블 셀로 감싸서 정확한 너비 적용 else if ( component.type === "divider" && @@ -2809,6 +2848,58 @@ export class ReportController { lastBottomY = adjustedY + component.height; } + // Checkbox 컴포넌트 + else if (component.type === "checkbox") { + // 체크 상태 결정 (쿼리 바인딩 또는 고정값) + let isChecked = component.checkboxChecked === true; + if (component.checkboxFieldName && component.queryId && queryResultsMap[component.queryId]) { + const qResult = queryResultsMap[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + const row = qResult.rows[0]; + const val = row[component.checkboxFieldName]; + // truthy/falsy 값 판정 + if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") { + isChecked = true; + } else { + isChecked = false; + } + } + } + + const checkboxSymbol = isChecked ? "☑" : "☐"; + const checkboxLabel = component.checkboxLabel || ""; + const labelPosition = component.checkboxLabelPosition || "right"; + const displayText = labelPosition === "left" + ? `${checkboxLabel} ${checkboxSymbol}` + : `${checkboxSymbol} ${checkboxLabel}`; + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push( + new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + children: [], + }) + ); + } + + children.push( + new Paragraph({ + indent: { left: indentLeft }, + children: [ + new TextRun({ + text: displayText.trim(), + size: pxToHalfPt(component.fontSize || 14), + font: "맑은 고딕", + color: (component.fontColor || "#374151").replace("#", ""), + }), + ], + }) + ); + + lastBottomY = adjustedY + component.height; + } + // Table 컴포넌트 else if (component.type === "table" && component.queryId) { const queryResult = queryResultsMap[component.queryId]; diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index e622a65c..f82b2db4 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -260,4 +260,12 @@ export interface ComponentConfig { barcodeBackground?: string; barcodeMargin?: number; qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; + // 체크박스 컴포넌트 전용 + checkboxChecked?: boolean; // 체크 상태 (고정값) + checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값) + checkboxLabel?: string; // 체크박스 옆 레이블 텍스트 + checkboxSize?: number; // 체크박스 크기 (px) + checkboxColor?: string; // 체크 색상 + checkboxBorderColor?: string; // 테두리 색상 + checkboxLabelPosition?: "left" | "right"; // 레이블 위치 } diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index e3f06e9f..12011813 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -1002,6 +1002,89 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
); + case "checkbox": + // 체크박스 컴포넌트 렌더링 + const checkboxSize = component.checkboxSize || 18; + const checkboxColor = component.checkboxColor || "#2563eb"; + const checkboxBorderColor = component.checkboxBorderColor || "#6b7280"; + const checkboxLabelPosition = component.checkboxLabelPosition || "right"; + const checkboxLabel = component.checkboxLabel || ""; + + // 체크 상태 결정 (쿼리 바인딩 또는 고정값) + const getCheckboxValue = (): boolean => { + if (component.checkboxFieldName && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + const row = queryResult.rows[0]; + const val = row[component.checkboxFieldName]; + // truthy/falsy 값 판정 + if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") { + return true; + } + return false; + } + return false; + } + return component.checkboxChecked === true; + }; + + const isChecked = getCheckboxValue(); + + return ( +
+
+ 체크박스 + {component.checkboxFieldName && component.queryId && ( + ● 연결됨 + )} +
+
+ {/* 체크박스 */} +
+ {isChecked && ( + + + + )} +
+ {/* 레이블 */} + {checkboxLabel && ( + + {checkboxLabel} + + )} +
+
+ ); + default: return
알 수 없는 컴포넌트
; } diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx index b7bdcbb0..68f445c4 100644 --- a/frontend/components/report/designer/ComponentPalette.tsx +++ b/frontend/components/report/designer/ComponentPalette.tsx @@ -1,7 +1,7 @@ "use client"; import { useDrag } from "react-dnd"; -import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator, Barcode } from "lucide-react"; +import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator, Barcode, CheckSquare } from "lucide-react"; interface ComponentItem { type: string; @@ -20,6 +20,7 @@ const COMPONENTS: ComponentItem[] = [ { type: "card", label: "정보카드", icon: }, { type: "calculation", label: "계산", icon: }, { type: "barcode", label: "바코드/QR", icon: }, + { type: "checkbox", label: "체크박스", icon: }, ]; function DraggableComponentItem({ type, label, icon }: ComponentItem) { diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index c4ea2a27..e63d1a9d 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -71,6 +71,9 @@ export function ReportDesignerCanvas() { } else if (item.componentType === "barcode") { width = 200; height = 80; + } else if (item.componentType === "checkbox") { + width = 150; + height = 30; } // 여백을 px로 변환 (1mm ≈ 3.7795px) @@ -218,6 +221,15 @@ export function ReportDesignerCanvas() { barcodeMargin: 10, qrErrorCorrectionLevel: "M" as const, }), + // 체크박스 컴포넌트 전용 + ...(item.componentType === "checkbox" && { + checkboxChecked: false, + checkboxLabel: "항목", + checkboxSize: 18, + checkboxColor: "#2563eb", + checkboxBorderColor: "#6b7280", + checkboxLabelPosition: "right" as const, + }), }; addComponent(newComponent); diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index 58dd5047..aef6d1f2 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -1834,11 +1834,170 @@ export function ReportDesignerRightPanel() {
)} - {/* 데이터 바인딩 (텍스트/라벨/테이블/바코드 컴포넌트) */} + {/* 체크박스 컴포넌트 전용 설정 */} + {selectedComponent.type === "checkbox" && ( + + + 체크박스 설정 + + + {/* 체크 상태 (쿼리 연결 없을 때) */} + {!selectedComponent.queryId && ( +
+ + updateComponent(selectedComponent.id, { + checkboxChecked: e.target.checked, + }) + } + className="h-4 w-4 rounded border-gray-300" + /> + +
+ )} + + {/* 쿼리 연결 시 필드 선택 */} + {selectedComponent.queryId && ( +
+ + +

+ true, "Y", 1 등 truthy 값이면 체크됨 +

+
+ )} + + {/* 레이블 텍스트 */} +
+ + + updateComponent(selectedComponent.id, { + checkboxLabel: e.target.value, + }) + } + placeholder="체크박스 옆 텍스트" + className="h-8" + /> +
+ + {/* 레이블 위치 */} +
+ + +
+ + {/* 체크박스 크기 */} +
+ + + updateComponent(selectedComponent.id, { + checkboxSize: Number(e.target.value), + }) + } + min={12} + max={40} + className="h-8" + /> +
+ + {/* 색상 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + checkboxColor: e.target.value, + }) + } + className="h-8 w-full" + /> +
+
+ + + updateComponent(selectedComponent.id, { + checkboxBorderColor: e.target.value, + }) + } + className="h-8 w-full" + /> +
+
+ + {/* 쿼리 연결 안내 */} + {!selectedComponent.queryId && ( +
+ 쿼리를 연결하면 데이터베이스 값으로 체크 상태를 결정할 수 있습니다. +
+ )} +
+
+ )} + + {/* 데이터 바인딩 (텍스트/라벨/테이블/바코드/체크박스 컴포넌트) */} {(selectedComponent.type === "text" || selectedComponent.type === "label" || selectedComponent.type === "table" || - selectedComponent.type === "barcode") && ( + selectedComponent.type === "barcode" || + selectedComponent.type === "checkbox") && (
diff --git a/frontend/types/report.ts b/frontend/types/report.ts index bd4c8e68..9a01638a 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -198,6 +198,14 @@ export interface ComponentConfig { barcodeBackground?: string; // 배경 색상 barcodeMargin?: number; // 여백 qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; // QR 오류 보정 수준 + // 체크박스 컴포넌트 전용 + checkboxChecked?: boolean; // 체크 상태 (고정값) + checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값) + checkboxLabel?: string; // 체크박스 옆 레이블 텍스트 + checkboxSize?: number; // 체크박스 크기 (px) + checkboxColor?: string; // 체크 색상 + checkboxBorderColor?: string; // 테두리 색상 + checkboxLabelPosition?: "left" | "right"; // 레이블 위치 } // 리포트 상세 From 8789b2b86447c99daeffa3817845810cb1389ae1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 19 Dec 2025 18:19:29 +0900 Subject: [PATCH 04/30] =?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=84=A0=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 76 +++++++++++++------ .../report/designer/ReportDesignerCanvas.tsx | 2 +- .../designer/ReportDesignerRightPanel.tsx | 12 ++- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 12011813..85eed9f0 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -310,11 +310,26 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const boundedWidth = Math.min(newWidth, maxWidth); const boundedHeight = Math.min(newHeight, maxHeight); - // Grid Snap 적용 - updateComponent(component.id, { - width: snapValueToGrid(boundedWidth), - height: snapValueToGrid(boundedHeight), - }); + // 구분선은 방향에 따라 한 축만 조절 가능 + if (component.type === "divider") { + if (component.orientation === "vertical") { + // 세로 구분선: 높이만 조절 + updateComponent(component.id, { + height: snapValueToGrid(boundedHeight), + }); + } else { + // 가로 구분선: 너비만 조절 + updateComponent(component.id, { + width: snapValueToGrid(boundedWidth), + }); + } + } else { + // Grid Snap 적용 + updateComponent(component.id, { + width: snapValueToGrid(boundedWidth), + height: snapValueToGrid(boundedHeight), + }); + } } }; @@ -546,21 +561,23 @@ export function CanvasComponent({ component }: CanvasComponentProps) { ); case "divider": - const lineWidth = component.lineWidth || 1; - const lineColor = component.lineColor || "#000000"; + // 구분선 (가로: 너비만 조절, 세로: 높이만 조절) + const dividerLineWidth = component.lineWidth || 1; + const dividerLineColor = component.lineColor || "#000000"; + const isHorizontal = component.orientation !== "vertical"; return ( -
+
@@ -1093,7 +1109,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { return (
)} diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index e63d1a9d..47bfa7b5 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -58,7 +58,7 @@ export function ReportDesignerCanvas() { height = 150; } else if (item.componentType === "divider") { width = 300; - height = 2; + height = 10; // 선 두께 + 여백 (선택/드래그를 위한 최소 높이) } else if (item.componentType === "signature") { width = 120; height = 70; diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index aef6d1f2..e648ca8b 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -562,11 +562,17 @@ export function ReportDesignerRightPanel() { - updateComponent(selectedComponent.id, { - barcodeType: value as "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR", - }) - } + onValueChange={(value) => { + const newType = value as "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR"; + // QR코드는 정사각형으로 크기 조정 + if (newType === "QR") { + const size = Math.max(selectedComponent.width, selectedComponent.height); + updateComponent(selectedComponent.id, { + barcodeType: newType, + width: size, + height: size, + }); + } else { + updateComponent(selectedComponent.id, { + barcodeType: newType, + }); + } + }} > From 2b912105a82349a3e7eb93d3d23307bcb7aacb56 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 13:36:42 +0900 Subject: [PATCH 10/30] =?UTF-8?q?=20QR=EC=BD=94=EB=93=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=ED=95=84=EB=93=9C=20JSON=20=EB=B0=8F=20=EB=AA=A8?= =?UTF-8?q?=EB=93=A0=20=ED=96=89=20=ED=8F=AC=ED=95=A8=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 63 +++++- backend-node/src/types/report.ts | 7 + .../report/designer/CanvasComponent.tsx | 66 ++++++ .../designer/ReportDesignerRightPanel.tsx | 202 +++++++++++++++--- frontend/types/report.ts | 7 + 5 files changed, 313 insertions(+), 32 deletions(-) diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index 4c6845fa..124eb265 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -1443,13 +1443,66 @@ export class ReportController { // 바코드 값 결정 (쿼리 바인딩 또는 고정값) let barcodeValue = component.barcodeValue || "SAMPLE123"; - if (component.barcodeFieldName && component.queryId && queryResultsMapRef[component.queryId]) { + + // QR코드 다중 필드 모드 + if ( + barcodeType === "QR" && + component.qrUseMultiField && + component.qrDataFields && + component.qrDataFields.length > 0 && + component.queryId && + queryResultsMapRef[component.queryId] + ) { const qResult = queryResultsMapRef[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { - const row = qResult.rows[0]; - const val = row[component.barcodeFieldName]; - if (val !== null && val !== undefined) { - barcodeValue = String(val); + // 모든 행 포함 모드 + if (component.qrIncludeAllRows) { + const allRowsData: Record[] = []; + qResult.rows.forEach((row) => { + const rowData: Record = {}; + component.qrDataFields!.forEach((field: { fieldName: string; label: string }) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + rowData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + allRowsData.push(rowData); + }); + barcodeValue = JSON.stringify(allRowsData); + } else { + // 단일 행 (첫 번째 행만) + const row = qResult.rows[0]; + const jsonData: Record = {}; + component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + jsonData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + barcodeValue = JSON.stringify(jsonData); + } + } + } + // 단일 필드 바인딩 + else if (component.barcodeFieldName && component.queryId && queryResultsMapRef[component.queryId]) { + const qResult = queryResultsMapRef[component.queryId]; + if (qResult.rows && qResult.rows.length > 0) { + // QR코드 + 모든 행 포함 + if (barcodeType === "QR" && component.qrIncludeAllRows) { + const allValues = qResult.rows + .map((row) => { + const val = row[component.barcodeFieldName!]; + return val !== null && val !== undefined ? String(val) : ""; + }) + .filter((v) => v !== ""); + barcodeValue = JSON.stringify(allValues); + } else { + // 단일 행 (첫 번째 행만) + const row = qResult.rows[0]; + const val = row[component.barcodeFieldName]; + if (val !== null && val !== undefined) { + barcodeValue = String(val); + } } } } diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index f82b2db4..23a7496d 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -260,6 +260,13 @@ export interface ComponentConfig { barcodeBackground?: string; barcodeMargin?: number; qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; + // QR코드 다중 필드 (JSON 형식) + qrDataFields?: Array<{ + fieldName: string; + label: string; + }>; + qrUseMultiField?: boolean; + qrIncludeAllRows?: boolean; // 체크박스 컴포넌트 전용 checkboxChecked?: boolean; // 체크 상태 (고정값) checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 2eefca42..8816bfe4 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -970,15 +970,81 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // 바코드 값 결정 (쿼리 바인딩 또는 고정값) const getBarcodeValue = (): string => { + // QR코드 다중 필드 모드 + if ( + barcodeType === "QR" && + component.qrUseMultiField && + component.qrDataFields && + component.qrDataFields.length > 0 && + component.queryId + ) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + // 모든 행 포함 모드 + if (component.qrIncludeAllRows) { + const allRowsData: Record[] = []; + queryResult.rows.forEach((row) => { + const rowData: Record = {}; + component.qrDataFields!.forEach((field) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + rowData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + allRowsData.push(rowData); + }); + return JSON.stringify(allRowsData); + } + + // 단일 행 (첫 번째 행만) + const row = queryResult.rows[0]; + const jsonData: Record = {}; + component.qrDataFields.forEach((field) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + jsonData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + return JSON.stringify(jsonData); + } + // 쿼리 결과가 없으면 플레이스홀더 표시 + const placeholderData: Record = {}; + component.qrDataFields.forEach((field) => { + if (field.label) { + placeholderData[field.label] = `{${field.fieldName || "field"}}`; + } + }); + return component.qrIncludeAllRows + ? JSON.stringify([placeholderData, { "...": "..." }]) + : JSON.stringify(placeholderData); + } + + // 단일 필드 바인딩 if (component.barcodeFieldName && component.queryId) { const queryResult = getQueryResult(component.queryId); if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + // QR코드 + 모든 행 포함 + if (barcodeType === "QR" && component.qrIncludeAllRows) { + const allValues = queryResult.rows + .map((row) => { + const val = row[component.barcodeFieldName!]; + return val !== null && val !== undefined ? String(val) : ""; + }) + .filter((v) => v !== ""); + return JSON.stringify(allValues); + } + + // 단일 행 (첫 번째 행만) const row = queryResult.rows[0]; const val = row[component.barcodeFieldName]; if (val !== null && val !== undefined) { return String(val); } } + // 플레이스홀더 + if (barcodeType === "QR" && component.qrIncludeAllRows) { + return JSON.stringify([`{${component.barcodeFieldName}}`, "..."]); + } return `{${component.barcodeFieldName}}`; } return component.barcodeValue || "SAMPLE123"; diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index 635ffd9e..c5b53e25 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -9,7 +9,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Trash2, Settings, Database, Link2, Upload, Loader2 } from "lucide-react"; +import { Trash2, Settings, Database, Link2, Upload, Loader2, X } from "lucide-react"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { QueryManager } from "./QueryManager"; import { SignaturePad } from "./SignaturePad"; @@ -1714,35 +1714,183 @@ export function ReportDesignerRightPanel() { {/* 쿼리 연결 시 필드 선택 */} {selectedComponent.queryId && ( -
- - + updateComponent(selectedComponent.id, { + qrUseMultiField: e.target.checked, + // 다중 필드 모드 활성화 시 단일 필드 초기화 + ...(e.target.checked && { barcodeFieldName: "" }), + }) + } + className="h-4 w-4 rounded border-gray-300" + /> + +
+ )} + + {/* 단일 필드 모드 (1D 바코드 또는 QR 단일 모드) */} + {(selectedComponent.barcodeType !== "QR" || !selectedComponent.qrUseMultiField) && ( +
+ + +
+ )} + + {/* QR코드 다중 필드 모드 UI */} + {selectedComponent.barcodeType === "QR" && selectedComponent.qrUseMultiField && ( +
+
+ + +
+ + {/* 필드 목록 */} +
+ {(selectedComponent.qrDataFields || []).map((field, index) => ( +
+
+ + { + const newFields = [...(selectedComponent.qrDataFields || [])]; + newFields[index] = { ...newFields[index], label: e.target.value }; + updateComponent(selectedComponent.id, { qrDataFields: newFields }); + }} + placeholder="JSON 키 이름" + className="h-7 text-xs" + /> +
+ +
+ ))} +
+ + {(selectedComponent.qrDataFields || []).length === 0 && ( +

+ 필드를 추가하세요 +

+ )} + +

+ 결과: {selectedComponent.qrIncludeAllRows + ? `[{"${(selectedComponent.qrDataFields || []).map(f => f.label || "key").join('":"값","')}"}, ...]` + : `{"${(selectedComponent.qrDataFields || []).map(f => f.label || "key").join('":"값","')}":"값"}` + } +

+
+ )} + + )} + + {/* QR코드 모든 행 포함 옵션 (다중 필드와 독립) */} + {selectedComponent.barcodeType === "QR" && selectedComponent.queryId && ( +
+ updateComponent(selectedComponent.id, { - barcodeFieldName: value === "none" ? "" : value, + qrIncludeAllRows: e.target.checked, }) } - > - - - - - 선택 안함 - {(() => { - const query = queries.find((q) => q.id === selectedComponent.queryId); - const result = query ? getQueryResult(query.id) : null; - if (result && result.fields) { - return result.fields.map((field: string) => ( - - {field} - - )); - } - return null; - })()} - - + className="h-4 w-4 rounded border-gray-300" + /> +
)} diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 9a01638a..5a61e5b9 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -198,6 +198,13 @@ export interface ComponentConfig { barcodeBackground?: string; // 배경 색상 barcodeMargin?: number; // 여백 qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; // QR 오류 보정 수준 + // QR코드 다중 필드 (JSON 형식) + qrDataFields?: Array<{ + fieldName: string; // 쿼리 필드명 + label: string; // JSON 키 이름 + }>; + qrUseMultiField?: boolean; // 다중 필드 사용 여부 + qrIncludeAllRows?: boolean; // 모든 행 포함 (배열 JSON) // 체크박스 컴포넌트 전용 checkboxChecked?: boolean; // 체크 상태 (고정값) checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값) From 0decfe95de1d86b5141a28309ebb5c4d7c0f291b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 13:58:12 +0900 Subject: [PATCH 11/30] =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0/?= =?UTF-8?q?=EC=9D=B8=EC=87=84=EC=97=90=20=EB=B0=94=EC=BD=94=EB=93=9C,=20QR?= =?UTF-8?q?=EC=BD=94=EB=93=9C,=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 2 +- .../report/designer/ReportPreviewModal.tsx | 410 +++++++++++++++++- 2 files changed, 404 insertions(+), 8 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 8816bfe4..1329f847 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, useEffect, useCallback } from "react"; +import { useRef, useState, useEffect } from "react"; import { ComponentConfig } from "@/types/report"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { getFullImageUrl } from "@/lib/api/client"; diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index ded27f37..0851fa92 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -11,15 +11,162 @@ import { import { Button } from "@/components/ui/button"; import { Printer, FileDown, FileText } from "lucide-react"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useToast } from "@/hooks/use-toast"; import { getFullImageUrl } from "@/lib/api/client"; +import JsBarcode from "jsbarcode"; +import QRCode from "qrcode"; interface ReportPreviewModalProps { isOpen: boolean; onClose: () => void; } +// 바코드/QR코드 미리보기 컴포넌트 +function BarcodePreview({ + component, + getQueryResult, +}: { + component: any; + getQueryResult: (queryId: string) => { fields: string[]; rows: Record[] } | null; +}) { + const svgRef = useRef(null); + const canvasRef = useRef(null); + const [error, setError] = useState(null); + + const barcodeType = component.barcodeType || "CODE128"; + const isQR = barcodeType === "QR"; + + // 바코드 값 결정 + const getBarcodeValue = (): string => { + // QR코드 다중 필드 모드 + if ( + isQR && + component.qrUseMultiField && + component.qrDataFields && + component.qrDataFields.length > 0 && + component.queryId + ) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + if (component.qrIncludeAllRows) { + const allRowsData: Record[] = []; + queryResult.rows.forEach((row) => { + const rowData: Record = {}; + component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + rowData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + allRowsData.push(rowData); + }); + return JSON.stringify(allRowsData); + } + const row = queryResult.rows[0]; + const jsonData: Record = {}; + component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + jsonData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + return JSON.stringify(jsonData); + } + } + + // 단일 필드 바인딩 + if (component.barcodeFieldName && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + if (isQR && component.qrIncludeAllRows) { + const allValues = queryResult.rows + .map((row) => { + const val = row[component.barcodeFieldName]; + return val !== null && val !== undefined ? String(val) : ""; + }) + .filter((v) => v !== ""); + return JSON.stringify(allValues); + } + const row = queryResult.rows[0]; + const val = row[component.barcodeFieldName]; + if (val !== null && val !== undefined) { + return String(val); + } + } + return `{${component.barcodeFieldName}}`; + } + return component.barcodeValue || "SAMPLE123"; + }; + + const barcodeValue = getBarcodeValue(); + + useEffect(() => { + setError(null); + + if (isQR) { + // QR코드 렌더링 + if (canvasRef.current && barcodeValue) { + const bgColor = component.barcodeBackground === "transparent" ? "#ffffff" : (component.barcodeBackground || "#ffffff"); + QRCode.toCanvas( + canvasRef.current, + barcodeValue, + { + width: Math.min(component.width, component.height) - 20, + margin: 2, + color: { + dark: component.barcodeColor || "#000000", + light: bgColor, + }, + errorCorrectionLevel: component.qrErrorCorrectionLevel || "M", + }, + (err) => { + if (err) setError(err.message || "QR코드 생성 실패"); + } + ); + } + } else { + // 1D 바코드 렌더링 + if (svgRef.current && barcodeValue) { + try { + const bgColor = component.barcodeBackground === "transparent" ? "" : (component.barcodeBackground || "#ffffff"); + JsBarcode(svgRef.current, barcodeValue.trim(), { + format: barcodeType.toLowerCase(), + width: 2, + height: Math.max(30, component.height - 40), + displayValue: component.showBarcodeText !== false, + lineColor: component.barcodeColor || "#000000", + background: bgColor, + margin: component.barcodeMargin ?? 10, + fontSize: 12, + textMargin: 2, + }); + } catch (err: any) { + setError(err?.message || "바코드 생성 실패"); + } + } + } + }, [barcodeValue, barcodeType, isQR, component]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {isQR ? ( + + ) : ( + + )} +
+ ); +} + export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) { const { layoutConfig, getQueryResult, reportDetail } = useReportDesigner(); const [isExporting, setIsExporting] = useState(false); @@ -40,9 +187,131 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) return component.defaultValue || "텍스트"; }; - const handlePrint = () => { + // 바코드/QR코드를 base64 이미지로 변환 + const generateBarcodeImage = async (component: any): Promise => { + const barcodeType = component.barcodeType || "CODE128"; + const isQR = barcodeType === "QR"; + + // 바코드 값 결정 + const getBarcodeValue = (): string => { + if ( + isQR && + component.qrUseMultiField && + component.qrDataFields && + component.qrDataFields.length > 0 && + component.queryId + ) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + if (component.qrIncludeAllRows) { + const allRowsData: Record[] = []; + queryResult.rows.forEach((row) => { + const rowData: Record = {}; + component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + rowData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + allRowsData.push(rowData); + }); + return JSON.stringify(allRowsData); + } + const row = queryResult.rows[0]; + const jsonData: Record = {}; + component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { + if (field.fieldName && field.label) { + const val = row[field.fieldName]; + jsonData[field.label] = val !== null && val !== undefined ? String(val) : ""; + } + }); + return JSON.stringify(jsonData); + } + } + + if (component.barcodeFieldName && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + if (isQR && component.qrIncludeAllRows) { + const allValues = queryResult.rows + .map((row) => { + const val = row[component.barcodeFieldName]; + return val !== null && val !== undefined ? String(val) : ""; + }) + .filter((v) => v !== ""); + return JSON.stringify(allValues); + } + const row = queryResult.rows[0]; + const val = row[component.barcodeFieldName]; + if (val !== null && val !== undefined) { + return String(val); + } + } + } + return component.barcodeValue || "SAMPLE123"; + }; + + const barcodeValue = getBarcodeValue(); + + try { + if (isQR) { + // QR코드를 canvas에 렌더링 후 base64로 변환 + const canvas = document.createElement("canvas"); + const bgColor = component.barcodeBackground === "transparent" ? "#ffffff" : (component.barcodeBackground || "#ffffff"); + await QRCode.toCanvas(canvas, barcodeValue, { + width: Math.min(component.width, component.height) - 10, + margin: 2, + color: { + dark: component.barcodeColor || "#000000", + light: bgColor, + }, + errorCorrectionLevel: component.qrErrorCorrectionLevel || "M", + }); + return canvas.toDataURL("image/png"); + } else { + // 1D 바코드를 SVG로 렌더링 후 base64로 변환 + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const bgColor = component.barcodeBackground === "transparent" ? "" : (component.barcodeBackground || "#ffffff"); + JsBarcode(svg, barcodeValue.trim(), { + format: barcodeType.toLowerCase(), + width: 2, + height: Math.max(30, component.height - 40), + displayValue: component.showBarcodeText !== false, + lineColor: component.barcodeColor || "#000000", + background: bgColor, + margin: component.barcodeMargin ?? 10, + fontSize: 12, + textMargin: 2, + }); + const svgData = new XMLSerializer().serializeToString(svg); + const svgBase64 = btoa(unescape(encodeURIComponent(svgData))); + return `data:image/svg+xml;base64,${svgBase64}`; + } + } catch (error) { + console.error("바코드 생성 오류:", error); + return null; + } + }; + + const handlePrint = async () => { + // 바코드 이미지 미리 생성 + const pagesWithBarcodes = await Promise.all( + layoutConfig.pages.map(async (page) => { + const componentsWithBarcodes = await Promise.all( + (Array.isArray(page.components) ? page.components : []).map(async (component) => { + if (component.type === "barcode") { + const barcodeImage = await generateBarcodeImage(component); + return { ...component, barcodeImageBase64: barcodeImage }; + } + return component; + }) + ); + return { ...page, components: componentsWithBarcodes }; + }) + ); + // HTML 생성하여 인쇄 - const printHtml = generatePrintHTML(); + const printHtml = generatePrintHTML(pagesWithBarcodes); const printWindow = window.open("", "_blank"); if (!printWindow) return; @@ -298,6 +567,46 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
`; } + // 바코드/QR코드 컴포넌트 (인쇄용 - base64 이미지 사용) + else if (component.type === "barcode") { + // 바코드 이미지는 미리 생성된 base64 사용 (handlePrint에서 생성) + const barcodeImage = (component as any).barcodeImageBase64; + if (barcodeImage) { + content = ``; + } else { + content = `
바코드
`; + } + } + + // 체크박스 컴포넌트 (인쇄용) + else if (component.type === "checkbox") { + const checkboxSize = component.checkboxSize || 18; + const checkboxColor = component.checkboxColor || "#2563eb"; + const checkboxBorderColor = component.checkboxBorderColor || "#6b7280"; + const checkboxLabel = component.checkboxLabel || ""; + const checkboxLabelPosition = component.checkboxLabelPosition || "right"; + + // 체크 상태 결정 + let isChecked = component.checkboxChecked === true; + if (component.checkboxFieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) { + const val = queryResult.rows[0][component.checkboxFieldName]; + isChecked = val === true || val === "Y" || val === "1" || val === 1 || val === "true"; + } + + const checkboxHTML = ` +
+ ${isChecked ? `` : ""} +
+ `; + + content = ` +
+ ${checkboxHTML} + ${checkboxLabel ? `${checkboxLabel}` : ""} +
+ `; + } + // Table 컴포넌트 else if (component.type === "table" && queryResult && queryResult.rows.length > 0) { const columns = @@ -347,8 +656,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }; // 모든 페이지 HTML 생성 (인쇄/PDF용) - const generatePrintHTML = (): string => { - const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order); + const generatePrintHTML = (pagesWithBarcodes?: any[]): string => { + const pages = pagesWithBarcodes || layoutConfig.pages; + const sortedPages = pages.sort((a, b) => a.page_order - b.page_order); const totalPages = sortedPages.length; const pagesHTML = sortedPages @@ -422,8 +732,24 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }; // PDF 다운로드 (브라우저 인쇄 기능 이용) - const handleDownloadPDF = () => { - const printHtml = generatePrintHTML(); + const handleDownloadPDF = async () => { + // 바코드 이미지 미리 생성 + const pagesWithBarcodes = await Promise.all( + layoutConfig.pages.map(async (page) => { + const componentsWithBarcodes = await Promise.all( + (Array.isArray(page.components) ? page.components : []).map(async (component) => { + if (component.type === "barcode") { + const barcodeImage = await generateBarcodeImage(component); + return { ...component, barcodeImageBase64: barcodeImage }; + } + return component; + }) + ); + return { ...page, components: componentsWithBarcodes }; + }) + ); + + const printHtml = generatePrintHTML(pagesWithBarcodes); const printWindow = window.open("", "_blank"); if (!printWindow) return; @@ -1113,6 +1439,76 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
); })()} + + {/* 바코드/QR코드 컴포넌트 */} + {component.type === "barcode" && ( + + )} + + {/* 체크박스 컴포넌트 */} + {component.type === "checkbox" && (() => { + const checkboxSize = component.checkboxSize || 18; + const checkboxColor = component.checkboxColor || "#2563eb"; + const checkboxBorderColor = component.checkboxBorderColor || "#6b7280"; + const checkboxLabel = component.checkboxLabel || ""; + const checkboxLabelPosition = component.checkboxLabelPosition || "right"; + + // 체크 상태 결정 + let isChecked = component.checkboxChecked === true; + if (component.checkboxFieldName && component.queryId) { + const qResult = getQueryResult(component.queryId); + if (qResult && qResult.rows && qResult.rows.length > 0) { + const val = qResult.rows[0][component.checkboxFieldName]; + isChecked = val === true || val === "Y" || val === "1" || val === 1 || val === "true"; + } + } + + return ( +
+
+ {isChecked && ( + + + + )} +
+ {checkboxLabel && ( + {checkboxLabel} + )} +
+ ); + })()}
); })} From 117912045f4b97dfca43d80ba818087ab04aa917 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 15:13:49 +0900 Subject: [PATCH 12/30] =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=20=ED=8C=A9=ED=84=B0=EB=A5=BC=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20px=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 25 +++++++++------- .../report/designer/ReportDesignerCanvas.tsx | 30 +++++++++++-------- frontend/components/report/designer/Ruler.tsx | 7 +++-- frontend/contexts/ReportDesignerContext.tsx | 7 +++-- 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 1329f847..238b79a9 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -7,6 +7,9 @@ import { getFullImageUrl } from "@/lib/api/client"; import JsBarcode from "jsbarcode"; import QRCode from "qrcode"; +// 고정 스케일 팩터 (화면 해상도와 무관) +const MM_TO_PX = 4; + // 1D 바코드 렌더러 컴포넌트 interface BarcodeRendererProps { value: string; @@ -252,15 +255,15 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const newX = Math.max(0, e.clientX - dragStart.x); const newY = Math.max(0, e.clientY - dragStart.y); - // 여백을 px로 변환 (1mm ≈ 3.7795px) - const marginTopPx = margins.top * 3.7795; - const marginBottomPx = margins.bottom * 3.7795; - const marginLeftPx = margins.left * 3.7795; - const marginRightPx = margins.right * 3.7795; + // 여백을 px로 변환 + const marginTopPx = margins.top * MM_TO_PX; + const marginBottomPx = margins.bottom * MM_TO_PX; + const marginLeftPx = margins.left * MM_TO_PX; + const marginRightPx = margins.right * MM_TO_PX; // 캔버스 경계 체크 (mm를 px로 변환) - const canvasWidthPx = canvasWidth * 3.7795; - const canvasHeightPx = canvasHeight * 3.7795; + const canvasWidthPx = canvasWidth * MM_TO_PX; + const canvasHeightPx = canvasHeight * MM_TO_PX; // 컴포넌트가 여백 안에 있도록 제한 const minX = marginLeftPx; @@ -312,12 +315,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const newHeight = Math.max(30, resizeStart.height + deltaY); // 여백을 px로 변환 - const marginRightPx = margins.right * 3.7795; - const marginBottomPx = margins.bottom * 3.7795; + const marginRightPx = margins.right * MM_TO_PX; + const marginBottomPx = margins.bottom * MM_TO_PX; // 캔버스 경계 체크 - const canvasWidthPx = canvasWidth * 3.7795; - const canvasHeightPx = canvasHeight * 3.7795; + const canvasWidthPx = canvasWidth * MM_TO_PX; + const canvasHeightPx = canvasHeight * MM_TO_PX; // 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한 const maxWidth = canvasWidthPx - marginRightPx - component.x; diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index 08bf9c1b..f278cd97 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -8,6 +8,10 @@ import { CanvasComponent } from "./CanvasComponent"; import { Ruler } from "./Ruler"; import { v4 as uuidv4 } from "uuid"; +// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정) +// A4 기준: 210mm x 297mm → 840px x 1188px +export const MM_TO_PX = 4; + export function ReportDesignerCanvas() { const canvasRef = useRef(null); const { @@ -76,15 +80,15 @@ export function ReportDesignerCanvas() { height = 30; } - // 여백을 px로 변환 (1mm ≈ 3.7795px) - const marginTopPx = margins.top * 3.7795; - const marginLeftPx = margins.left * 3.7795; - const marginRightPx = margins.right * 3.7795; - const marginBottomPx = margins.bottom * 3.7795; + // 여백을 px로 변환 + const marginTopPx = margins.top * MM_TO_PX; + const marginLeftPx = margins.left * MM_TO_PX; + const marginRightPx = margins.right * MM_TO_PX; + const marginBottomPx = margins.bottom * MM_TO_PX; // 캔버스 경계 (px) - const canvasWidthPx = canvasWidth * 3.7795; - const canvasHeightPx = canvasHeight * 3.7795; + const canvasWidthPx = canvasWidth * MM_TO_PX; + const canvasHeightPx = canvasHeight * MM_TO_PX; // 드롭 위치 계산 (여백 내부로 제한) const rawX = x - 100; @@ -402,8 +406,8 @@ export function ReportDesignerCanvas() { }} className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`} style={{ - width: `${canvasWidth}mm`, - minHeight: `${canvasHeight}mm`, + width: `${canvasWidth * MM_TO_PX}px`, + minHeight: `${canvasHeight * MM_TO_PX}px`, backgroundImage: showGrid ? ` linear-gradient(to right, #e5e7eb 1px, transparent 1px), @@ -419,10 +423,10 @@ export function ReportDesignerCanvas() {
)} diff --git a/frontend/components/report/designer/Ruler.tsx b/frontend/components/report/designer/Ruler.tsx index 6f2dc1a2..e293c593 100644 --- a/frontend/components/report/designer/Ruler.tsx +++ b/frontend/components/report/designer/Ruler.tsx @@ -8,9 +8,12 @@ interface RulerProps { offset?: number; // 스크롤 오프셋 (px) } +// 고정 스케일 팩터 (화면 해상도와 무관) +const MM_TO_PX = 4; + export function Ruler({ orientation, length, offset = 0 }: RulerProps) { - // mm를 px로 변환 (1mm = 3.7795px, 96dpi 기준) - const mmToPx = (mm: number) => mm * 3.7795; + // mm를 px로 변환 + const mmToPx = (mm: number) => mm * MM_TO_PX; const lengthPx = mmToPx(length); const isHorizontal = orientation === "horizontal"; diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index 324c0847..206f56db 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -803,9 +803,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin const horizontalLines: number[] = []; const threshold = 5; // 5px 오차 허용 - // 캔버스를 픽셀로 변환 (1mm = 3.7795px) - const canvasWidthPx = canvasWidth * 3.7795; - const canvasHeightPx = canvasHeight * 3.7795; + // 캔버스를 픽셀로 변환 (고정 스케일 팩터: 1mm = 4px) + const MM_TO_PX = 4; + const canvasWidthPx = canvasWidth * MM_TO_PX; + const canvasHeightPx = canvasHeight * MM_TO_PX; const canvasCenterX = canvasWidthPx / 2; const canvasCenterY = canvasHeightPx / 2; From 002c71f9e8f0d60def72e321b37d0571383301b6 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 15:21:14 +0900 Subject: [PATCH 13/30] =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=20=ED=95=9C=EA=B8=80=20=ED=8F=B0=ED=8A=B8=EA=B0=80?= =?UTF-8?q?=20=EC=9D=BC=EB=B6=80=20=EA=B8=80=EC=9E=90=EB=A7=8C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/globals.css | 4 ++-- .../report/designer/SignatureGenerator.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index be16f68d..06b7bd27 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,5 +1,5 @@ -/* ===== 서명용 손글씨 폰트 ===== */ -@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap"); +/* ===== 서명용 손글씨 폰트 (완전한 한글 지원 폰트) ===== */ +@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Hi+Melody&family=Gamja+Flower&family=Poor+Story&family=Do+Hyeon&family=Jua&display=swap"); /* ===== Tailwind CSS & Animations ===== */ @import "tailwindcss"; diff --git a/frontend/components/report/designer/SignatureGenerator.tsx b/frontend/components/report/designer/SignatureGenerator.tsx index db152157..9a3bd29f 100644 --- a/frontend/components/report/designer/SignatureGenerator.tsx +++ b/frontend/components/report/designer/SignatureGenerator.tsx @@ -13,17 +13,17 @@ interface SignatureGeneratorProps { onSignatureSelect: (dataUrl: string) => void; } -// 서명용 손글씨 폰트 목록 (스타일이 확실히 구분되는 폰트들) +// 서명용 손글씨 폰트 목록 (완전한 한글 지원 폰트만 사용) const SIGNATURE_FONTS = { korean: [ { name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 }, { name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 }, - { name: "배달의민족 도현", style: "Dokdo, cursive", weight: 400 }, - { name: "귀여운", style: "Gugi, cursive", weight: 400 }, - { name: "싱글데이", style: "'Single Day', cursive", weight: 400 }, - { name: "스타일리시", style: "Stylish, cursive", weight: 400 }, - { name: "해바라기", style: "Sunflower, sans-serif", weight: 700 }, - { name: "손글씨", style: "Gaegu, cursive", weight: 700 }, + { name: "손글씨 (Gaegu)", style: "Gaegu, cursive", weight: 700 }, + { name: "하이멜로디", style: "'Hi Melody', cursive", weight: 400 }, + { name: "감자꽃", style: "'Gamja Flower', cursive", weight: 400 }, + { name: "푸어스토리", style: "'Poor Story', cursive", weight: 400 }, + { name: "도현", style: "'Do Hyeon', sans-serif", weight: 400 }, + { name: "주아", style: "Jua, sans-serif", weight: 400 }, ], english: [ { name: "Allura (우아한)", style: "Allura, cursive", weight: 400 }, From d90e68905e3285a92b9755f0b6ab6f9174515ea2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 15:40:31 +0900 Subject: [PATCH 14/30] =?UTF-8?q?=EC=9B=8C=ED=84=B0=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 35 ++ backend-node/src/types/report.ts | 17 + .../report/designer/ReportDesignerCanvas.tsx | 188 +++++++- .../designer/ReportDesignerRightPanel.tsx | 409 ++++++++++++++++++ .../report/designer/ReportPreviewModal.tsx | 265 +++++++++++- frontend/types/report.ts | 17 + 6 files changed, 929 insertions(+), 2 deletions(-) diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index 124eb265..5f755947 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -27,7 +27,11 @@ import { BorderStyle, PageOrientation, convertMillimetersToTwip, + Header, + Footer, + HeadingLevel, } from "docx"; +import { WatermarkConfig } from "../types/report"; import bwipjs from "bwip-js"; export class ReportController { @@ -3063,6 +3067,36 @@ export class ReportController { children.push(new Paragraph({ children: [] })); } + // 워터마크 헤더 생성 (워터마크가 활성화된 경우) + const watermark: WatermarkConfig | undefined = page.watermark; + let headers: { default?: Header } | undefined; + + if (watermark?.enabled && watermark.type === "text" && watermark.text) { + // 워터마크 색상을 hex로 변환 (alpha 적용) + const opacity = watermark.opacity ?? 0.3; + const fontColor = watermark.fontColor || "#CCCCCC"; + // hex 색상에서 # 제거 + const cleanColor = fontColor.replace("#", ""); + + headers = { + default: new Header({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: watermark.text, + size: (watermark.fontSize || 48) * 2, // Word는 half-point 사용 + color: cleanColor, + bold: true, + }), + ], + }), + ], + }), + }; + } + return { properties: { page: { @@ -3082,6 +3116,7 @@ export class ReportController { }, }, }, + headers, children, }; }); diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index 23a7496d..d5641cff 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -116,6 +116,22 @@ export interface UpdateReportRequest { useYn?: string; } +// 워터마크 설정 +export interface WatermarkConfig { + enabled: boolean; + type: "text" | "image"; + // 텍스트 워터마크 + text?: string; + fontSize?: number; + fontColor?: string; + // 이미지 워터마크 + imageUrl?: string; + // 공통 설정 + opacity: number; // 0~1 + style: "diagonal" | "center" | "tile"; + rotation?: number; // 대각선일 때 각도 (기본 -45) +} + // 페이지 설정 export interface PageConfig { page_id: string; @@ -131,6 +147,7 @@ export interface PageConfig { right: number; }; components: any[]; + watermark?: WatermarkConfig; } // 레이아웃 설정 diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index f278cd97..7f63123b 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -3,15 +3,192 @@ import { useRef, useEffect } from "react"; import { useDrop } from "react-dnd"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; -import { ComponentConfig } from "@/types/report"; +import { ComponentConfig, WatermarkConfig } from "@/types/report"; import { CanvasComponent } from "./CanvasComponent"; import { Ruler } from "./Ruler"; import { v4 as uuidv4 } from "uuid"; +import { getFullImageUrl } from "@/lib/api/client"; // mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정) // A4 기준: 210mm x 297mm → 840px x 1188px export const MM_TO_PX = 4; +// 워터마크 레이어 컴포넌트 +interface WatermarkLayerProps { + watermark: WatermarkConfig; + canvasWidth: number; + canvasHeight: number; +} + +function WatermarkLayer({ watermark, canvasWidth, canvasHeight }: WatermarkLayerProps) { + // 공통 스타일 + const baseStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + pointerEvents: "none", + overflow: "hidden", + zIndex: 1, // 컴포넌트보다 낮은 z-index + }; + + // 대각선 스타일 + if (watermark.style === "diagonal") { + const rotation = watermark.rotation ?? -45; + return ( +
+
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+
+ ); + } + + // 중앙 스타일 + if (watermark.style === "center") { + return ( +
+
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+
+ ); + } + + // 타일 스타일 + if (watermark.style === "tile") { + const rotation = watermark.rotation ?? -30; + // 타일 간격 계산 + const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; + const cols = Math.ceil(canvasWidth / tileSize) + 2; + const rows = Math.ceil(canvasHeight / tileSize) + 2; + + return ( +
+
+ {Array.from({ length: rows * cols }).map((_, index) => ( +
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+ ))} +
+
+ ); + } + + return null; +} + export function ReportDesignerCanvas() { const canvasRef = useRef(null); const { @@ -431,6 +608,15 @@ export function ReportDesignerCanvas() { /> )} + {/* 워터마크 렌더링 */} + {currentPage?.watermark?.enabled && ( + + )} + {/* 정렬 가이드라인 렌더링 */} {alignmentGuides.vertical.map((x, index) => (
("properties"); const [uploadingImage, setUploadingImage] = useState(false); + const [uploadingWatermarkImage, setUploadingWatermarkImage] = useState(false); const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw"); const fileInputRef = useRef(null); + const watermarkFileInputRef = useRef(null); const { toast } = useToast(); const selectedComponent = components.find((c) => c.id === selectedComponentId); @@ -94,6 +98,65 @@ export function ReportDesignerRightPanel() { } }; + // 워터마크 이미지 업로드 핸들러 + const handleWatermarkImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !currentPageId) return; + + // 파일 타입 체크 + if (!file.type.startsWith("image/")) { + toast({ + title: "오류", + description: "이미지 파일만 업로드 가능합니다.", + variant: "destructive", + }); + return; + } + + // 파일 크기 체크 (5MB) + if (file.size > 5 * 1024 * 1024) { + toast({ + title: "오류", + description: "파일 크기는 5MB 이하여야 합니다.", + variant: "destructive", + }); + return; + } + + try { + setUploadingWatermarkImage(true); + + const result = await reportApi.uploadImage(file); + + if (result.success) { + // 업로드된 이미지 URL을 워터마크에 설정 + updatePageSettings(currentPageId, { + watermark: { + ...currentPage!.watermark!, + imageUrl: result.data.fileUrl, + }, + }); + + toast({ + title: "성공", + description: "워터마크 이미지가 업로드되었습니다.", + }); + } + } catch { + toast({ + title: "오류", + description: "이미지 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setUploadingWatermarkImage(false); + // input 초기화 + if (watermarkFileInputRef.current) { + watermarkFileInputRef.current.value = ""; + } + } + }; + // 선택된 쿼리의 결과 필드 가져오기 const getQueryFields = (queryId: string): string[] => { const result = context.getQueryResult(queryId); @@ -2626,6 +2689,352 @@ export function ReportDesignerRightPanel() {
+ + {/* 워터마크 설정 */} + + + 워터마크 + + + {/* 워터마크 활성화 */} +
+ + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark, + enabled: checked, + type: currentPage.watermark?.type ?? "text", + opacity: currentPage.watermark?.opacity ?? 0.3, + style: currentPage.watermark?.style ?? "diagonal", + }, + }) + } + /> +
+ + {currentPage.watermark?.enabled && ( + <> + {/* 워터마크 타입 */} +
+ + +
+ + {/* 텍스트 워터마크 설정 */} + {currentPage.watermark?.type === "text" && ( + <> +
+ + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + text: e.target.value, + }, + }) + } + placeholder="DRAFT, 대외비 등" + className="mt-1" + /> +
+
+
+ + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + fontSize: Number(e.target.value), + }, + }) + } + className="mt-1" + min={12} + max={200} + /> +
+
+ +
+ + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + fontColor: e.target.value, + }, + }) + } + className="h-9 w-12 cursor-pointer p-1" + /> + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + fontColor: e.target.value, + }, + }) + } + className="flex-1" + /> +
+
+
+ + )} + + {/* 이미지 워터마크 설정 */} + {currentPage.watermark?.type === "image" && ( +
+ +
+ + + {currentPage.watermark?.imageUrl && ( + + )} +
+

+ JPG, PNG, GIF, WEBP (최대 5MB) +

+ {currentPage.watermark?.imageUrl && ( +

+ 현재: ...{currentPage.watermark.imageUrl.slice(-30)} +

+ )} +
+ )} + + {/* 공통 설정 */} +
+ + +
+ + {/* 대각선/타일 회전 각도 */} + {(currentPage.watermark?.style === "diagonal" || + currentPage.watermark?.style === "tile") && ( +
+ + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + rotation: Number(e.target.value), + }, + }) + } + className="mt-1" + min={-180} + max={180} + /> +
+ )} + + {/* 투명도 */} +
+
+ + + {Math.round((currentPage.watermark?.opacity ?? 0.3) * 100)}% + +
+ + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + opacity: value[0] / 100, + }, + }) + } + min={5} + max={100} + step={5} + className="mt-2" + /> +
+ + {/* 프리셋 버튼 */} +
+ + + + +
+ + )} +
+
) : (
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 0851fa92..c1b35854 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -22,6 +22,202 @@ interface ReportPreviewModalProps { onClose: () => void; } +// 미리보기용 워터마크 레이어 컴포넌트 +interface PreviewWatermarkLayerProps { + watermark: { + enabled: boolean; + type: "text" | "image"; + text?: string; + fontSize?: number; + fontColor?: string; + imageUrl?: string; + opacity: number; + style: "diagonal" | "center" | "tile"; + rotation?: number; + }; + pageWidth: number; + pageHeight: number; +} + +function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWatermarkLayerProps) { + const baseStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + pointerEvents: "none", + overflow: "hidden", + zIndex: 0, + }; + + const rotation = watermark.rotation ?? -45; + + // 대각선 스타일 + if (watermark.style === "diagonal") { + return ( +
+
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+
+ ); + } + + // 중앙 스타일 + if (watermark.style === "center") { + return ( +
+
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+
+ ); + } + + // 타일 스타일 + 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; + + return ( +
+
+ {Array.from({ length: rows * cols }).map((_, index) => ( +
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+ ))} +
+
+ ); + } + + return null; +} + // 바코드/QR코드 미리보기 컴포넌트 function BarcodePreview({ component, @@ -321,6 +517,60 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) printWindow.print(); }; + // 워터마크 HTML 생성 헬퍼 함수 + const generateWatermarkHTML = (watermark: any, pageWidth: number, pageHeight: number): string => { + if (!watermark?.enabled) return ""; + + const opacity = watermark.opacity ?? 0.3; + const rotation = watermark.rotation ?? -45; + + // 공통 래퍼 스타일 + const wrapperStyle = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;`; + + // 텍스트 컨텐츠 생성 + const textContent = watermark.type === "text" + ? `${watermark.text || "WATERMARK"}` + : watermark.imageUrl + ? `` + : ""; + + if (watermark.style === "diagonal") { + return ` +
+
+ ${textContent} +
+
`; + } + + if (watermark.style === "center") { + return ` +
+
+ ${textContent} +
+
`; + } + + 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 tileItems = Array.from({ length: rows * cols }) + .map(() => `
${textContent}
`) + .join(""); + + return ` +
+
+ ${tileItems} +
+
`; + } + + return ""; + }; + // 페이지별 컴포넌트 HTML 생성 const generatePageHTML = ( pageComponents: any[], @@ -329,6 +579,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) backgroundColor: string, pageIndex: number = 0, totalPages: number = 1, + watermark?: any, ): string => { const componentsHTML = pageComponents .map((component) => { @@ -649,8 +900,11 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }) .join(""); + const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight); + return `
+ ${watermarkHTML} ${componentsHTML}
`; }; @@ -670,6 +924,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) page.background_color, pageIndex, totalPages, + page.watermark, ), ) .join('
'); @@ -894,13 +1149,21 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
{/* 페이지 컨텐츠 */}
+ {/* 워터마크 렌더링 */} + {page.watermark?.enabled && ( + + )} {(Array.isArray(page.components) ? page.components : []).map((component) => { const displayValue = getComponentValue(component); const queryResult = component.queryId ? getQueryResult(component.queryId) : null; diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 5a61e5b9..6619b534 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -81,6 +81,22 @@ export interface ExternalConnection { is_active: string; } +// 워터마크 설정 +export interface WatermarkConfig { + enabled: boolean; + type: "text" | "image"; + // 텍스트 워터마크 + text?: string; + fontSize?: number; + fontColor?: string; + // 이미지 워터마크 + imageUrl?: string; + // 공통 설정 + opacity: number; // 0~1 + style: "diagonal" | "center" | "tile"; + rotation?: number; // 대각선일 때 각도 (기본 -45) +} + // 페이지 설정 export interface ReportPage { page_id: string; @@ -97,6 +113,7 @@ export interface ReportPage { }; background_color: string; components: ComponentConfig[]; + watermark?: WatermarkConfig; } // 레이아웃 설정 (페이지 기반) From e8b581f5da0add50eea270d9f6adce6a887820a3 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 15:45:17 +0900 Subject: [PATCH 15/30] =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/report/designer/ReportPreviewModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index c1b35854..92043666 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -1151,8 +1151,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
From 9493d81903f00a620eb11fd96993f76aeff0b668 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 22 Dec 2025 16:39:46 +0900 Subject: [PATCH 16/30] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/menuCopyService.ts | 79 +++++++++++++++++++- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index eb230454..ac9768a1 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -938,7 +938,9 @@ export class MenuCopyService { copiedCategoryMappings = await this.copyCategoryMappingsAndValues( menuObjids, menuIdMap, + sourceCompanyCode, targetCompanyCode, + Array.from(screenIds), userId, client ); @@ -2569,11 +2571,16 @@ export class MenuCopyService { /** * 카테고리 매핑 + 값 복사 (최적화: 배치 조회) + * + * 화면에서 사용하는 table_name + column_name 조합을 기준으로 카테고리 값 복사 + * menu_objid 기준이 아닌 화면 컴포넌트 기준으로 복사하여 누락 방지 */ private async copyCategoryMappingsAndValues( menuObjids: number[], menuIdMap: Map, + sourceCompanyCode: string, targetCompanyCode: string, + screenIds: number[], userId: string, client: PoolClient ): Promise { @@ -2697,12 +2704,70 @@ export class MenuCopyService { ); } - // 4. 모든 원본 카테고리 값 한 번에 조회 + // 4. 화면에서 사용하는 카테고리 컬럼 조합 수집 + // 복사된 화면의 레이아웃에서 webType='category'인 컴포넌트의 tableName, columnName 추출 + const categoryColumnsResult = await client.query( + `SELECT DISTINCT + sl.properties->>'tableName' as table_name, + sl.properties->>'columnName' as column_name + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'webType' = 'category' + AND sl.properties->>'tableName' IS NOT NULL + AND sl.properties->>'columnName' IS NOT NULL`, + [screenIds] + ); + + // 카테고리 매핑에서 사용하는 table_name, column_name도 추가 + const mappingColumnsResult = await client.query( + `SELECT DISTINCT table_name, logical_column_name as column_name + FROM category_column_mapping + WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + // 두 결과 합치기 + const categoryColumns = new Set(); + for (const row of categoryColumnsResult.rows) { + if (row.table_name && row.column_name) { + categoryColumns.add(`${row.table_name}|${row.column_name}`); + } + } + for (const row of mappingColumnsResult.rows) { + if (row.table_name && row.column_name) { + categoryColumns.add(`${row.table_name}|${row.column_name}`); + } + } + + logger.info( + ` 📋 화면에서 사용하는 카테고리 컬럼: ${categoryColumns.size}개` + ); + + if (categoryColumns.size === 0) { + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + // 5. 원본 회사의 카테고리 값 조회 (table_name + column_name 기준) + // menu_objid 조건 대신 table_name + column_name + 원본 회사 코드로 조회 + const columnConditions = Array.from(categoryColumns).map((col, i) => { + const [tableName, columnName] = col.split("|"); + return `(table_name = $${i * 2 + 2} AND column_name = $${i * 2 + 3})`; + }); + + const columnParams: string[] = []; + for (const col of categoryColumns) { + const [tableName, columnName] = col.split("|"); + columnParams.push(tableName, columnName); + } + const allValuesResult = await client.query( `SELECT * FROM table_column_category_values - WHERE menu_objid = ANY($1) + WHERE company_code = $1 + AND (${columnConditions.join(" OR ")}) ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, - [menuObjids] + [sourceCompanyCode, ...columnParams] ); if (allValuesResult.rows.length === 0) { @@ -2710,6 +2775,8 @@ export class MenuCopyService { return copiedCount; } + logger.info(` 📋 원본 카테고리 값: ${allValuesResult.rows.length}개 발견`); + // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 const existingValuesResult = await client.query( `SELECT value_id, table_name, column_name, value_code @@ -2763,8 +2830,12 @@ export class MenuCopyService { ) .join(", "); + // 기본 menu_objid: 매핑이 없을 경우 첫 번째 복사된 메뉴 사용 + const defaultMenuObjid = menuIdMap.values().next().value || 0; + const valueParams = values.flatMap((v) => { - const newMenuObjid = menuIdMap.get(v.menu_objid); + // 원본 menu_objid가 매핑에 있으면 사용, 없으면 기본값 사용 + const newMenuObjid = menuIdMap.get(v.menu_objid) ?? defaultMenuObjid; const newParentId = v.parent_value_id ? valueIdMap.get(v.parent_value_id) || null : null; From 5f26e998e35a91939a069221ad401da38bb728ea Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 17:06:11 +0900 Subject: [PATCH 17/30] =?UTF-8?q?=EC=9B=8C=ED=84=B0=EB=A7=88=ED=81=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EC=B2=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 4 +- backend-node/src/types/report.ts | 2 +- .../report/designer/ReportDesignerCanvas.tsx | 7 +- .../designer/ReportDesignerRightPanel.tsx | 220 ++++++++---------- .../report/designer/ReportPreviewModal.tsx | 8 +- frontend/contexts/ReportDesignerContext.tsx | 13 +- frontend/types/report.ts | 2 +- 7 files changed, 120 insertions(+), 136 deletions(-) diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index 5f755947..c6605d3e 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -3067,8 +3067,8 @@ export class ReportController { children.push(new Paragraph({ children: [] })); } - // 워터마크 헤더 생성 (워터마크가 활성화된 경우) - const watermark: WatermarkConfig | undefined = page.watermark; + // 워터마크 헤더 생성 (전체 페이지 공유 워터마크) + const watermark: WatermarkConfig | undefined = layoutConfig.watermark; let headers: { default?: Header } | undefined; if (watermark?.enabled && watermark.type === "text" && watermark.text) { diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index d5641cff..27254b0d 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -147,12 +147,12 @@ export interface PageConfig { right: number; }; components: any[]; - watermark?: WatermarkConfig; } // 레이아웃 설정 export interface ReportLayoutConfig { pages: PageConfig[]; + watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크 } // 레이아웃 저장 요청 diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index 7f63123b..85dc89b8 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -213,6 +213,7 @@ export function ReportDesignerCanvas() { undo, redo, showRuler, + layoutConfig, } = useReportDesigner(); const [{ isOver }, drop] = useDrop(() => ({ @@ -608,10 +609,10 @@ export function ReportDesignerCanvas() { /> )} - {/* 워터마크 렌더링 */} - {currentPage?.watermark?.enabled && ( + {/* 워터마크 렌더링 (전체 페이지 공유) */} + {layoutConfig.watermark?.enabled && ( diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index da28fdfe..e3a24025 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -31,6 +31,8 @@ export function ReportDesignerRightPanel() { currentPageId, updatePageSettings, getQueryResult, + layoutConfig, + updateWatermark, } = context; const [activeTab, setActiveTab] = useState("properties"); const [uploadingImage, setUploadingImage] = useState(false); @@ -101,7 +103,7 @@ export function ReportDesignerRightPanel() { // 워터마크 이미지 업로드 핸들러 const handleWatermarkImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (!file || !currentPageId) return; + if (!file) return; // 파일 타입 체크 if (!file.type.startsWith("image/")) { @@ -129,12 +131,10 @@ export function ReportDesignerRightPanel() { const result = await reportApi.uploadImage(file); if (result.success) { - // 업로드된 이미지 URL을 워터마크에 설정 - updatePageSettings(currentPageId, { - watermark: { - ...currentPage!.watermark!, - imageUrl: result.data.fileUrl, - }, + // 업로드된 이미지 URL을 전체 워터마크에 설정 + updateWatermark({ + ...layoutConfig.watermark!, + imageUrl: result.data.fileUrl, }); toast({ @@ -2690,44 +2690,40 @@ export function ReportDesignerRightPanel() { - {/* 워터마크 설정 */} + {/* 워터마크 설정 (전체 페이지 공유) */} - 워터마크 + 워터마크 (전체 페이지) {/* 워터마크 활성화 */}
- updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark, - enabled: checked, - type: currentPage.watermark?.type ?? "text", - opacity: currentPage.watermark?.opacity ?? 0.3, - style: currentPage.watermark?.style ?? "diagonal", - }, + updateWatermark({ + ...layoutConfig.watermark, + enabled: checked, + type: layoutConfig.watermark?.type ?? "text", + opacity: layoutConfig.watermark?.opacity ?? 0.3, + style: layoutConfig.watermark?.style ?? "diagonal", }) } />
- {currentPage.watermark?.enabled && ( + {layoutConfig.watermark?.enabled && ( <> {/* 워터마크 타입 */}
- updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - text: e.target.value, - }, + updateWatermark({ + ...layoutConfig.watermark!, + text: e.target.value, }) } placeholder="DRAFT, 대외비 등" @@ -2765,13 +2759,11 @@ export function ReportDesignerRightPanel() { - updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - fontSize: Number(e.target.value), - }, + updateWatermark({ + ...layoutConfig.watermark!, + fontSize: Number(e.target.value), }) } className="mt-1" @@ -2784,26 +2776,22 @@ export function ReportDesignerRightPanel() {
- updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - fontColor: e.target.value, - }, + updateWatermark({ + ...layoutConfig.watermark!, + fontColor: e.target.value, }) } className="h-9 w-12 cursor-pointer p-1" /> - updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - fontColor: e.target.value, - }, + updateWatermark({ + ...layoutConfig.watermark!, + fontColor: e.target.value, }) } className="flex-1" @@ -2815,7 +2803,7 @@ export function ReportDesignerRightPanel() { )} {/* 이미지 워터마크 설정 */} - {currentPage.watermark?.type === "image" && ( + {layoutConfig.watermark?.type === "image" && (
@@ -2843,21 +2831,19 @@ export function ReportDesignerRightPanel() { ) : ( <> - {currentPage.watermark?.imageUrl ? "이미지 변경" : "이미지 선택"} + {layoutConfig.watermark?.imageUrl ? "이미지 변경" : "이미지 선택"} )} - {currentPage.watermark?.imageUrl && ( + {layoutConfig.watermark?.imageUrl && (
@@ -2880,13 +2866,11 @@ export function ReportDesignerRightPanel() {
- updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - rotation: Number(e.target.value), - }, + updateWatermark({ + ...layoutConfig.watermark!, + rotation: Number(e.target.value), }) } className="mt-1" @@ -2929,17 +2911,15 @@ export function ReportDesignerRightPanel() {
- {Math.round((currentPage.watermark?.opacity ?? 0.3) * 100)}% + {Math.round((layoutConfig.watermark?.opacity ?? 0.3) * 100)}%
- updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - opacity: value[0] / 100, - }, + updateWatermark({ + ...layoutConfig.watermark!, + opacity: value[0] / 100, }) } min={5} @@ -2955,17 +2935,15 @@ export function ReportDesignerRightPanel() { size="sm" variant="outline" onClick={() => - updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - type: "text", - text: "DRAFT", - fontSize: 64, - fontColor: "#cccccc", - style: "diagonal", - opacity: 0.2, - rotation: -45, - }, + updateWatermark({ + ...layoutConfig.watermark!, + type: "text", + text: "DRAFT", + fontSize: 64, + fontColor: "#cccccc", + style: "diagonal", + opacity: 0.2, + rotation: -45, }) } > @@ -2975,17 +2953,15 @@ export function ReportDesignerRightPanel() { size="sm" variant="outline" onClick={() => - updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - type: "text", - text: "대외비", - fontSize: 64, - fontColor: "#ff0000", - style: "diagonal", - opacity: 0.15, - rotation: -45, - }, + updateWatermark({ + ...layoutConfig.watermark!, + type: "text", + text: "대외비", + fontSize: 64, + fontColor: "#ff0000", + style: "diagonal", + opacity: 0.15, + rotation: -45, }) } > @@ -2995,17 +2971,15 @@ export function ReportDesignerRightPanel() { size="sm" variant="outline" onClick={() => - updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - type: "text", - text: "SAMPLE", - fontSize: 48, - fontColor: "#888888", - style: "tile", - opacity: 0.1, - rotation: -30, - }, + updateWatermark({ + ...layoutConfig.watermark!, + type: "text", + text: "SAMPLE", + fontSize: 48, + fontColor: "#888888", + style: "tile", + opacity: 0.1, + rotation: -30, }) } > @@ -3015,16 +2989,14 @@ export function ReportDesignerRightPanel() { size="sm" variant="outline" onClick={() => - updatePageSettings(currentPageId, { - watermark: { - ...currentPage.watermark!, - type: "text", - text: "COPY", - fontSize: 56, - fontColor: "#aaaaaa", - style: "center", - opacity: 0.25, - }, + updateWatermark({ + ...layoutConfig.watermark!, + type: "text", + text: "COPY", + fontSize: 56, + fontColor: "#aaaaaa", + style: "center", + opacity: 0.25, }) } > diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 92043666..b8fcb9ce 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -924,7 +924,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) page.background_color, pageIndex, totalPages, - page.watermark, + layoutConfig.watermark, // 전체 페이지 공유 워터마크 ), ) .join('
'); @@ -1156,10 +1156,10 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) backgroundColor: page.background_color, }} > - {/* 워터마크 렌더링 */} - {page.watermark?.enabled && ( + {/* 워터마크 렌더링 (전체 페이지 공유) */} + {layoutConfig.watermark?.enabled && ( diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index 206f56db..f8764d15 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react"; -import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig } from "@/types/report"; +import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig, WatermarkConfig } from "@/types/report"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import { v4 as uuidv4 } from "uuid"; @@ -40,6 +40,7 @@ interface ReportDesignerContextType { reorderPages: (sourceIndex: number, targetIndex: number) => void; selectPage: (pageId: string) => void; updatePageSettings: (pageId: string, settings: Partial) => void; + updateWatermark: (watermark: WatermarkConfig | undefined) => void; // 전체 페이지 공유 워터마크 // 컴포넌트 (현재 페이지) components: ComponentConfig[]; // currentPage의 components (읽기 전용) @@ -988,10 +989,19 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin const updatePageSettings = useCallback((pageId: string, settings: Partial) => { setLayoutConfig((prev) => ({ + ...prev, pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)), })); }, []); + // 전체 페이지 공유 워터마크 업데이트 + const updateWatermark = useCallback((watermark: WatermarkConfig | undefined) => { + setLayoutConfig((prev) => ({ + ...prev, + watermark, + })); + }, []); + // 리포트 및 레이아웃 로드 const loadLayout = useCallback(async () => { setIsLoading(true); @@ -1471,6 +1481,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin reorderPages, selectPage, updatePageSettings, + updateWatermark, // 컴포넌트 (현재 페이지) components, diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 6619b534..3631f831 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -113,12 +113,12 @@ export interface ReportPage { }; background_color: string; components: ComponentConfig[]; - watermark?: WatermarkConfig; } // 레이아웃 설정 (페이지 기반) export interface ReportLayoutConfig { pages: ReportPage[]; + watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크 } // 컴포넌트 설정 From 99c09603254d725a5a482ecbd73b1b8a110c9a7b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 17:42:35 +0900 Subject: [PATCH 18/30] =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=20=ED=8F=B0=ED=8A=B8=EA=B0=80=20=EC=9D=BC=EB=B6=80?= =?UTF-8?q?=20=EA=B8=80=EC=9E=90=EC=97=90=EB=A7=8C=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/SignatureGenerator.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/components/report/designer/SignatureGenerator.tsx b/frontend/components/report/designer/SignatureGenerator.tsx index 9a3bd29f..a36b0b8c 100644 --- a/frontend/components/report/designer/SignatureGenerator.tsx +++ b/frontend/components/report/designer/SignatureGenerator.tsx @@ -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[] = []; // 동기적으로 하나씩 생성 From e1a032933dc6080a470dd094124dbf600a213d03 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 18:17:58 +0900 Subject: [PATCH 19/30] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=97=AC=EB=B0=B1?= =?UTF-8?q?=EC=97=90=20=EC=B5=9C=EC=86=9F=EA=B0=92=200=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/ReportDesignerRightPanel.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index e3a24025..037125ee 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -2589,12 +2589,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - top: Number(e.target.value), + top: Math.max(0, Number(e.target.value)), }, }) } @@ -2605,12 +2606,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - bottom: Number(e.target.value), + bottom: Math.max(0, Number(e.target.value)), }, }) } @@ -2621,12 +2623,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - left: Number(e.target.value), + left: Math.max(0, Number(e.target.value)), }, }) } @@ -2637,12 +2640,13 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { margins: { ...currentPage.margins, - right: Number(e.target.value), + right: Math.max(0, Number(e.target.value)), }, }) } From 7875d8ab86c1b0d41c35a41788106a6b39b9528c Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 18:20:16 +0900 Subject: [PATCH 20/30] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=B5=9C=EC=86=9F=EA=B0=92=201=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/report/designer/ReportDesignerRightPanel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index 037125ee..bf401680 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -2502,10 +2502,11 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { - width: Number(e.target.value), + width: Math.max(1, Number(e.target.value)), }) } className="mt-1" @@ -2515,10 +2516,11 @@ export function ReportDesignerRightPanel() { updatePageSettings(currentPageId, { - height: Number(e.target.value), + height: Math.max(1, Number(e.target.value)), }) } className="mt-1" From 5f406fbe888157c9df7f7f4a330719cfcd0a9bf6 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 23 Dec 2025 09:31:18 +0900 Subject: [PATCH 21/30] =?UTF-8?q?=EA=B3=B5=ED=86=B5=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/commonCodeController.ts | 145 +++- .../src/routes/cascadingAutoFillRoutes.ts | 1 + .../src/routes/cascadingConditionRoutes.ts | 1 + .../src/routes/cascadingHierarchyRoutes.ts | 1 + .../routes/cascadingMutualExclusionRoutes.ts | 1 + backend-node/src/routes/commonCodeRoutes.ts | 15 + .../src/services/commonCodeService.ts | 207 +++++- docs/노드플로우_개선사항.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + docs/즉시저장_버튼_액션_구현_계획서.md | 1 + .../admin/cascading-management/page.tsx | 5 +- .../tabs/HierarchyColumnTab.tsx | 626 ++++++++++++++++++ frontend/app/(main)/admin/tableMng/page.tsx | 129 +++- frontend/components/admin/CodeDetailPanel.tsx | 188 +++++- frontend/components/admin/CodeFormModal.tsx | 211 +++--- .../components/admin/SortableCodeItem.tsx | 218 ++++-- .../common/HierarchicalCodeSelect.tsx | 457 +++++++++++++ .../common/MultiColumnHierarchySelect.tsx | 389 +++++++++++ frontend/components/unified/UnifiedDate.tsx | 488 ++++++++++++++ .../config-panels/UnifiedInputConfigPanel.tsx | 152 +++++ frontend/contexts/ActiveTabContext.tsx | 1 + frontend/hooks/queries/useCodes.ts | 147 +++- frontend/hooks/useAutoFill.ts | 1 + frontend/lib/api/commonCode.ts | 58 ++ .../select-basic/SelectBasicComponent.tsx | 275 ++++++-- .../select-basic/SelectBasicConfigPanel.tsx | 392 +++++++---- .../registry/components/select-basic/types.ts | 18 + frontend/lib/schemas/commonCode.ts | 11 +- frontend/types/commonCode.ts | 8 + ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 32 files changed, 3673 insertions(+), 478 deletions(-) create mode 100644 frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx create mode 100644 frontend/components/common/HierarchicalCodeSelect.tsx create mode 100644 frontend/components/common/MultiColumnHierarchySelect.tsx create mode 100644 frontend/components/unified/UnifiedDate.tsx create mode 100644 frontend/components/unified/config-panels/UnifiedInputConfigPanel.tsx diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index b0db2059..5b6b1453 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -94,7 +94,9 @@ export class CommonCodeController { sortOrder: code.sort_order, isActive: code.is_active, useYn: code.is_active, - companyCode: code.company_code, // 추가 + companyCode: code.company_code, + parentCodeValue: code.parent_code_value, // 계층구조: 부모 코드값 + depth: code.depth, // 계층구조: 깊이 // 기존 필드명도 유지 (하위 호환성) code_category: code.code_category, @@ -103,7 +105,9 @@ export class CommonCodeController { code_name_eng: code.code_name_eng, sort_order: code.sort_order, is_active: code.is_active, - company_code: code.company_code, // 추가 + company_code: code.company_code, + parent_code_value: code.parent_code_value, // 계층구조: 부모 코드값 + // depth는 위에서 이미 정의됨 (snake_case와 camelCase 동일) created_date: code.created_date, created_by: code.created_by, updated_date: code.updated_date, @@ -286,19 +290,17 @@ export class CommonCodeController { }); } - if (!menuObjid) { - return res.status(400).json({ - success: false, - message: "메뉴 OBJID는 필수입니다.", - }); - } + // menuObjid가 없으면 공통코드관리 메뉴의 기본 OBJID 사용 (전역 코드) + // 공통코드관리 메뉴 OBJID: 1757401858940 + const DEFAULT_CODE_MANAGEMENT_MENU_OBJID = 1757401858940; + const effectiveMenuObjid = menuObjid ? Number(menuObjid) : DEFAULT_CODE_MANAGEMENT_MENU_OBJID; const code = await this.commonCodeService.createCode( categoryCode, codeData, userId, companyCode, - Number(menuObjid) + effectiveMenuObjid ); return res.status(201).json({ @@ -588,4 +590,129 @@ export class CommonCodeController { }); } } + + /** + * 계층구조 코드 조회 + * GET /api/common-codes/categories/:categoryCode/hierarchy + * Query: parentCodeValue (optional), depth (optional), menuObjid (optional) + */ + async getHierarchicalCodes(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode } = req.params; + const { parentCodeValue, depth, menuObjid } = req.query; + const userCompanyCode = req.user?.companyCode; + const menuObjidNum = menuObjid ? Number(menuObjid) : undefined; + + // parentCodeValue가 빈 문자열이면 최상위 코드 조회 + const parentValue = parentCodeValue === '' || parentCodeValue === undefined + ? null + : parentCodeValue as string; + + const codes = await this.commonCodeService.getHierarchicalCodes( + categoryCode, + parentValue, + depth ? parseInt(depth as string) : undefined, + userCompanyCode, + menuObjidNum + ); + + // 프론트엔드 형식으로 변환 + const transformedData = codes.map((code: any) => ({ + codeValue: code.code_value, + codeName: code.code_name, + codeNameEng: code.code_name_eng, + description: code.description, + sortOrder: code.sort_order, + isActive: code.is_active, + parentCodeValue: code.parent_code_value, + depth: code.depth, + // 기존 필드도 유지 + code_category: code.code_category, + code_value: code.code_value, + code_name: code.code_name, + code_name_eng: code.code_name_eng, + sort_order: code.sort_order, + is_active: code.is_active, + parent_code_value: code.parent_code_value, + })); + + return res.json({ + success: true, + data: transformedData, + message: `계층구조 코드 조회 성공 (${categoryCode})`, + }); + } catch (error) { + logger.error(`계층구조 코드 조회 실패 (${req.params.categoryCode}):`, error); + return res.status(500).json({ + success: false, + message: "계층구조 코드 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 코드 트리 조회 + * GET /api/common-codes/categories/:categoryCode/tree + */ + async getCodeTree(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode } = req.params; + const { menuObjid } = req.query; + const userCompanyCode = req.user?.companyCode; + const menuObjidNum = menuObjid ? Number(menuObjid) : undefined; + + const result = await this.commonCodeService.getCodeTree( + categoryCode, + userCompanyCode, + menuObjidNum + ); + + return res.json({ + success: true, + data: result, + message: `코드 트리 조회 성공 (${categoryCode})`, + }); + } catch (error) { + logger.error(`코드 트리 조회 실패 (${req.params.categoryCode}):`, error); + return res.status(500).json({ + success: false, + message: "코드 트리 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 자식 코드 존재 여부 확인 + * GET /api/common-codes/categories/:categoryCode/codes/:codeValue/has-children + */ + async hasChildren(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode, codeValue } = req.params; + const companyCode = req.user?.companyCode; + + const hasChildren = await this.commonCodeService.hasChildren( + categoryCode, + codeValue, + companyCode + ); + + return res.json({ + success: true, + data: { hasChildren }, + message: "자식 코드 확인 완료", + }); + } catch (error) { + logger.error( + `자식 코드 확인 실패 (${req.params.categoryCode}.${req.params.codeValue}):`, + error + ); + return res.status(500).json({ + success: false, + message: "자식 코드 확인 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } } diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 92036080..a5107448 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -54,3 +54,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index ed11d3d1..22cd2d2b 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -50,3 +50,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index d74929cb..79a1c6e8 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -66,3 +66,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index ce2fbcac..352a05b5 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -54,3 +54,4 @@ export default router; + diff --git a/backend-node/src/routes/commonCodeRoutes.ts b/backend-node/src/routes/commonCodeRoutes.ts index 6772a6e9..d1205e51 100644 --- a/backend-node/src/routes/commonCodeRoutes.ts +++ b/backend-node/src/routes/commonCodeRoutes.ts @@ -46,6 +46,21 @@ router.put("/categories/:categoryCode/codes/reorder", (req, res) => commonCodeController.reorderCodes(req, res) ); +// 계층구조 코드 조회 (구체적인 경로를 먼저 배치) +router.get("/categories/:categoryCode/hierarchy", (req, res) => + commonCodeController.getHierarchicalCodes(req, res) +); + +// 코드 트리 조회 +router.get("/categories/:categoryCode/tree", (req, res) => + commonCodeController.getCodeTree(req, res) +); + +// 자식 코드 존재 여부 확인 +router.get("/categories/:categoryCode/codes/:codeValue/has-children", (req, res) => + commonCodeController.hasChildren(req, res) +); + router.put("/categories/:categoryCode/codes/:codeValue", (req, res) => commonCodeController.updateCode(req, res) ); diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index db19adc3..7c0d917a 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -25,6 +25,8 @@ export interface CodeInfo { is_active: string; company_code: string; menu_objid?: number | null; // 메뉴 기반 코드 관리용 + parent_code_value?: string | null; // 계층구조: 부모 코드값 + depth?: number; // 계층구조: 깊이 (1, 2, 3단계) created_date?: Date | null; created_by?: string | null; updated_date?: Date | null; @@ -61,6 +63,8 @@ export interface CreateCodeData { description?: string; sortOrder?: number; isActive?: string; + parentCodeValue?: string; // 계층구조: 부모 코드값 + depth?: number; // 계층구조: 깊이 (1, 2, 3단계) } export class CommonCodeService { @@ -405,11 +409,22 @@ export class CommonCodeService { menuObjid: number ) { try { + // 계층구조: depth 계산 (부모가 있으면 부모의 depth + 1, 없으면 1) + let depth = 1; + if (data.parentCodeValue) { + const parentCode = await queryOne( + `SELECT depth FROM code_info + WHERE code_category = $1 AND code_value = $2 AND company_code = $3`, + [categoryCode, data.parentCodeValue, companyCode] + ); + depth = parentCode ? (parentCode.depth || 1) + 1 : 1; + } + const code = await queryOne( `INSERT INTO code_info (code_category, code_value, code_name, code_name_eng, description, sort_order, - is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW()) + is_active, menu_objid, company_code, parent_code_value, depth, created_by, updated_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, $11, $12, NOW(), NOW()) RETURNING *`, [ categoryCode, @@ -420,13 +435,15 @@ export class CommonCodeService { data.sortOrder || 0, menuObjid, companyCode, + data.parentCodeValue || null, + depth, createdBy, createdBy, ] ); logger.info( - `코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})` + `코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode}, 부모: ${data.parentCodeValue || '없음'}, 깊이: ${depth})` ); return code; } catch (error) { @@ -491,6 +508,24 @@ export class CommonCodeService { updateFields.push(`is_active = $${paramIndex++}`); values.push(activeValue); } + // 계층구조: 부모 코드값 수정 + if (data.parentCodeValue !== undefined) { + updateFields.push(`parent_code_value = $${paramIndex++}`); + values.push(data.parentCodeValue || null); + + // depth도 함께 업데이트 + let newDepth = 1; + if (data.parentCodeValue) { + const parentCode = await queryOne( + `SELECT depth FROM code_info + WHERE code_category = $1 AND code_value = $2`, + [categoryCode, data.parentCodeValue] + ); + newDepth = parentCode ? (parentCode.depth || 1) + 1 : 1; + } + updateFields.push(`depth = $${paramIndex++}`); + values.push(newDepth); + } // WHERE 절 구성 let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`; @@ -847,4 +882,170 @@ export class CommonCodeService { throw error; } } + + /** + * 계층구조 코드 조회 (특정 depth 또는 부모코드 기준) + * @param categoryCode 카테고리 코드 + * @param parentCodeValue 부모 코드값 (없으면 최상위 코드만 조회) + * @param depth 특정 깊이만 조회 (선택) + */ + async getHierarchicalCodes( + categoryCode: string, + parentCodeValue?: string | null, + depth?: number, + userCompanyCode?: string, + menuObjid?: number + ) { + try { + const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"]; + const values: any[] = [categoryCode]; + let paramIndex = 2; + + // 부모 코드값 필터링 + if (parentCodeValue === null || parentCodeValue === undefined) { + // 최상위 코드 (부모가 없는 코드) + whereConditions.push("(parent_code_value IS NULL OR parent_code_value = '')"); + } else if (parentCodeValue !== '') { + whereConditions.push(`parent_code_value = $${paramIndex}`); + values.push(parentCodeValue); + paramIndex++; + } + + // 특정 깊이 필터링 + if (depth !== undefined) { + whereConditions.push(`depth = $${paramIndex}`); + values.push(depth); + paramIndex++; + } + + // 메뉴별 필터링 (형제 메뉴 포함) + if (menuObjid) { + const { getSiblingMenuObjids } = await import('./menuService'); + const siblingMenuObjids = await getSiblingMenuObjids(menuObjid); + whereConditions.push(`menu_objid = ANY($${paramIndex})`); + values.push(siblingMenuObjids); + paramIndex++; + } + + // 회사별 필터링 + if (userCompanyCode && userCompanyCode !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + values.push(userCompanyCode); + paramIndex++; + } + + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; + + const codes = await query( + `SELECT * FROM code_info + ${whereClause} + ORDER BY sort_order ASC, code_value ASC`, + values + ); + + logger.info( + `계층구조 코드 조회: ${categoryCode}, 부모: ${parentCodeValue || '최상위'}, 깊이: ${depth || '전체'} - ${codes.length}개` + ); + + return codes; + } catch (error) { + logger.error(`계층구조 코드 조회 중 오류 (${categoryCode}):`, error); + throw error; + } + } + + /** + * 계층구조 코드 트리 전체 조회 (카테고리 기준) + */ + async getCodeTree( + categoryCode: string, + userCompanyCode?: string, + menuObjid?: number + ) { + try { + const whereConditions: string[] = ["code_category = $1", "is_active = 'Y'"]; + const values: any[] = [categoryCode]; + let paramIndex = 2; + + // 메뉴별 필터링 (형제 메뉴 포함) + if (menuObjid) { + const { getSiblingMenuObjids } = await import('./menuService'); + const siblingMenuObjids = await getSiblingMenuObjids(menuObjid); + whereConditions.push(`menu_objid = ANY($${paramIndex})`); + values.push(siblingMenuObjids); + paramIndex++; + } + + // 회사별 필터링 + if (userCompanyCode && userCompanyCode !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + values.push(userCompanyCode); + paramIndex++; + } + + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; + + const allCodes = await query( + `SELECT * FROM code_info + ${whereClause} + ORDER BY depth ASC, sort_order ASC, code_value ASC`, + values + ); + + // 트리 구조로 변환 + const buildTree = (codes: CodeInfo[], parentValue: string | null = null): any[] => { + return codes + .filter(code => { + const codeParent = code.parent_code_value || null; + return codeParent === parentValue; + }) + .map(code => ({ + ...code, + children: buildTree(codes, code.code_value) + })); + }; + + const tree = buildTree(allCodes); + + logger.info( + `코드 트리 조회 완료: ${categoryCode} - 전체 ${allCodes.length}개` + ); + + return { + flat: allCodes, + tree + }; + } catch (error) { + logger.error(`코드 트리 조회 중 오류 (${categoryCode}):`, error); + throw error; + } + } + + /** + * 자식 코드가 있는지 확인 + */ + async hasChildren( + categoryCode: string, + codeValue: string, + companyCode?: string + ): Promise { + try { + let sql = `SELECT COUNT(*) as count FROM code_info + WHERE code_category = $1 AND parent_code_value = $2`; + const values: any[] = [categoryCode, codeValue]; + + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $3`; + values.push(companyCode); + } + + const result = await queryOne<{ count: string }>(sql, values); + const count = parseInt(result?.count || "0"); + + return count > 0; + } catch (error) { + logger.error(`자식 코드 확인 중 오류 (${categoryCode}.${codeValue}):`, error); + throw error; + } + } } diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index c2c44be0..c9349b94 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -586,3 +586,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 4ffb7655..42900211 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -359,3 +359,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index 1de42fb2..c392eece 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -345,3 +345,4 @@ const getComponentValue = (componentId: string) => { + diff --git a/frontend/app/(main)/admin/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx index 5b5f6b37..c36d8ae0 100644 --- a/frontend/app/(main)/admin/cascading-management/page.tsx +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Link2, Layers, Filter, FormInput, Ban, Tags } from "lucide-react"; +import { Link2, Layers, Filter, FormInput, Ban, Tags, Columns } from "lucide-react"; // 탭별 컴포넌트 import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; @@ -12,6 +12,7 @@ import HierarchyTab from "./tabs/HierarchyTab"; import ConditionTab from "./tabs/ConditionTab"; import MutualExclusionTab from "./tabs/MutualExclusionTab"; import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab"; +import HierarchyColumnTab from "./tabs/HierarchyColumnTab"; export default function CascadingManagementPage() { const searchParams = useSearchParams(); @@ -21,7 +22,7 @@ export default function CascadingManagementPage() { // URL 쿼리 파라미터에서 탭 설정 useEffect(() => { const tab = searchParams.get("tab"); - if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value"].includes(tab)) { + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) { setActiveTab(tab); } }, [searchParams]); diff --git a/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx new file mode 100644 index 00000000..d0d77230 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx @@ -0,0 +1,626 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react"; +import { toast } from "sonner"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { + hierarchyColumnApi, + HierarchyColumnGroup, + CreateHierarchyGroupRequest, +} from "@/lib/api/hierarchyColumn"; +import { commonCodeApi } from "@/lib/api/commonCode"; +import apiClient from "@/lib/api/client"; + +interface TableInfo { + tableName: string; + displayName?: string; +} + +interface ColumnInfo { + columnName: string; + displayName?: string; + dataType?: string; +} + +interface CategoryInfo { + categoryCode: string; + categoryName: string; +} + +export default function HierarchyColumnTab() { + // 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + const [isEditing, setIsEditing] = useState(false); + + // 폼 상태 + const [formData, setFormData] = useState({ + groupCode: "", + groupName: "", + description: "", + codeCategory: "", + tableName: "", + maxDepth: 3, + mappings: [ + { depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true }, + { depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false }, + { depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false }, + ], + }); + + // 참조 데이터 + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [categories, setCategories] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingCategories, setLoadingCategories] = useState(false); + + // 그룹 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + try { + const response = await hierarchyColumnApi.getAll(); + if (response.success && response.data) { + setGroups(response.data); + } else { + toast.error(response.error || "계층구조 그룹 로드 실패"); + } + } catch (error) { + console.error("계층구조 그룹 로드 에러:", error); + toast.error("계층구조 그룹을 로드하는 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + setLoadingTables(true); + try { + const response = await apiClient.get("/table-management/tables"); + if (response.data?.success && response.data?.data) { + setTables(response.data.data); + } + } catch (error) { + console.error("테이블 로드 에러:", error); + } finally { + setLoadingTables(false); + } + }, []); + + // 카테고리 목록 로드 + const loadCategories = useCallback(async () => { + setLoadingCategories(true); + try { + const response = await commonCodeApi.categories.getList(); + if (response.success && response.data) { + setCategories( + response.data.map((cat: any) => ({ + categoryCode: cat.categoryCode || cat.category_code, + categoryName: cat.categoryName || cat.category_name, + })) + ); + } + } catch (error) { + console.error("카테고리 로드 에러:", error); + } finally { + setLoadingCategories(false); + } + }, []); + + // 테이블 선택 시 컬럼 로드 + const loadColumns = useCallback(async (tableName: string) => { + if (!tableName) { + setColumns([]); + return; + } + setLoadingColumns(true); + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data?.success && response.data?.data) { + setColumns(response.data.data); + } + } catch (error) { + console.error("컬럼 로드 에러:", error); + } finally { + setLoadingColumns(false); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadGroups(); + loadTables(); + loadCategories(); + }, [loadGroups, loadTables, loadCategories]); + + // 테이블 선택 변경 시 컬럼 로드 + useEffect(() => { + if (formData.tableName) { + loadColumns(formData.tableName); + } + }, [formData.tableName, loadColumns]); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + groupCode: "", + groupName: "", + description: "", + codeCategory: "", + tableName: "", + maxDepth: 3, + mappings: [ + { depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true }, + { depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false }, + { depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false }, + ], + }); + setSelectedGroup(null); + setIsEditing(false); + }; + + // 모달 열기 (신규) + const openCreateModal = () => { + resetForm(); + setModalOpen(true); + }; + + // 모달 열기 (수정) + const openEditModal = (group: HierarchyColumnGroup) => { + setSelectedGroup(group); + setIsEditing(true); + + // 매핑 데이터 변환 + const mappings = [1, 2, 3].map((depth) => { + const existing = group.mappings?.find((m) => m.depth === depth); + return { + depth, + levelLabel: existing?.level_label || (depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"), + columnName: existing?.column_name || "", + placeholder: existing?.placeholder || `${depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"} 선택`, + isRequired: existing?.is_required === "Y", + }; + }); + + setFormData({ + groupCode: group.group_code, + groupName: group.group_name, + description: group.description || "", + codeCategory: group.code_category, + tableName: group.table_name, + maxDepth: group.max_depth, + mappings, + }); + + // 컬럼 로드 + loadColumns(group.table_name); + setModalOpen(true); + }; + + // 삭제 확인 열기 + const openDeleteDialog = (group: HierarchyColumnGroup) => { + setSelectedGroup(group); + setDeleteDialogOpen(true); + }; + + // 저장 + const handleSave = async () => { + // 필수 필드 검증 + if (!formData.groupCode || !formData.groupName || !formData.codeCategory || !formData.tableName) { + toast.error("필수 필드를 모두 입력해주세요."); + return; + } + + // 최소 1개 컬럼 매핑 검증 + const validMappings = formData.mappings + .filter((m) => m.depth <= formData.maxDepth && m.columnName) + .map((m) => ({ + depth: m.depth, + levelLabel: m.levelLabel, + columnName: m.columnName, + placeholder: m.placeholder, + isRequired: m.isRequired, + })); + + if (validMappings.length === 0) { + toast.error("최소 하나의 컬럼 매핑이 필요합니다."); + return; + } + + try { + if (isEditing && selectedGroup) { + // 수정 + const response = await hierarchyColumnApi.update(selectedGroup.group_id, { + groupName: formData.groupName, + description: formData.description, + maxDepth: formData.maxDepth, + mappings: validMappings, + }); + + if (response.success) { + toast.success("계층구조 그룹이 수정되었습니다."); + setModalOpen(false); + loadGroups(); + } else { + toast.error(response.error || "수정 실패"); + } + } else { + // 생성 + const request: CreateHierarchyGroupRequest = { + groupCode: formData.groupCode, + groupName: formData.groupName, + description: formData.description, + codeCategory: formData.codeCategory, + tableName: formData.tableName, + maxDepth: formData.maxDepth, + mappings: validMappings, + }; + + const response = await hierarchyColumnApi.create(request); + + if (response.success) { + toast.success("계층구조 그룹이 생성되었습니다."); + setModalOpen(false); + loadGroups(); + } else { + toast.error(response.error || "생성 실패"); + } + } + } catch (error) { + console.error("저장 에러:", error); + toast.error("저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async () => { + if (!selectedGroup) return; + + try { + const response = await hierarchyColumnApi.delete(selectedGroup.group_id); + if (response.success) { + toast.success("계층구조 그룹이 삭제되었습니다."); + setDeleteDialogOpen(false); + loadGroups(); + } else { + toast.error(response.error || "삭제 실패"); + } + } catch (error) { + console.error("삭제 에러:", error); + toast.error("삭제 중 오류가 발생했습니다."); + } + }; + + // 매핑 컬럼 변경 + const handleMappingChange = (depth: number, field: string, value: any) => { + setFormData((prev) => ({ + ...prev, + mappings: prev.mappings.map((m) => + m.depth === depth ? { ...m, [field]: value } : m + ), + })); + }; + + return ( +
+ {/* 헤더 */} +
+
+

계층구조 컬럼 그룹

+

+ 공통코드 계층구조를 테이블 컬럼에 매핑하여 대분류/중분류/소분류를 각각 별도 컬럼에 저장합니다. +

+
+
+ + +
+
+ + {/* 그룹 목록 */} + {loading ? ( +
+ + 로딩 중... +
+ ) : groups.length === 0 ? ( + + + +

계층구조 컬럼 그룹이 없습니다.

+ +
+
+ ) : ( +
+ {groups.map((group) => ( + + +
+
+ {group.group_name} + {group.group_code} +
+
+ + +
+
+
+ +
+ + {group.table_name} +
+
+ {group.code_category} + {group.max_depth}단계 +
+ {group.mappings && group.mappings.length > 0 && ( +
+ {group.mappings.map((mapping) => ( +
+ + {mapping.level_label} + + {mapping.column_name} +
+ ))} +
+ )} +
+
+ ))} +
+ )} + + {/* 생성/수정 모달 */} + + + + {isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"} + + 공통코드 계층구조를 테이블 컬럼에 매핑합니다. + + + +
+ {/* 기본 정보 */} +
+
+ + setFormData({ ...formData, groupCode: e.target.value.toUpperCase() })} + placeholder="예: ITEM_CAT_HIERARCHY" + disabled={isEditing} + /> +
+
+ + setFormData({ ...formData, groupName: e.target.value })} + placeholder="예: 품목분류 계층" + /> +
+
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="계층구조에 대한 설명" + /> +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + {/* 컬럼 매핑 */} +
+ +

+ 각 계층 레벨에 저장할 컬럼을 선택합니다. +

+ + {formData.mappings + .filter((m) => m.depth <= formData.maxDepth) + .map((mapping) => ( +
+
+ + {mapping.depth}단계 + + handleMappingChange(mapping.depth, "levelLabel", e.target.value)} + className="h-8 text-xs" + placeholder="라벨" + /> +
+ + handleMappingChange(mapping.depth, "placeholder", e.target.value)} + className="h-8 text-xs" + placeholder="플레이스홀더" + /> +
+ handleMappingChange(mapping.depth, "isRequired", e.target.checked)} + className="h-4 w-4" + /> + 필수 +
+
+ ))} +
+
+ + + + + +
+
+ + {/* 삭제 확인 다이얼로그 */} + + + + 계층구조 그룹 삭제 + + "{selectedGroup?.group_name}" 그룹을 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + + + +
+
+
+ ); +} + diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index b554dff1..0b5ff573 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -56,6 +56,7 @@ interface ColumnTypeInfo { referenceColumn?: string; displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 + hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할 } interface SecondLevelMenu { @@ -292,11 +293,27 @@ export default function TableManagementPage() { }); // 컬럼 데이터에 기본값 설정 - const processedColumns = (data.columns || data).map((col: any) => ({ - ...col, - inputType: col.inputType || "text", // 기본값: text - categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 - })); + const processedColumns = (data.columns || data).map((col: any) => { + // detailSettings에서 hierarchyRole 추출 + let hierarchyRole: "large" | "medium" | "small" | undefined = undefined; + if (col.detailSettings && typeof col.detailSettings === "string") { + try { + const parsed = JSON.parse(col.detailSettings); + if (parsed.hierarchyRole === "large" || parsed.hierarchyRole === "medium" || parsed.hierarchyRole === "small") { + hierarchyRole = parsed.hierarchyRole; + } + } catch { + // JSON 파싱 실패 시 무시 + } + } + + return { + ...col, + inputType: col.inputType || "text", // 기본값: text + categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 + hierarchyRole, // 계층구조 역할 + }; + }); if (page === 1) { setColumns(processedColumns); @@ -367,18 +384,40 @@ export default function TableManagementPage() { let referenceTable = col.referenceTable; let referenceColumn = col.referenceColumn; let displayColumn = col.displayColumn; + let hierarchyRole = col.hierarchyRole; if (settingType === "code") { if (value === "none") { newDetailSettings = ""; codeCategory = undefined; codeValue = undefined; + hierarchyRole = undefined; // 코드 선택 해제 시 계층 역할도 초기화 } else { - const codeOption = commonCodeOptions.find((option) => option.value === value); - newDetailSettings = codeOption ? `공통코드: ${codeOption.label}` : ""; + // 기존 hierarchyRole 유지하면서 JSON 형식으로 저장 + const existingHierarchyRole = hierarchyRole; + newDetailSettings = JSON.stringify({ + codeCategory: value, + hierarchyRole: existingHierarchyRole + }); codeCategory = value; codeValue = value; } + } else if (settingType === "hierarchy_role") { + // 계층구조 역할 변경 - JSON 형식으로 저장 + hierarchyRole = value === "none" ? undefined : (value as "large" | "medium" | "small"); + // detailSettings를 JSON으로 업데이트 + let existingSettings: Record = {}; + if (typeof col.detailSettings === "string" && col.detailSettings.trim().startsWith("{")) { + try { + existingSettings = JSON.parse(col.detailSettings); + } catch { + existingSettings = {}; + } + } + newDetailSettings = JSON.stringify({ + ...existingSettings, + hierarchyRole: hierarchyRole, + }); } else if (settingType === "entity") { if (value === "none") { newDetailSettings = ""; @@ -415,6 +454,7 @@ export default function TableManagementPage() { referenceTable, referenceColumn, displayColumn, + hierarchyRole, }; } return col; @@ -487,6 +527,26 @@ export default function TableManagementPage() { console.log("🔧 Entity 설정 JSON 생성:", entitySettings); } + // 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함 + if (column.inputType === "code" && column.hierarchyRole) { + let existingSettings: Record = {}; + if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { + try { + existingSettings = JSON.parse(finalDetailSettings); + } catch { + existingSettings = {}; + } + } + + const codeSettings = { + ...existingSettings, + hierarchyRole: column.hierarchyRole, + }; + + finalDetailSettings = JSON.stringify(codeSettings); + console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings); + } + const columnSetting = { columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnLabel: column.displayName, // 사용자가 입력한 표시명 @@ -1229,23 +1289,44 @@ export default function TableManagementPage() { {/* 입력 타입이 'code'인 경우 공통코드 선택 */} {column.inputType === "code" && ( - + <> + + {/* 계층구조 역할 선택 */} + {column.codeCategory && column.codeCategory !== "none" && ( + + )} + )} {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} {column.inputType === "category" && ( diff --git a/frontend/components/admin/CodeDetailPanel.tsx b/frontend/components/admin/CodeDetailPanel.tsx index 62f33cd2..3110a5ee 100644 --- a/frontend/components/admin/CodeDetailPanel.tsx +++ b/frontend/components/admin/CodeDetailPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -45,15 +45,124 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { const reorderCodesMutation = useReorderCodes(); // 드래그앤드롭을 위해 필터링된 코드 목록 사용 - const { filteredItems: filteredCodes } = useSearchAndFilter(codes, { + const { filteredItems: filteredCodesRaw } = useSearchAndFilter(codes, { searchFields: ["code_name", "code_value"], }); + // 계층 구조로 정렬 (부모 → 자식 순서) + const filteredCodes = useMemo(() => { + if (!filteredCodesRaw || filteredCodesRaw.length === 0) return []; + + // 코드를 계층 순서로 정렬하는 함수 + const sortHierarchically = (codes: CodeInfo[]): CodeInfo[] => { + const result: CodeInfo[] = []; + const codeMap = new Map(); + const childrenMap = new Map(); + + // 코드 맵 생성 + codes.forEach((code) => { + const codeValue = code.codeValue || code.code_value || ""; + const parentValue = code.parentCodeValue || code.parent_code_value; + codeMap.set(codeValue, code); + + if (parentValue) { + if (!childrenMap.has(parentValue)) { + childrenMap.set(parentValue, []); + } + childrenMap.get(parentValue)!.push(code); + } + }); + + // 재귀적으로 트리 구조 순회 + const traverse = (parentValue: string | null, depth: number) => { + const children = parentValue + ? childrenMap.get(parentValue) || [] + : codes.filter((c) => !c.parentCodeValue && !c.parent_code_value); + + // 정렬 순서로 정렬 + children + .sort((a, b) => (a.sortOrder || a.sort_order || 0) - (b.sortOrder || b.sort_order || 0)) + .forEach((code) => { + result.push(code); + const codeValue = code.codeValue || code.code_value || ""; + traverse(codeValue, depth + 1); + }); + }; + + traverse(null, 1); + + // 트리에 포함되지 않은 코드들도 추가 (orphan 코드) + codes.forEach((code) => { + if (!result.includes(code)) { + result.push(code); + } + }); + + return result; + }; + + return sortHierarchically(filteredCodesRaw); + }, [filteredCodesRaw]); + // 모달 상태 const [showFormModal, setShowFormModal] = useState(false); const [editingCode, setEditingCode] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); const [deletingCode, setDeletingCode] = useState(null); + const [defaultParentCode, setDefaultParentCode] = useState(undefined); + + // 트리 접기/펼치기 상태 (코드값 Set) + const [collapsedCodes, setCollapsedCodes] = useState>(new Set()); + + // 자식 정보 계산 + const childrenMap = useMemo(() => { + const map = new Map(); + codes.forEach((code) => { + const parentValue = code.parentCodeValue || code.parent_code_value; + if (parentValue) { + if (!map.has(parentValue)) { + map.set(parentValue, []); + } + map.get(parentValue)!.push(code); + } + }); + return map; + }, [codes]); + + // 접기/펼치기 토글 + const toggleExpand = (codeValue: string) => { + setCollapsedCodes((prev) => { + const newSet = new Set(prev); + if (newSet.has(codeValue)) { + newSet.delete(codeValue); + } else { + newSet.add(codeValue); + } + return newSet; + }); + }; + + // 특정 코드가 표시되어야 하는지 확인 (부모가 접혀있으면 숨김) + const isCodeVisible = (code: CodeInfo): boolean => { + const parentValue = code.parentCodeValue || code.parent_code_value; + if (!parentValue) return true; // 최상위 코드는 항상 표시 + + // 부모가 접혀있으면 숨김 + if (collapsedCodes.has(parentValue)) return false; + + // 부모의 부모도 확인 (재귀적으로) + const parentCode = codes.find((c) => (c.codeValue || c.code_value) === parentValue); + if (parentCode) { + return isCodeVisible(parentCode); + } + + return true; + }; + + // 표시할 코드 목록 (접힌 상태 반영) + const visibleCodes = useMemo(() => { + return filteredCodes.filter(isCodeVisible); + }, [filteredCodes, collapsedCodes, codes]); // 드래그 앤 드롭 훅 사용 const dragAndDrop = useDragAndDrop({ @@ -73,12 +182,21 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { // 새 코드 생성 const handleNewCode = () => { setEditingCode(null); + setDefaultParentCode(undefined); setShowFormModal(true); }; // 코드 수정 const handleEditCode = (code: CodeInfo) => { setEditingCode(code); + setDefaultParentCode(undefined); + setShowFormModal(true); + }; + + // 하위 코드 추가 + const handleAddChild = (parentCode: CodeInfo) => { + setEditingCode(null); + setDefaultParentCode(parentCode.codeValue || parentCode.code_value || ""); setShowFormModal(true); }; @@ -110,7 +228,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { if (!categoryCode) { return (
-

카테고리를 선택하세요

+

카테고리를 선택하세요

); } @@ -119,7 +237,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { return (
-

코드를 불러오는 중 오류가 발생했습니다.

+

코드를 불러오는 중 오류가 발생했습니다.

@@ -135,7 +253,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { {/* 검색 + 버튼 */}
- + setShowActiveOnly(e.target.checked)} - className="h-4 w-4 rounded border-input" + className="border-input h-4 w-4 rounded" /> -
@@ -170,9 +288,9 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
- ) : filteredCodes.length === 0 ? ( + ) : visibleCodes.length === 0 ? (
-

+

{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}

@@ -180,23 +298,35 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { <> code.codeValue || code.code_value)} + items={visibleCodes.map((code) => code.codeValue || code.code_value)} strategy={verticalListSortingStrategy} > - {filteredCodes.map((code, index) => ( - handleEditCode(code)} - onDelete={() => handleDeleteCode(code)} - /> - ))} + {visibleCodes.map((code, index) => { + const codeValue = code.codeValue || code.code_value || ""; + const children = childrenMap.get(codeValue) || []; + const hasChildren = children.length > 0; + const isExpanded = !collapsedCodes.has(codeValue); + + return ( + handleEditCode(code)} + onDelete={() => handleDeleteCode(code)} + onAddChild={() => handleAddChild(code)} + hasChildren={hasChildren} + childCount={children.length} + isExpanded={isExpanded} + onToggleExpand={() => toggleExpand(codeValue)} + /> + ); + })} {dragAndDrop.activeItem ? ( -
+
{(() => { const activeCode = dragAndDrop.activeItem; if (!activeCode) return null; @@ -204,24 +334,20 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
-

- {activeCode.codeName || activeCode.code_name} -

+

{activeCode.codeName || activeCode.code_name}

{activeCode.isActive === "Y" || activeCode.is_active === "Y" ? "활성" : "비활성"}
-

+

{activeCode.codeValue || activeCode.code_value}

{activeCode.description && ( -

{activeCode.description}

+

{activeCode.description}

)}
@@ -236,13 +362,13 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { {isFetchingNextPage && (
- 코드를 더 불러오는 중... + 코드를 더 불러오는 중...
)} {/* 모든 코드 로드 완료 메시지 */} {!hasNextPage && codes.length > 0 && ( -
모든 코드를 불러왔습니다.
+
모든 코드를 불러왔습니다.
)} )} @@ -255,10 +381,12 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) { onClose={() => { setShowFormModal(false); setEditingCode(null); + setDefaultParentCode(undefined); }} categoryCode={categoryCode} editingCode={editingCode} codes={codes} + defaultParentCode={defaultParentCode} /> )} diff --git a/frontend/components/admin/CodeFormModal.tsx b/frontend/components/admin/CodeFormModal.tsx index 977e9e84..b5a8847b 100644 --- a/frontend/components/admin/CodeFormModal.tsx +++ b/frontend/components/admin/CodeFormModal.tsx @@ -24,6 +24,7 @@ interface CodeFormModalProps { categoryCode: string; editingCode?: CodeInfo | null; codes: CodeInfo[]; + defaultParentCode?: string; // 하위 코드 추가 시 기본 부모 코드 } // 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수 @@ -33,28 +34,32 @@ const getErrorMessage = (error: FieldError | undefined): string => { return error.message || ""; }; -export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, codes }: CodeFormModalProps) { +// 코드값 자동 생성 함수 (UUID 기반 짧은 코드) +const generateCodeValue = (): string => { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `${timestamp}${random}`; +}; + +export function CodeFormModal({ + isOpen, + onClose, + categoryCode, + editingCode, + codes, + defaultParentCode, +}: CodeFormModalProps) { const createCodeMutation = useCreateCode(); const updateCodeMutation = useUpdateCode(); const isEditing = !!editingCode; - // 검증 상태 관리 + // 검증 상태 관리 (코드명만 중복 검사) const [validationStates, setValidationStates] = useState({ - codeValue: { enabled: false, value: "" }, codeName: { enabled: false, value: "" }, - codeNameEng: { enabled: false, value: "" }, }); - // 중복 검사 훅들 - const codeValueCheck = useCheckCodeDuplicate( - categoryCode, - "codeValue", - validationStates.codeValue.value, - isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined, - validationStates.codeValue.enabled, - ); - + // 코드명 중복 검사 const codeNameCheck = useCheckCodeDuplicate( categoryCode, "codeName", @@ -63,22 +68,11 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code validationStates.codeName.enabled, ); - const codeNameEngCheck = useCheckCodeDuplicate( - categoryCode, - "codeNameEng", - validationStates.codeNameEng.value, - isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined, - validationStates.codeNameEng.enabled, - ); - // 중복 검사 결과 확인 - const hasDuplicateErrors = - (codeValueCheck.data?.isDuplicate && validationStates.codeValue.enabled) || - (codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled) || - (codeNameEngCheck.data?.isDuplicate && validationStates.codeNameEng.enabled); + const hasDuplicateErrors = codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled; // 중복 검사 로딩 중인지 확인 - const isDuplicateChecking = codeValueCheck.isLoading || codeNameCheck.isLoading || codeNameEngCheck.isLoading; + const isDuplicateChecking = codeNameCheck.isLoading; // 폼 스키마 선택 (생성/수정에 따라) const schema = isEditing ? updateCodeSchema : createCodeSchema; @@ -92,6 +86,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code codeNameEng: "", description: "", sortOrder: 1, + parentCodeValue: "" as string | undefined, ...(isEditing && { isActive: "Y" as const }), }, }); @@ -101,30 +96,40 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code if (isOpen) { if (isEditing && editingCode) { // 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정) + const parentValue = editingCode.parentCodeValue || editingCode.parent_code_value || ""; + form.reset({ codeName: editingCode.codeName || editingCode.code_name, codeNameEng: editingCode.codeNameEng || editingCode.code_name_eng || "", description: editingCode.description || "", sortOrder: editingCode.sortOrder || editingCode.sort_order, - isActive: (editingCode.isActive || editingCode.is_active) as "Y" | "N", // 타입 캐스팅 + isActive: (editingCode.isActive || editingCode.is_active) as "Y" | "N", + parentCodeValue: parentValue, }); // codeValue는 별도로 설정 (표시용) form.setValue("codeValue" as any, editingCode.codeValue || editingCode.code_value); } else { // 새 코드 모드: 자동 순서 계산 - const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order)) : 0; + const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order || 0)) : 0; + + // 기본 부모 코드가 있으면 설정 (하위 코드 추가 시) + const parentValue = defaultParentCode || ""; + + // 코드값 자동 생성 + const autoCodeValue = generateCodeValue(); form.reset({ - codeValue: "", + codeValue: autoCodeValue, codeName: "", codeNameEng: "", description: "", sortOrder: maxSortOrder + 1, + parentCodeValue: parentValue, }); } } - }, [isOpen, isEditing, editingCode, codes]); + }, [isOpen, isEditing, editingCode, codes, defaultParentCode]); const handleSubmit = form.handleSubmit(async (data) => { try { @@ -132,7 +137,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code // 수정 await updateCodeMutation.mutateAsync({ categoryCode, - codeValue: editingCode.codeValue || editingCode.code_value, + codeValue: editingCode.codeValue || editingCode.code_value || "", data: data as UpdateCodeData, }); } else { @@ -156,50 +161,38 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code - {isEditing ? "코드 수정" : "새 코드"} + + {isEditing ? "코드 수정" : defaultParentCode ? "하위 코드 추가" : "새 코드"} +
- {/* 코드값 */} -
- - { - const value = e.target.value.trim(); - if (value && !isEditing) { - setValidationStates((prev) => ({ - ...prev, - codeValue: { enabled: true, value }, - })); - } - }} - /> - {(form.formState.errors as any)?.codeValue && ( -

{getErrorMessage((form.formState.errors as any)?.codeValue)}

- )} - {!isEditing && !(form.formState.errors as any)?.codeValue && ( - - )} -
+ {/* 코드값 (자동 생성, 수정 시에만 표시) */} + {isEditing && ( +
+ +
+ {form.watch("codeValue")} +
+

코드값은 변경할 수 없습니다

+
+ )} {/* 코드명 */}
- + { const value = e.target.value.trim(); if (value) { @@ -211,7 +204,9 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code }} /> {form.formState.errors.codeName && ( -

{getErrorMessage(form.formState.errors.codeName)}

+

+ {getErrorMessage(form.formState.errors.codeName)} +

)} {!form.formState.errors.codeName && ( - {/* 영문명 */} + {/* 영문명 (선택) */}
- + { - const value = e.target.value.trim(); - if (value) { - setValidationStates((prev) => ({ - ...prev, - codeNameEng: { enabled: true, value }, - })); - } - }} + placeholder="코드 영문명을 입력하세요 (선택사항)" + className="h-8 text-xs sm:h-10 sm:text-sm" /> - {form.formState.errors.codeNameEng && ( -

{getErrorMessage(form.formState.errors.codeNameEng)}

- )} - {!form.formState.errors.codeNameEng && ( - - )}
- {/* 설명 */} + {/* 설명 (선택) */}
- +