From 6a4ebf362cbf0586fdc3bfed16af090d5ecc67f5 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 22 Dec 2025 14:36:13 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(UniversalFormModal):=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=ED=91=9C=EC=8B=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80=20ConfigPan?= =?UTF-8?q?el=EC=97=90=20showSaveButton=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EC=B2=B4=ED=81=AC=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EB=AA=A8=EB=8B=AC=20=ED=95=98=EB=8B=A8?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=20SaveSettingsModal=20SelectItem=20key=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=95=B4=EA=B2=B0=20=EC=84=9C=EB=B8=8C=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=82=AD=EC=A0=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalConfigPanel.tsx | 16 ++++++++ .../modals/SaveSettingsModal.tsx | 38 +++++++++---------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 4ef28d6f..aa2386be 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -4,6 +4,7 @@ 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 { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; @@ -334,6 +335,21 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor 모달 창의 크기를 선택하세요 + {/* 저장 버튼 표시 설정 */} +
+
+ updateModalConfig({ showSaveButton: checked === true })} + /> + +
+ 체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다 +
+
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index 2607cf83..c9976ed8 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -217,8 +217,8 @@ export function SaveSettingsModal({ const repeatSections = sections.filter((s) => s.repeatable); // 모든 필드 목록 (반복 섹션 포함) - const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => { - const fields: { columnName: string; label: string; sectionTitle: string }[] = []; + const getAllFields = (): { columnName: string; label: string; sectionTitle: string; sectionId: string }[] => { + const fields: { columnName: string; label: string; sectionTitle: string; sectionId: string }[] = []; sections.forEach((section) => { // 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined) if (section.fields && Array.isArray(section.fields)) { @@ -227,6 +227,7 @@ export function SaveSettingsModal({ columnName: field.columnName, label: field.label, sectionTitle: section.title, + sectionId: section.id, }); }); } @@ -550,8 +551,8 @@ export function SaveSettingsModal({ return ( - -
+
+
서브 테이블 {subIndex + 1}: {subTable.tableName || "(미설정)"} @@ -560,19 +561,18 @@ export function SaveSettingsModal({ ({subTable.fieldMappings?.length || 0}개 매핑)
- -
- + + +
@@ -755,8 +755,8 @@ export function SaveSettingsModal({ - {allFields.map((field) => ( - + {allFields.map((field, fieldIndex) => ( + {field.label} ({field.sectionTitle}) ))} From 99c09603254d725a5a482ecbd73b1b8a110c9a7b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 22 Dec 2025 17:42:35 +0900 Subject: [PATCH 02/15] =?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 03/15] =?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 04/15] =?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 533eaf5c9ff14bcb99965668b0e8e4b5d0ff7c5d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 23 Dec 2025 09:24:59 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat(TableSection):=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=BB=AC=EB=9F=BC=20=EB=B6=80=EB=AA=A8=EA=B0=92=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?TableColumnConfig=EC=97=90=20receiveFromParent,=20parentFieldNa?= =?UTF-8?q?me=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80=20allComponents?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=80=EB=AA=A8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=9E=90=EB=8F=99=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=84=A4=EC=A0=95=EC=97=90=20"=EB=B6=80?= =?UTF-8?q?=EB=AA=A8=EA=B0=92"=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B6=80=EB=AA=A8=20=ED=95=84=EB=93=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20UI=20=EC=B6=94=EA=B0=80=20handleAddItems()=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B6=80=EB=AA=A8=EA=B0=92=20=EC=9E=90=EB=8F=99=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableSectionRenderer.tsx | 8 + .../UniversalFormModalConfigPanel.tsx | 206 ++++++++++++- .../modals/FieldDetailSettingsModal.tsx | 57 ++++ .../modals/SaveSettingsModal.tsx | 276 +++++++++++++----- .../modals/SectionLayoutModal.tsx | 226 ++++++++++++-- .../modals/TableSectionSettingsModal.tsx | 120 ++++++++ .../components/universal-form-modal/types.ts | 6 + 7 files changed, 805 insertions(+), 94 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 224459f0..047849b6 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -621,6 +621,14 @@ export function TableSectionRenderer({ if (col.defaultValue !== undefined && newItem[col.field] === undefined) { newItem[col.field] = col.defaultValue; } + + // 부모에서 값 받기 (receiveFromParent) + if (col.receiveFromParent) { + const parentField = col.parentFieldName || col.field; + if (formData[parentField] !== undefined) { + newItem[col.field] = formData[parentField]; + } + } } return newItem; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index aa2386be..b2d5a9da 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -48,13 +48,24 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); -export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFormModalConfigPanelProps) { +// 부모 화면에서 전달 가능한 필드 타입 +interface AvailableParentField { + name: string; // 필드명 (columnName) + label: string; // 표시 라벨 + sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2") + sourceTable?: string; // 출처 테이블명 +} + +export function UniversalFormModalConfigPanel({ config, onChange, allComponents = [] }: UniversalFormModalConfigPanelProps) { // 테이블 목록 const [tables, setTables] = useState<{ name: string; label: string }[]>([]); const [tableColumns, setTableColumns] = useState<{ [tableName: string]: { name: string; type: string; label: string }[]; }>({}); + // 부모 화면에서 전달 가능한 필드 목록 + const [availableParentFields, setAvailableParentFields] = useState([]); + // 채번규칙 목록 const [numberingRules, setNumberingRules] = useState<{ id: string; name: string }[]>([]); @@ -72,6 +83,186 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor loadNumberingRules(); }, []); + // allComponents에서 부모 화면에서 전달 가능한 필드 추출 + useEffect(() => { + const extractParentFields = async () => { + if (!allComponents || allComponents.length === 0) { + setAvailableParentFields([]); + return; + } + + const fields: AvailableParentField[] = []; + + for (const comp of allComponents) { + // 컴포넌트 타입 추출 (여러 위치에서 확인) + const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type; + const compConfig = comp.componentConfig || {}; + + // 1. TableList / InteractiveDataTable - 테이블 컬럼 추출 + if (compType === "table-list" || compType === "interactive-data-table") { + const tableName = compConfig.selectedTable || compConfig.tableName; + if (tableName) { + // 테이블 컬럼 로드 + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "TableList", + sourceTable: tableName, + }); + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${tableName}):`, error); + } + } + } + + // 2. SplitPanelLayout2 - 데이터 전달 필드 및 소스 테이블 컬럼 추출 + if (compType === "split-panel-layout2") { + // dataTransferFields 추출 + const transferFields = compConfig.dataTransferFields; + if (transferFields && Array.isArray(transferFields)) { + transferFields.forEach((field: any) => { + if (field.targetColumn) { + fields.push({ + name: field.targetColumn, + label: field.targetColumn, + sourceComponent: "SplitPanelLayout2", + sourceTable: compConfig.leftPanel?.tableName, + }); + } + }); + } + + // 좌측 패널 테이블 컬럼도 추출 + const leftTableName = compConfig.leftPanel?.tableName; + if (leftTableName) { + try { + const response = await apiClient.get(`/table-management/tables/${leftTableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + // 중복 방지 + if (!fields.some(f => f.name === colName && f.sourceTable === leftTableName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "SplitPanelLayout2 (좌측)", + sourceTable: leftTableName, + }); + } + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${leftTableName}):`, error); + } + } + } + + // 3. 기타 테이블 관련 컴포넌트 + if (compType === "card-display" || compType === "simple-repeater-table") { + const tableName = compConfig.tableName || compConfig.initialDataConfig?.sourceTable; + if (tableName) { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + if (!fields.some(f => f.name === colName && f.sourceTable === tableName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: compType, + sourceTable: tableName, + }); + } + }); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${tableName}):`, error); + } + } + } + + // 4. 버튼 컴포넌트 - openModalWithData의 fieldMappings/dataMapping에서 소스 컬럼 추출 + if (compType === "button-primary" || compType === "button" || compType === "button-secondary") { + const action = compConfig.action || {}; + + // fieldMappings에서 소스 컬럼 추출 + const fieldMappings = action.fieldMappings || []; + fieldMappings.forEach((mapping: any) => { + if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) { + fields.push({ + name: mapping.sourceColumn, + label: mapping.sourceColumn, + sourceComponent: "Button (fieldMappings)", + sourceTable: action.sourceTableName, + }); + } + }); + + // dataMapping에서 소스 컬럼 추출 + const dataMapping = action.dataMapping || []; + dataMapping.forEach((mapping: any) => { + if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) { + fields.push({ + name: mapping.sourceColumn, + label: mapping.sourceColumn, + sourceComponent: "Button (dataMapping)", + sourceTable: action.sourceTableName, + }); + } + }); + } + } + + // 5. 현재 모달의 저장 테이블 컬럼도 추가 (부모에서 전달받을 수 있는 값들) + const currentTableName = config.saveConfig?.tableName; + if (currentTableName) { + try { + const response = await apiClient.get(`/table-management/tables/${currentTableName}/columns`); + const columns = response.data?.data?.columns; + if (response.data?.success && Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnComment || col.column_comment || colName; + if (!fields.some(f => f.name === colName)) { + fields.push({ + name: colName, + label: colLabel, + sourceComponent: "현재 폼 테이블", + sourceTable: currentTableName, + }); + } + }); + } + } catch (error) { + console.error(`현재 테이블 컬럼 로드 실패 (${currentTableName}):`, error); + } + } + + // 중복 제거 (같은 name이면 첫 번째만 유지) + const uniqueFields = fields.filter((field, index, self) => + index === self.findIndex(f => f.name === field.name) + ); + + setAvailableParentFields(uniqueFields); + }; + + extractParentFields(); + }, [allComponents, config.saveConfig?.tableName]); + // 저장 테이블 변경 시 컬럼 로드 useEffect(() => { if (config.saveConfig.tableName) { @@ -85,9 +276,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor const data = response.data?.data; if (response.data?.success && Array.isArray(data)) { setTables( - data.map((t: { tableName?: string; table_name?: string; tableLabel?: string; table_label?: string }) => ({ + data.map((t: { tableName?: string; table_name?: string; displayName?: string; tableLabel?: string; table_label?: string }) => ({ name: t.tableName || t.table_name || "", - label: t.tableLabel || t.table_label || t.tableName || t.table_name || "", + // displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명 + label: t.displayName || t.tableLabel || t.table_label || "", })), ); } @@ -620,6 +812,12 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor setSelectedField(field); setFieldDetailModalOpen(true); }} + tableName={config.saveConfig.tableName} + tableColumns={tableColumns[config.saveConfig.tableName || ""]?.map(col => ({ + name: col.name, + type: col.type, + label: col.label || col.name + })) || []} /> )} @@ -666,6 +864,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor tableColumns={tableColumns} numberingRules={numberingRules} onLoadTableColumns={loadTableColumns} + availableParentFields={availableParentFields} /> )} @@ -706,6 +905,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} onLoadTableColumns={loadTableColumns} allSections={config.sections as FormSectionConfig[]} + availableParentFields={availableParentFields} /> )}
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index d53d6e00..c8584a5f 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -36,6 +36,17 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +/** + * 부모 화면에서 전달 가능한 필드 타입 + * 유니버셜 폼 모달에서 "부모에서 값 받기" 설정 시 선택 가능한 필드 목록 + */ +export interface AvailableParentField { + name: string; // 필드명 (columnName) + label: string; // 표시 라벨 + sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2") + sourceTable?: string; // 출처 테이블명 +} + interface FieldDetailSettingsModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -45,6 +56,8 @@ interface FieldDetailSettingsModalProps { tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] }; numberingRules: { id: string; name: string }[]; onLoadTableColumns: (tableName: string) => void; + // 부모 화면에서 전달 가능한 필드 목록 (선택사항) + availableParentFields?: AvailableParentField[]; } export function FieldDetailSettingsModal({ @@ -56,6 +69,7 @@ export function FieldDetailSettingsModal({ tableColumns, numberingRules, onLoadTableColumns, + availableParentFields = [], }: FieldDetailSettingsModalProps) { // 로컬 상태로 필드 설정 관리 const [localField, setLocalField] = useState(field); @@ -293,6 +307,49 @@ export function FieldDetailSettingsModal({ />
부모 화면에서 전달받은 값으로 자동 채워집니다 + + {/* 부모에서 값 받기 활성화 시 필드 선택 */} + {localField.receiveFromParent && ( +
+ + {availableParentFields.length > 0 ? ( + + ) : ( +
+ updateField({ parentFieldName: e.target.value })} + placeholder={`예: ${localField.columnName || "parent_field_name"}`} + className="h-8 text-xs" + /> +

+ 부모 화면에서 전달받을 필드명을 입력하세요. 비워두면 "{localField.columnName}"을 사용합니다. +

+
+ )} +
+ )}
{/* Accordion으로 고급 설정 */} diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index c9976ed8..5be176dc 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; -import { Plus, Trash2, Database, Layers, Info } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Plus, Trash2, Database, Layers, Info, Check, ChevronsUpDown } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types"; @@ -50,6 +52,11 @@ export function SaveSettingsModal({ saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single" ); + // 테이블 검색 Popover 상태 + const [singleTableSearchOpen, setSingleTableSearchOpen] = useState(false); + const [mainTableSearchOpen, setMainTableSearchOpen] = useState(false); + const [subTableSearchOpen, setSubTableSearchOpen] = useState>({}); + // open이 변경될 때마다 데이터 동기화 useEffect(() => { if (open) { @@ -376,24 +383,68 @@ export function SaveSettingsModal({
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + onLoadTableColumns(t.name); + setSingleTableSearchOpen(false); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+
폼 데이터를 저장할 테이블을 선택하세요
@@ -426,37 +477,81 @@ export function SaveSettingsModal({
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSaveConfig({ + customApiSave: { + ...localSaveConfig.customApiSave, + apiType: "multi-table", + multiTable: { + ...localSaveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...localSaveConfig.customApiSave?.multiTable?.mainTable, + tableName: t.name, + }, + }, + }, + }); + onLoadTableColumns(t.name); + setMainTableSearchOpen(false); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+
주요 데이터를 저장할 메인 테이블 (예: orders, user_info)
@@ -576,24 +671,71 @@ export function SaveSettingsModal({
- + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + updateSubTable(subIndex, { tableName: t.name }); + onLoadTableColumns(t.name); + setSubTableSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-xs" + > + +
+ {t.name} + {t.label && ( + {t.label} + )} +
+
+ ))} +
+
+
+
+ 반복 데이터를 저장할 서브 테이블
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx index 4a90a777..b47c2424 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx @@ -11,7 +11,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; -import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon, Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormSectionConfig, FormFieldConfig, OptionalFieldGroupConfig, FIELD_TYPE_OPTIONS } from "../types"; import { defaultFieldConfig, generateFieldId, generateUniqueId } from "../config"; @@ -21,12 +23,22 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +// 테이블 컬럼 정보 타입 +interface TableColumnInfo { + name: string; + type: string; + label: string; +} + interface SectionLayoutModalProps { open: boolean; onOpenChange: (open: boolean) => void; section: FormSectionConfig; onSave: (updates: Partial) => void; onOpenFieldDetail: (field: FormFieldConfig) => void; + // 저장 테이블의 컬럼 정보 + tableName?: string; + tableColumns?: TableColumnInfo[]; } export function SectionLayoutModal({ @@ -35,8 +47,13 @@ export function SectionLayoutModal({ section, onSave, onOpenFieldDetail, + tableName = "", + tableColumns = [], }: SectionLayoutModalProps) { + // 컬럼 선택 Popover 상태 (필드별) + const [columnSearchOpen, setColumnSearchOpen] = useState>({}); + // 로컬 상태로 섹션 관리 (fields가 없으면 빈 배열로 초기화) const [localSection, setLocalSection] = useState(() => ({ ...section, @@ -443,11 +460,90 @@ export function SectionLayoutModal({
- updateField(field.id, { columnName: e.target.value })} - className="h-6 text-[9px] mt-0.5" - /> + {tableColumns.length > 0 ? ( + setColumnSearchOpen(prev => ({ ...prev, [field.id]: open }))} + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {tableColumns.map((col) => ( + { + updateField(field.id, { + columnName: col.name, + // 라벨이 기본값이면 컬럼 라벨로 자동 설정 + ...(field.label.startsWith("새 필드") || field.label.startsWith("field_") + ? { label: col.label || col.name } + : {}) + }); + setColumnSearchOpen(prev => ({ ...prev, [field.id]: false })); + }} + className="text-xs" + > + +
+
+ {col.name} + {col.label && col.label !== col.name && ( + ({col.label}) + )} +
+ {tableName && ( + {tableName} + )} +
+
+ ))} +
+
+
+
+
+ ) : ( + updateField(field.id, { columnName: e.target.value })} + className="h-6 text-[9px] mt-0.5" + placeholder="저장 테이블을 먼저 설정하세요" + /> + )}
@@ -821,24 +917,106 @@ export function SectionLayoutModal({ className="h-5 text-[8px]" placeholder="라벨" /> - { - const newGroups = localSection.optionalFieldGroups?.map((g) => - g.id === group.id - ? { - ...g, - fields: g.fields.map((f) => - f.id === field.id ? { ...f, columnName: e.target.value } : f - ), - } - : g - ); - updateSection({ optionalFieldGroups: newGroups }); - }} - className="h-5 text-[8px]" - placeholder="컬럼명" - /> + {tableColumns.length > 0 ? ( + setColumnSearchOpen(prev => ({ ...prev, [`opt-${field.id}`]: open }))} + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {tableColumns.map((col) => ( + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id + ? { + ...g, + fields: g.fields.map((f) => + f.id === field.id + ? { + ...f, + columnName: col.name, + ...(f.label.startsWith("필드 ") ? { label: col.label || col.name } : {}) + } + : f + ), + } + : g + ); + updateSection({ optionalFieldGroups: newGroups }); + setColumnSearchOpen(prev => ({ ...prev, [`opt-${field.id}`]: false })); + }} + className="text-[9px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + ({col.label}) + )} +
+
+ ))} +
+
+
+
+
+ ) : ( + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id + ? { + ...g, + fields: g.fields.map((f) => + f.id === field.id ? { ...f, columnName: e.target.value } : f + ), + } + : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="h-5 text-[8px]" + placeholder="컬럼명" + /> + )} onUpdate({ parentFieldName: e.target.value })} + placeholder={col.field} + className="h-8 text-xs" + /> +

+ 비워두면 "{col.field}"를 사용합니다. +

+ + )} + + )} + {/* 조회 설정 (조회 ON일 때만 표시) */} {col.lookup?.enabled && (
@@ -1119,6 +1235,8 @@ interface TableSectionSettingsModalProps { onLoadCategoryList?: () => void; // 전체 섹션 목록 (다른 섹션 필드 참조용) allSections?: FormSectionConfig[]; + // 부모 화면에서 전달 가능한 필드 목록 + availableParentFields?: AvailableParentField[]; } export function TableSectionSettingsModal({ @@ -1132,6 +1250,7 @@ export function TableSectionSettingsModal({ categoryList = [], onLoadCategoryList, allSections = [], + availableParentFields = [], }: TableSectionSettingsModalProps) { // 로컬 상태 const [title, setTitle] = useState(section.title); @@ -1693,6 +1812,7 @@ export function TableSectionSettingsModal({ sections={otherSections} formFields={otherSectionFields} tableConfig={tableConfig} + availableParentFields={availableParentFields} onLoadTableColumns={onLoadTableColumns} onUpdate={(updates) => updateColumn(index, updates)} onMoveUp={() => moveColumn(index, "up")} diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 4e25f7d7..935d46be 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -335,6 +335,10 @@ export interface TableColumnConfig { // 날짜 일괄 적용 (type이 "date"일 때만 사용) // 활성화 시 첫 번째 날짜 입력 시 모든 행에 동일한 날짜가 자동 적용됨 batchApply?: boolean; + + // 부모에서 값 받기 (모든 행에 동일한 값 적용) + receiveFromParent?: boolean; // 부모에서 값 받기 활성화 + parentFieldName?: string; // 부모 필드명 (미지정 시 field와 동일) } // ============================================ @@ -705,6 +709,8 @@ export interface UniversalFormModalComponentProps { export interface UniversalFormModalConfigPanelProps { config: UniversalFormModalConfig; onChange: (config: UniversalFormModalConfig) => void; + // 화면 설계 시 같은 화면의 다른 컴포넌트들 (부모 데이터 필드 추출용) + allComponents?: any[]; } // 필드 타입 옵션 From da195200a87025432b423c7dbb5a1506c81d79bd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 09:49:44 +0900 Subject: [PATCH 06/15] =?UTF-8?q?=20UX=20=EA=B0=9C=EC=84=A0=20-=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EA=B0=92=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?confirm=EC=9D=84=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/ReportDesignerToolbar.tsx | 64 ++++++++++++++++--- .../report/designer/TemplatePalette.tsx | 52 +++++++++++++-- 2 files changed, 99 insertions(+), 17 deletions(-) diff --git a/frontend/components/report/designer/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx index dba01fbb..484dae80 100644 --- a/frontend/components/report/designer/ReportDesignerToolbar.tsx +++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx @@ -42,6 +42,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { SaveAsTemplateModal } from "./SaveAsTemplateModal"; import { reportApi } from "@/lib/api/reportApi"; @@ -93,6 +103,8 @@ export function ReportDesignerToolbar() { } = useReportDesigner(); const [showPreview, setShowPreview] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); + const [showBackConfirm, setShowBackConfirm] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); const { toast } = useToast(); // 버튼 활성화 조건 @@ -120,16 +132,14 @@ export function ReportDesignerToolbar() { router.push("/admin/report"); }; - const handleReset = async () => { - if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) { - await loadLayout(); - } + const handleResetConfirm = async () => { + setShowResetConfirm(false); + await loadLayout(); }; - const handleBack = () => { - if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) { - router.push("/admin/report"); - } + const handleBackConfirm = () => { + setShowBackConfirm(false); + router.push("/admin/report"); }; const handleSaveAsTemplate = async (data: { @@ -193,7 +203,7 @@ export function ReportDesignerToolbar() { <>
- @@ -437,7 +447,7 @@ export function ReportDesignerToolbar() { - @@ -491,6 +501,40 @@ export function ReportDesignerToolbar() { onClose={() => setShowSaveAsTemplate(false)} onSave={handleSaveAsTemplate} /> + + {/* 목록으로 돌아가기 확인 모달 */} + + + + 목록으로 돌아가기 + + 저장하지 않은 변경사항이 있을 수 있습니다. +
+ 목록으로 돌아가시겠습니까? +
+
+ + 취소 + 확인 + +
+
+ + {/* 초기화 확인 모달 */} + + + + 초기화 + + 현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까? + + + + 취소 + 확인 + + + ); } diff --git a/frontend/components/report/designer/TemplatePalette.tsx b/frontend/components/report/designer/TemplatePalette.tsx index 268b2dcc..27a062ad 100644 --- a/frontend/components/report/designer/TemplatePalette.tsx +++ b/frontend/components/report/designer/TemplatePalette.tsx @@ -3,6 +3,16 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Trash2, Loader2, RefreshCw } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; @@ -19,6 +29,7 @@ export function TemplatePalette() { const [customTemplates, setCustomTemplates] = useState([]); const [isLoading, setIsLoading] = useState(false); const [deletingId, setDeletingId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); const { toast } = useToast(); const fetchTemplates = async () => { @@ -49,14 +60,18 @@ export function TemplatePalette() { await applyTemplate(templateId); }; - const handleDeleteTemplate = async (templateId: string, templateName: string) => { - if (!confirm(`"${templateName}" 템플릿을 삭제하시겠습니까?`)) { - return; - } + const handleDeleteClick = (templateId: string, templateName: string) => { + setDeleteTarget({ id: templateId, name: templateName }); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + + setDeletingId(deleteTarget.id); + setDeleteTarget(null); - setDeletingId(templateId); try { - const response = await reportApi.deleteTemplate(templateId); + const response = await reportApi.deleteTemplate(deleteTarget.id); if (response.success) { toast({ title: "성공", @@ -108,7 +123,7 @@ export function TemplatePalette() { size="sm" onClick={(e) => { e.stopPropagation(); - handleDeleteTemplate(template.template_id, template.template_name_kor); + handleDeleteClick(template.template_id, template.template_name_kor); }} disabled={deletingId === template.template_id} className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0 opacity-0 transition-opacity group-hover:opacity-100" @@ -123,6 +138,29 @@ export function TemplatePalette() { )) )}
+ + {/* 삭제 확인 모달 */} + !open && setDeleteTarget(null)}> + + + 템플릿 삭제 + + "{deleteTarget?.name}" 템플릿을 삭제하시겠습니까? +
+ 삭제된 템플릿은 복구할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
); } From e1567d3f778f84b0f0b8027f0cba09afa5614b4f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 13:56:15 +0900 Subject: [PATCH 07/15] =?UTF-8?q?=EC=9B=8C=EB=93=9C=20export=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=B0=8F=20=EB=B0=94=EC=BD=94?= =?UTF-8?q?=EB=93=9C/=EC=84=9C=EB=AA=85=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 32 +++++++++++++++---- .../report/designer/ReportPreviewModal.tsx | 19 ++++++++--- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index c6605d3e..d334e46e 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -30,6 +30,7 @@ import { Header, Footer, HeadingLevel, + TableLayoutType, } from "docx"; import { WatermarkConfig } from "../types/report"; import bwipjs from "bwip-js"; @@ -592,8 +593,12 @@ export class ReportController { // mm를 twip으로 변환 const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); - // px를 twip으로 변환 (1px = 15twip at 96DPI) - const pxToTwip = (px: number) => Math.round(px * 15); + + // 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값) + const MM_TO_PX = 4; + // 1mm = 56.692913386 twip (docx 라이브러리 기준) + // px를 twip으로 변환: px -> mm -> twip + const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386); // 쿼리 결과 맵 const queryResultsMap: Record< @@ -726,6 +731,9 @@ export class ReportController { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); + // 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정 + const sigImageHeight = 30; // 고정 높이 (약 40px) + const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80; result.push( new ParagraphRef({ children: [ @@ -733,8 +741,8 @@ export class ReportController { new ImageRunRef({ data: imageBuffer, transformation: { - width: Math.round(component.width * 0.75), - height: Math.round(component.height * 0.75), + width: sigImageWidth, + height: sigImageHeight, }, type: "png", }), @@ -1443,7 +1451,11 @@ export class ReportController { try { const barcodeType = component.barcodeType || "CODE128"; const barcodeColor = (component.barcodeColor || "#000000").replace("#", ""); - const barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", ""); + // transparent는 bwip-js에서 지원하지 않으므로 흰색으로 변환 + let barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", ""); + if (barcodeBackground === "transparent" || barcodeBackground === "") { + barcodeBackground = "ffffff"; + } // 바코드 값 결정 (쿼리 바인딩 또는 고정값) let barcodeValue = component.barcodeValue || "SAMPLE123"; @@ -1739,6 +1751,7 @@ export class ReportController { const rowTable = new Table({ rows: [new TableRow({ children: cells })], width: { size: 100, type: WidthType.PERCENTAGE }, + layout: TableLayoutType.FIXED, // 셀 너비 고정 borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, @@ -1821,6 +1834,7 @@ export class ReportController { const textTable = new Table({ rows: [new TableRow({ children: [textCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, + layout: TableLayoutType.FIXED, // 셀 너비 고정 indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, @@ -1970,6 +1984,10 @@ export class ReportController { component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); + // 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정 + const sigImageHeight = 30; // 고정 높이 + const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80; + const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, @@ -1978,8 +1996,8 @@ export class ReportController { new ImageRun({ data: imageBuffer, transformation: { - width: Math.round(component.width * 0.75), - height: Math.round(component.height * 0.75), + width: sigImageWidth, + height: sigImageHeight, }, type: "png", }), diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index b8fcb9ce..0ba67bae 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -1052,7 +1052,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) description: "WORD 파일을 생성하고 있습니다...", }); - // 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함 + // 이미지 및 바코드를 Base64로 변환하여 컴포넌트 데이터에 포함 const pagesWithBase64 = await Promise.all( layoutConfig.pages.map(async (page) => { const componentsWithBase64 = await Promise.all( @@ -1066,12 +1066,21 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) return component; } } + // 바코드/QR코드 컴포넌트는 이미지로 변환 + if (component.type === "barcode") { + try { + const barcodeImage = await generateBarcodeImage(component); + return { ...component, barcodeImageBase64: barcodeImage }; + } catch { + return component; + } + } return component; - }), - ); + }) + ); return { ...page, components: componentsWithBase64 }; - }), - ); + }) + ); // 쿼리 결과 수집 const queryResults: Record[] }> = {}; From 050a183c9606eb7432f8b86657c0b8ca261f8b7d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 14:34:49 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat(report):=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8-=EB=A9=94=EB=89=B4=20=EC=97=B0=EA=B2=B0=20=EA=B8=B0?= =?UTF-8?q?=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 --- backend-node/src/services/reportService.ts | 50 +++ backend-node/src/types/report.ts | 14 +- .../report/designer/MenuSelectModal.tsx | 320 ++++++++++++++++++ .../report/designer/ReportDesignerToolbar.tsx | 30 +- frontend/contexts/ReportDesignerContext.tsx | 81 ++++- frontend/types/report.ts | 2 + 6 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 frontend/components/report/designer/MenuSelectModal.tsx diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index f4991863..6e2df6b2 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -234,10 +234,23 @@ export class ReportService { `; const queries = await query(queriesQuery, [reportId]); + // 메뉴 매핑 조회 + const menuMappingQuery = ` + SELECT menu_objid + FROM report_menu_mapping + WHERE report_id = $1 + ORDER BY created_at + `; + const menuMappings = await query<{ menu_objid: number }>(menuMappingQuery, [ + reportId, + ]); + const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || []; + return { report, layout, queries: queries || [], + menuObjids, }; } @@ -696,6 +709,43 @@ export class ReportService { } } + // 3. 메뉴 매핑 저장 (있는 경우) + if (data.menuObjids !== undefined) { + // 기존 메뉴 매핑 모두 삭제 + await client.query( + `DELETE FROM report_menu_mapping WHERE report_id = $1`, + [reportId] + ); + + // 새 메뉴 매핑 삽입 + if (data.menuObjids.length > 0) { + // 리포트의 company_code 조회 + const reportResult = await client.query( + `SELECT company_code FROM report_master WHERE report_id = $1`, + [reportId] + ); + const companyCode = reportResult.rows[0]?.company_code || "*"; + + const insertMappingSql = ` + INSERT INTO report_menu_mapping ( + report_id, + menu_objid, + company_code, + created_by + ) VALUES ($1, $2, $3, $4) + `; + + for (const menuObjid of data.menuObjids) { + await client.query(insertMappingSql, [ + reportId, + menuObjid, + companyCode, + userId, + ]); + } + } + } + return true; }); } diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index 27254b0d..fc79df32 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -71,11 +71,12 @@ export interface ReportQuery { updated_by: string | null; } -// 리포트 상세 (마스터 + 레이아웃 + 쿼리) +// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴) export interface ReportDetail { report: ReportMaster; layout: ReportLayout | null; queries: ReportQuery[]; + menuObjids?: number[]; // 연결된 메뉴 ID 목록 } // 리포트 목록 조회 파라미터 @@ -166,6 +167,17 @@ export interface SaveLayoutRequest { parameters: string[]; externalConnectionId?: number; }>; + menuObjids?: number[]; // 연결할 메뉴 ID 목록 +} + +// 리포트-메뉴 매핑 +export interface ReportMenuMapping { + mapping_id: number; + report_id: string; + menu_objid: number; + company_code: string; + created_at: Date; + created_by: string | null; } // 템플릿 목록 응답 diff --git a/frontend/components/report/designer/MenuSelectModal.tsx b/frontend/components/report/designer/MenuSelectModal.tsx new file mode 100644 index 00000000..32455191 --- /dev/null +++ b/frontend/components/report/designer/MenuSelectModal.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Loader2, Search, ChevronRight, ChevronDown, FolderOpen, FileText } from "lucide-react"; +import { menuApi } from "@/lib/api/menu"; +import { MenuItem } from "@/types/menu"; +import { cn } from "@/lib/utils"; + +interface MenuSelectModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (menuObjids: number[]) => void; + selectedMenuObjids?: number[]; +} + +// 트리 구조의 메뉴 노드 +interface MenuTreeNode { + objid: string; + menuNameKor: string; + menuUrl: string; + level: number; + children: MenuTreeNode[]; + parentObjId: string; +} + +export function MenuSelectModal({ + isOpen, + onClose, + onConfirm, + selectedMenuObjids = [], +}: MenuSelectModalProps) { + const [menus, setMenus] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [searchText, setSearchText] = useState(""); + const [selectedIds, setSelectedIds] = useState>(new Set(selectedMenuObjids)); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + // 초기 선택 상태 동기화 + useEffect(() => { + if (isOpen) { + setSelectedIds(new Set(selectedMenuObjids)); + } + }, [isOpen, selectedMenuObjids]); + + // 메뉴 목록 로드 + useEffect(() => { + if (isOpen) { + fetchMenus(); + } + }, [isOpen]); + + const fetchMenus = async () => { + setIsLoading(true); + try { + const response = await menuApi.getUserMenus(); + if (response.success && response.data) { + setMenus(response.data); + // 처음 2레벨까지 자동 확장 + const initialExpanded = new Set(); + response.data.forEach((menu) => { + const level = menu.lev || menu.LEV || 1; + if (level <= 2) { + initialExpanded.add(menu.objid || menu.OBJID || ""); + } + }); + setExpandedIds(initialExpanded); + } + } catch (error) { + console.error("메뉴 로드 오류:", error); + } finally { + setIsLoading(false); + } + }; + + // 메뉴 트리 구조 생성 + const menuTree = useMemo(() => { + const menuMap = new Map(); + const rootMenus: MenuTreeNode[] = []; + + // 모든 메뉴를 노드로 변환 + menus.forEach((menu) => { + const objid = menu.objid || menu.OBJID || ""; + const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || ""; + const menuNameKor = menu.menuNameKor || menu.MENU_NAME_KOR || menu.translated_name || menu.TRANSLATED_NAME || ""; + const menuUrl = menu.menuUrl || menu.MENU_URL || ""; + const level = menu.lev || menu.LEV || 1; + + menuMap.set(objid, { + objid, + menuNameKor, + menuUrl, + level, + children: [], + parentObjId, + }); + }); + + // 부모-자식 관계 설정 + menus.forEach((menu) => { + const objid = menu.objid || menu.OBJID || ""; + const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || ""; + const node = menuMap.get(objid); + + if (!node) return; + + // 최상위 메뉴인지 확인 (parent가 없거나, 특정 루트 ID) + const parent = menuMap.get(parentObjId); + if (parent) { + parent.children.push(node); + } else { + rootMenus.push(node); + } + }); + + // 자식 메뉴 정렬 + const sortChildren = (nodes: MenuTreeNode[]) => { + nodes.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko")); + nodes.forEach((node) => sortChildren(node.children)); + }; + sortChildren(rootMenus); + + return rootMenus; + }, [menus]); + + // 검색 필터링 + const filteredTree = useMemo(() => { + if (!searchText.trim()) return menuTree; + + const searchLower = searchText.toLowerCase(); + + // 검색어에 맞는 노드와 그 조상 노드를 포함 + const filterNodes = (nodes: MenuTreeNode[]): MenuTreeNode[] => { + return nodes + .map((node) => { + const filteredChildren = filterNodes(node.children); + const matches = node.menuNameKor.toLowerCase().includes(searchLower); + + if (matches || filteredChildren.length > 0) { + return { + ...node, + children: filteredChildren, + }; + } + return null; + }) + .filter((node): node is MenuTreeNode => node !== null); + }; + + return filterNodes(menuTree); + }, [menuTree, searchText]); + + // 체크박스 토글 + const toggleSelect = useCallback((objid: string) => { + const numericId = Number(objid); + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(numericId)) { + next.delete(numericId); + } else { + next.add(numericId); + } + return next; + }); + }, []); + + // 확장/축소 토글 + const toggleExpand = useCallback((objid: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(objid)) { + next.delete(objid); + } else { + next.add(objid); + } + return next; + }); + }, []); + + // 확인 버튼 클릭 + const handleConfirm = () => { + onConfirm(Array.from(selectedIds)); + onClose(); + }; + + // 메뉴 노드 렌더링 + const renderMenuNode = (node: MenuTreeNode, depth: number = 0) => { + const hasChildren = node.children.length > 0; + const isExpanded = expandedIds.has(node.objid); + const isSelected = selectedIds.has(Number(node.objid)); + + return ( +
+
toggleSelect(node.objid)} + > + {/* 확장/축소 버튼 */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* 체크박스 - 모든 메뉴에서 선택 가능 */} + toggleSelect(node.objid)} + onClick={(e) => e.stopPropagation()} + /> + + {/* 아이콘 */} + {hasChildren ? ( + + ) : ( + + )} + + {/* 메뉴명 */} + + {node.menuNameKor} + +
+ + {/* 자식 메뉴 */} + {hasChildren && isExpanded && ( +
{node.children.map((child) => renderMenuNode(child, depth + 1))}
+ )} +
+ ); + }; + + return ( + + + + 사용 메뉴 선택 + + 이 리포트를 사용할 메뉴를 선택하세요. 선택한 메뉴에서 이 리포트를 사용할 수 있습니다. + + + + {/* 검색 */} +
+ + setSearchText(e.target.value)} + className="pl-10" + /> +
+ + {/* 선택된 메뉴 수 */} +
+ {selectedIds.size}개 메뉴 선택됨 +
+ + {/* 메뉴 트리 */} + + {isLoading ? ( +
+ + 메뉴 로드 중... +
+ ) : filteredTree.length === 0 ? ( +
+ {searchText ? "검색 결과가 없습니다." : "표시할 메뉴가 없습니다."} +
+ ) : ( +
{filteredTree.map((node) => renderMenuNode(node))}
+ )} +
+ + + + + +
+
+ ); +} + diff --git a/frontend/components/report/designer/ReportDesignerToolbar.tsx b/frontend/components/report/designer/ReportDesignerToolbar.tsx index 484dae80..2b0ef7b0 100644 --- a/frontend/components/report/designer/ReportDesignerToolbar.tsx +++ b/frontend/components/report/designer/ReportDesignerToolbar.tsx @@ -54,6 +54,7 @@ import { } from "@/components/ui/alert-dialog"; import { SaveAsTemplateModal } from "./SaveAsTemplateModal"; +import { MenuSelectModal } from "./MenuSelectModal"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import { ReportPreviewModal } from "./ReportPreviewModal"; @@ -62,7 +63,7 @@ export function ReportDesignerToolbar() { const router = useRouter(); const { reportDetail, - saveLayout, + saveLayoutWithMenus, isSaving, loadLayout, components, @@ -100,11 +101,14 @@ export function ReportDesignerToolbar() { setShowRuler, groupComponents, ungroupComponents, + menuObjids, } = useReportDesigner(); const [showPreview, setShowPreview] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showBackConfirm, setShowBackConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showMenuSelect, setShowMenuSelect] = useState(false); + const [pendingSaveAndClose, setPendingSaveAndClose] = useState(false); const { toast } = useToast(); // 버튼 활성화 조건 @@ -123,13 +127,21 @@ export function ReportDesignerToolbar() { setShowGrid(newValue); }; - const handleSave = async () => { - await saveLayout(); + const handleSave = () => { + setPendingSaveAndClose(false); + setShowMenuSelect(true); }; - const handleSaveAndClose = async () => { - await saveLayout(); - router.push("/admin/report"); + const handleSaveAndClose = () => { + setPendingSaveAndClose(true); + setShowMenuSelect(true); + }; + + const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => { + await saveLayoutWithMenus(selectedMenuObjids); + if (pendingSaveAndClose) { + router.push("/admin/report"); + } }; const handleResetConfirm = async () => { @@ -501,6 +513,12 @@ export function ReportDesignerToolbar() { onClose={() => setShowSaveAsTemplate(false)} onSave={handleSaveAsTemplate} /> + setShowMenuSelect(false)} + onConfirm={handleMenuSelectConfirm} + selectedMenuObjids={menuObjids} + /> {/* 목록으로 돌아가기 확인 모달 */} diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index f8764d15..42fb9504 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -138,6 +138,11 @@ interface ReportDesignerContextType { // 그룹화 groupComponents: () => void; ungroupComponents: () => void; + + // 메뉴 연결 + menuObjids: number[]; + setMenuObjids: (menuObjids: number[]) => void; + saveLayoutWithMenus: (menuObjids: number[]) => Promise; } const ReportDesignerContext = createContext(undefined); @@ -158,6 +163,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin const [selectedComponentIds, setSelectedComponentIds] = useState([]); // 다중 선택 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [menuObjids, setMenuObjids] = useState([]); // 연결된 메뉴 ID 목록 const { toast } = useToast(); // 현재 페이지 계산 @@ -1043,6 +1049,13 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin })); setQueries(loadedQueries); } + + // 연결된 메뉴 로드 + if (detailResponse.data.menuObjids && detailResponse.data.menuObjids.length > 0) { + setMenuObjids(detailResponse.data.menuObjids); + } else { + setMenuObjids([]); + } } // 레이아웃 조회 @@ -1331,6 +1344,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin ...q, externalConnectionId: q.externalConnectionId || undefined, })), + menuObjids, // 연결된 메뉴 목록 }); toast({ @@ -1352,7 +1366,68 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin } finally { setIsSaving(false); } - }, [reportId, layoutConfig, queries, toast, loadLayout]); + }, [reportId, layoutConfig, queries, menuObjids, toast, loadLayout]); + + // 메뉴를 선택하고 저장하는 함수 + const saveLayoutWithMenus = useCallback( + async (selectedMenuObjids: number[]) => { + // 먼저 메뉴 상태 업데이트 + setMenuObjids(selectedMenuObjids); + + setIsSaving(true); + try { + let actualReportId = reportId; + + // 새 리포트인 경우 먼저 리포트 생성 + if (reportId === "new") { + const createResponse = await reportApi.createReport({ + reportNameKor: "새 리포트", + reportType: "BASIC", + description: "새로 생성된 리포트입니다.", + }); + + if (!createResponse.success || !createResponse.data) { + throw new Error("리포트 생성에 실패했습니다."); + } + + actualReportId = createResponse.data.reportId; + + // URL 업데이트 (페이지 리로드 없이) + window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`); + } + + // 레이아웃 저장 (선택된 메뉴와 함께) + await reportApi.saveLayout(actualReportId, { + layoutConfig, + queries: queries.map((q) => ({ + ...q, + externalConnectionId: q.externalConnectionId || undefined, + })), + menuObjids: selectedMenuObjids, + }); + + toast({ + title: "성공", + description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다.", + }); + + // 새 리포트였다면 데이터 다시 로드 + if (reportId === "new") { + await loadLayout(); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "저장에 실패했습니다."; + toast({ + title: "오류", + description: errorMessage, + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }, + [reportId, layoutConfig, queries, toast, loadLayout], + ); // 템플릿 적용 const applyTemplate = useCallback( @@ -1553,6 +1628,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin // 그룹화 groupComponents, ungroupComponents, + // 메뉴 연결 + menuObjids, + setMenuObjids, + saveLayoutWithMenus, }; return {children}; diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 3631f831..4241035f 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -237,6 +237,7 @@ export interface ReportDetail { report: ReportMaster; layout: ReportLayout | null; queries: ReportQuery[]; + menuObjids?: number[]; // 연결된 메뉴 ID 목록 } // 리포트 목록 응답 @@ -288,6 +289,7 @@ export interface SaveLayoutRequest { parameters: string[]; externalConnectionId?: number; }>; + menuObjids?: number[]; // 연결할 메뉴 ID 목록 // 하위 호환성 (deprecated) canvasWidth?: number; From 83f171189bb21eb130434fd5954e572676372264 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 15:12:21 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9C=84=EC=B9=98/=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EB=B9=84=EC=9C=A8=20=EC=9E=90=EB=8F=99=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/contexts/ReportDesignerContext.tsx | 74 +++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index 42fb9504..3db07bc9 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -147,6 +147,40 @@ interface ReportDesignerContextType { const ReportDesignerContext = createContext(undefined); +// 페이지 사이즈 변경 시 컴포넌트 위치 및 크기 재계산 유틸리티 함수 +const recalculateComponentPositions = ( + components: ComponentConfig[], + oldWidth: number, + oldHeight: number, + newWidth: number, + newHeight: number +): ComponentConfig[] => { + // 사이즈가 동일하면 그대로 반환 + if (oldWidth === newWidth && oldHeight === newHeight) { + return components; + } + + const widthRatio = newWidth / oldWidth; + const heightRatio = newHeight / oldHeight; + + return components.map((comp) => { + // 위치와 크기 모두 비율대로 재계산 + // 소수점 2자리까지만 유지 + const newX = Math.round(comp.x * widthRatio * 100) / 100; + const newY = Math.round(comp.y * heightRatio * 100) / 100; + const newCompWidth = Math.round(comp.width * widthRatio * 100) / 100; + const newCompHeight = Math.round(comp.height * heightRatio * 100) / 100; + + return { + ...comp, + x: newX, + y: newY, + width: newCompWidth, + height: newCompHeight, + }; + }); +}; + export function ReportDesignerProvider({ reportId, children }: { reportId: string; children: ReactNode }) { const [reportDetail, setReportDetail] = useState(null); const [layout, setLayout] = useState(null); @@ -994,10 +1028,42 @@ 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)), - })); + setLayoutConfig((prev) => { + const targetPage = prev.pages.find((p) => p.page_id === pageId); + if (!targetPage) { + return prev; + } + + // 페이지 사이즈 변경 감지 + const isWidthChanging = settings.width !== undefined && settings.width !== targetPage.width; + const isHeightChanging = settings.height !== undefined && settings.height !== targetPage.height; + + // 사이즈 변경 시 컴포넌트 위치 재계산 + let updatedComponents = targetPage.components; + if (isWidthChanging || isHeightChanging) { + const oldWidth = targetPage.width; + const oldHeight = targetPage.height; + const newWidth = settings.width ?? targetPage.width; + const newHeight = settings.height ?? targetPage.height; + + updatedComponents = recalculateComponentPositions( + targetPage.components, + oldWidth, + oldHeight, + newWidth, + newHeight + ); + } + + return { + ...prev, + pages: prev.pages.map((page) => + page.page_id === pageId + ? { ...page, ...settings, components: updatedComponents } + : page + ), + }; + }); }, []); // 전체 페이지 공유 워터마크 업데이트 From 82a7ff62ee2858dad6111bb52050b232786211b8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 16:00:25 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EB=B0=91=EC=A4=84?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=99=84=EC=A0=84=ED=9E=88=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 9 --------- .../report/designer/ReportDesignerCanvas.tsx | 1 - .../designer/ReportDesignerRightPanel.tsx | 20 ------------------- .../report/designer/ReportPreviewModal.tsx | 13 ------------ frontend/types/report.ts | 1 - 5 files changed, 44 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 238b79a9..554c7065 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -606,7 +606,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) { const sigLabelPos = component.labelPosition || "left"; const sigShowLabel = component.showLabel !== false; const sigLabelText = component.labelText || "서명:"; - const sigShowUnderline = component.showUnderline !== false; return (
@@ -653,14 +652,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) { 서명 이미지
)} - {sigShowUnderline && ( -
- )}
diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index 85dc89b8..6684047b 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -319,7 +319,6 @@ export function ReportDesignerCanvas() { showLabel: true, labelText: "서명:", labelPosition: "left" as const, - showUnderline: true, borderWidth: 0, borderColor: "#cccccc", }), diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index bf401680..7fcfed4e 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -947,26 +947,6 @@ export function ReportDesignerRightPanel() { )} - {/* 밑줄 표시 (서명란만) */} - {selectedComponent.type === "signature" && ( -
- - updateComponent(selectedComponent.id, { - showUnderline: e.target.checked, - }) - } - className="h-4 w-4" - /> - -
- )} - {/* 이름 입력 (도장란만) */} {selectedComponent.type === "stamp" && (
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 0ba67bae..bf0603b7 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -624,7 +624,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ${showLabel ? `
${labelText}
` : ""}
${imageUrl ? `` : ""} - ${component.showUnderline ? '
' : ""}
`; } else { @@ -633,7 +632,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ${showLabel && labelPosition === "top" ? `
${labelText}
` : ""}
${imageUrl ? `` : ""} - ${component.showUnderline ? '
' : ""}
${showLabel && labelPosition === "bottom" ? `
${labelText}
` : ""} `; @@ -1386,17 +1384,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }} /> )} - {component.showUnderline !== false && ( -
- )}
)} diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 4241035f..bd0ff896 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -162,7 +162,6 @@ export interface ComponentConfig { showLabel?: boolean; // 레이블 표시 여부 ("서명:", "(인)") labelText?: string; // 커스텀 레이블 텍스트 labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치 - showUnderline?: boolean; // 서명란 밑줄 표시 여부 personName?: string; // 도장란 이름 (예: "홍길동") // 테이블 전용 tableColumns?: Array<{ From 542c0bae94e3b70c3f1c1f74e1740a5969d0c8f9 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 23 Dec 2025 17:06:21 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EC=9B=90=EB=B3=B8?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B0=B8=EC=A1=B0=20=EC=98=A4?= =?UTF-8?q?=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 | 59 ++++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index ac9768a1..6992baec 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -483,7 +483,8 @@ export class MenuCopyService { properties: any, screenIdMap: Map, flowIdMap: Map, - numberingRuleIdMap?: Map + numberingRuleIdMap?: Map, + menuIdMap?: Map ): any { if (!properties) return properties; @@ -496,7 +497,8 @@ export class MenuCopyService { screenIdMap, flowIdMap, "", - numberingRuleIdMap + numberingRuleIdMap, + menuIdMap ); return updated; @@ -510,7 +512,8 @@ export class MenuCopyService { screenIdMap: Map, flowIdMap: Map, path: string = "", - numberingRuleIdMap?: Map + numberingRuleIdMap?: Map, + menuIdMap?: Map ): void { if (!obj || typeof obj !== "object") return; @@ -522,7 +525,8 @@ export class MenuCopyService { screenIdMap, flowIdMap, `${path}[${index}]`, - numberingRuleIdMap + numberingRuleIdMap, + menuIdMap ); }); return; @@ -533,13 +537,16 @@ export class MenuCopyService { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; - // screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열) + // screen_id, screenId, targetScreenId, leftScreenId, rightScreenId, addModalScreenId, editModalScreenId, modalScreenId 매핑 (숫자 또는 숫자 문자열) if ( key === "screen_id" || key === "screenId" || key === "targetScreenId" || key === "leftScreenId" || - key === "rightScreenId" + key === "rightScreenId" || + key === "addModalScreenId" || + key === "editModalScreenId" || + key === "modalScreenId" ) { const numValue = typeof value === "number" ? value : parseInt(value); if (!isNaN(numValue) && numValue > 0) { @@ -549,6 +556,11 @@ export class MenuCopyService { logger.info( ` 🔗 화면 참조 업데이트 (${currentPath}): ${value} → ${newId}` ); + } else { + // 매핑이 없으면 경고 로그 (복사되지 않은 화면 참조) + logger.warn( + ` ⚠️ 화면 매핑 없음 (${currentPath}): ${value} - 원본 화면이 복사되지 않았을 수 있음` + ); } } } @@ -573,9 +585,9 @@ export class MenuCopyService { } } - // numberingRuleId 매핑 (문자열) + // numberingRuleId, ruleId 매핑 (문자열) - 채번규칙 참조 if ( - key === "numberingRuleId" && + (key === "numberingRuleId" || key === "ruleId") && numberingRuleIdMap && typeof value === "string" && value @@ -595,6 +607,25 @@ export class MenuCopyService { } } + // selectedMenuObjid 매핑 (메뉴 objid 참조) + if (key === "selectedMenuObjid" && menuIdMap) { + const numValue = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numValue) && numValue > 0) { + const newId = menuIdMap.get(numValue); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); + logger.info( + ` 🔗 메뉴 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } else { + // 매핑이 없으면 경고 로그 (복사되지 않은 메뉴 참조) + logger.warn( + ` ⚠️ 메뉴 매핑 없음 (${currentPath}): ${value} - 원본 메뉴가 복사되지 않았을 수 있음` + ); + } + } + } + // 재귀 호출 if (typeof value === "object" && value !== null) { this.recursiveUpdateReferences( @@ -602,7 +633,8 @@ export class MenuCopyService { screenIdMap, flowIdMap, currentPath, - numberingRuleIdMap + numberingRuleIdMap, + menuIdMap ); } } @@ -981,7 +1013,8 @@ export class MenuCopyService { userId, client, screenNameConfig, - numberingRuleIdMap + numberingRuleIdMap, + menuIdMap ); // === 6단계: 화면-메뉴 할당 === @@ -1315,7 +1348,8 @@ export class MenuCopyService { removeText?: string; addPrefix?: string; }, - numberingRuleIdMap?: Map + numberingRuleIdMap?: Map, + menuIdMap?: Map ): Promise> { const screenIdMap = new Map(); @@ -1601,7 +1635,8 @@ export class MenuCopyService { layout.properties, screenIdMap, flowIdMap, - numberingRuleIdMap + numberingRuleIdMap, + menuIdMap ); layoutValues.push( From 755bbc0c58e734d53e8e2f70c95208e9b775c122 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 23 Dec 2025 17:32:27 +0900 Subject: [PATCH 12/15] =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EC=A7=84=EC=A7=9C?= =?UTF-8?q?=EC=A7=84=EC=A7=9C=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, 79 insertions(+) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 6992baec..075a8229 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -279,11 +279,90 @@ export class MenuCopyService { logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); } } + + // 5) 모달 화면 ID (addModalScreenId, editModalScreenId, modalScreenId) + if (props?.componentConfig?.addModalScreenId) { + const addModalScreenId = props.componentConfig.addModalScreenId; + const numId = + typeof addModalScreenId === "number" + ? addModalScreenId + : parseInt(addModalScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📋 추가 모달 화면 참조 발견: ${numId}`); + } + } + + if (props?.componentConfig?.editModalScreenId) { + const editModalScreenId = props.componentConfig.editModalScreenId; + const numId = + typeof editModalScreenId === "number" + ? editModalScreenId + : parseInt(editModalScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📝 수정 모달 화면 참조 발견: ${numId}`); + } + } + + if (props?.componentConfig?.modalScreenId) { + const modalScreenId = props.componentConfig.modalScreenId; + const numId = + typeof modalScreenId === "number" + ? modalScreenId + : parseInt(modalScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 🔲 모달 화면 참조 발견: ${numId}`); + } + } + + // 6) 재귀적으로 모든 properties에서 화면 ID 추출 (깊은 탐색) + this.extractScreenIdsFromObject(props, referenced); } return referenced; } + /** + * 객체 내부에서 화면 ID를 재귀적으로 추출 + */ + private extractScreenIdsFromObject(obj: any, referenced: number[]): void { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (const item of obj) { + this.extractScreenIdsFromObject(item, referenced); + } + return; + } + + for (const key of Object.keys(obj)) { + const value = obj[key]; + + // 화면 ID 키 패턴 확인 + if ( + key === "screenId" || + key === "targetScreenId" || + key === "leftScreenId" || + key === "rightScreenId" || + key === "addModalScreenId" || + key === "editModalScreenId" || + key === "modalScreenId" + ) { + const numId = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numId) && numId > 0 && !referenced.includes(numId)) { + referenced.push(numId); + } + } + + // 재귀 탐색 + if (typeof value === "object" && value !== null) { + this.extractScreenIdsFromObject(value, referenced); + } + } + } + /** * 화면 수집 (중복 제거, 재귀적 참조 추적) */ From 859d68fff8125fe7e99538b8db80d81d2e03239e Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 23 Dec 2025 17:37:22 +0900 Subject: [PATCH 13/15] =?UTF-8?q?=EC=9D=B8=EC=87=84=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20-=20=EC=A4=91=EB=B3=B5=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=A0=95=ED=99=95=EB=8F=84=20=ED=96=A5?= =?UTF-8?q?=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/CanvasComponent.tsx | 84 +++++++++---------- .../report/designer/ReportPreviewModal.tsx | 44 ++++++---- 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 554c7065..ccc3aa8a 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -357,11 +357,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) { height: snappedSize, }); } else { - // Grid Snap 적용 - updateComponent(component.id, { - width: snapValueToGrid(boundedWidth), - height: snapValueToGrid(boundedHeight), - }); + // Grid Snap 적용 + updateComponent(component.id, { + width: snapValueToGrid(boundedWidth), + height: snapValueToGrid(boundedHeight), + }); } } }; @@ -444,17 +444,17 @@ export function CanvasComponent({ component }: CanvasComponentProps) { case "text": case "label": return ( -
- {displayValue} + style={{ + fontSize: `${component.fontSize}px`, + color: component.fontColor, + fontWeight: component.fontWeight, + textAlign: component.textAlign as "left" | "center" | "right", + whiteSpace: "pre-wrap", + }} + > + {displayValue}
); @@ -534,7 +534,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // 기본 테이블 (데이터 없을 때) return (
- 쿼리를 연결하세요 + 쿼리를 연결하세요
); @@ -858,12 +858,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) { // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) const calculateResult = (): number => { if (calcItems.length === 0) return 0; - + // 첫 번째 항목은 기준값 let result = getCalcItemValue( calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string }, ); - + // 두 번째 항목부터 연산자 적용 for (let i = 1; i < calcItems.length; i++) { const item = calcItems[i]; @@ -899,30 +899,30 @@ export function CanvasComponent({ component }: CanvasComponentProps) { item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number, ) => { - const itemValue = getCalcItemValue(item); - return ( -
- - {item.label} - - - {formatNumber(itemValue)} - -
- ); + const itemValue = getCalcItemValue(item); + return ( +
+ + {item.label} + + + {formatNumber(itemValue)} + +
+ ); }, )} diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index bf0603b7..7069bb75 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -17,6 +17,9 @@ import { getFullImageUrl } from "@/lib/api/client"; import JsBarcode from "jsbarcode"; import QRCode from "qrcode"; +// mm -> px 변환 상수 +const MM_TO_PX = 4; + interface ReportPreviewModalProps { isOpen: boolean; onClose: () => void; @@ -149,8 +152,8 @@ function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWate // 타일 스타일 if (watermark.style === "tile") { const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; - const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2; - const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2; + const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2; + const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2; return (
@@ -514,7 +517,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) printWindow.document.write(printHtml); printWindow.document.close(); - printWindow.print(); + // print()는 HTML 내 스크립트에서 이미지 로드 완료 후 자동 호출됨 }; // 워터마크 HTML 생성 헬퍼 함수 @@ -554,8 +557,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) if (watermark.style === "tile") { const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; - const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2; - const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2; + const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2; + const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2; const tileItems = Array.from({ length: rows * cols }) .map(() => `
${textContent}
`) .join(""); @@ -650,9 +653,9 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) : ""; content = ` -
- ${personName ? `
${personName}
` : ""} -
+
+ ${personName ? `
${personName}
` : ""} +
${imageUrl ? `` : ""} ${showLabel ? `
${labelText}
` : ""}
@@ -891,8 +894,15 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) `; } + // 컴포넌트 값은 px로 저장됨 (캔버스는 pageWidth * MM_TO_PX px) + // 인쇄용 mm 단위로 변환: px / MM_TO_PX = mm + const xMm = component.x / MM_TO_PX; + const yMm = component.y / MM_TO_PX; + const widthMm = component.width / MM_TO_PX; + const heightMm = component.height / MM_TO_PX; + return ` -
+
${content}
`; }) @@ -901,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight); return ` -
+ `; @@ -933,20 +943,18 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) 리포트 인쇄