From d13cd478de03bb99f72c0a30866606564350f07c Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Feb 2026 09:28:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B0=8F=20=EB=8F=99=EC=A0=81=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A1=B0=EC=A0=95=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 - 파일 미리보기 API에 공개 접근을 허용하여 인증되지 않은 사용자도 이미지 미리보기를 할 수 있도록 수정하였습니다. - ScreenModal 컴포넌트에서 숨겨진 컴포넌트의 동적 y 좌표 조정 로직을 추가하여 사용자 인터페이스의 일관성을 개선하였습니다. - V2Media 및 V2Select 컴포넌트에서 기본값 설정 기능을 추가하여 사용자 경험을 향상시켰습니다. - RepeaterTable 및 SimpleRepeaterTableComponent에서 키 값을 개선하여 렌더링 성능을 최적화하였습니다. - formData의 디버깅 로그를 추가하여 개발 중 상태 확인을 용이하게 하였습니다. --- .../src/controllers/fileController.ts | 5 +- backend-node/src/routes/fileRoutes.ts | 14 +- docs/v2-sales-order-modal-layout.json | 557 ++++++++++++++++++ .../app/(main)/screens/[screenId]/page.tsx | 3 +- frontend/components/common/ScreenModal.tsx | 139 ++++- .../screen/InteractiveScreenViewerDynamic.tsx | 48 +- frontend/components/screen/ScreenDesigner.tsx | 47 +- .../webtype-configs/SelectTypeConfigPanel.tsx | 29 + frontend/components/v2/V2Media.tsx | 452 ++++++++++---- .../v2/config-panels/V2SelectConfigPanel.tsx | 26 + .../lib/registry/DynamicComponentRenderer.tsx | 96 ++- .../modal-repeater-table/RepeaterTable.tsx | 17 +- .../SimpleRepeaterTableComponent.tsx | 17 +- .../ButtonPrimaryComponent.tsx | 18 + .../components/v2-media/V2MediaRenderer.tsx | 29 +- .../components/v2-select/V2SelectRenderer.tsx | 16 +- frontend/lib/utils/buttonActions.ts | 28 + 17 files changed, 1332 insertions(+), 209 deletions(-) create mode 100644 docs/v2-sales-order-modal-layout.json diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index d4e8d0cf..a648a4f9 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -793,8 +793,9 @@ export const previewFile = async ( return; } - // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) - if (companyCode !== "*" && fileRecord.company_code !== companyCode) { + // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 및 공개 접근 제외) + // 공개 접근(req.user가 없는 경우)은 미리보기 허용 (이미지 표시용) + if (companyCode && companyCode !== "*" && fileRecord.company_code !== companyCode) { console.warn("⚠️ 다른 회사 파일 접근 시도:", { userId: req.user?.userId, userCompanyCode: companyCode, diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index 64f02d14..4514e37f 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -24,6 +24,13 @@ const router = Router(); */ router.get("/public/:token", getFileByToken); +/** + * @route GET /api/files/preview/:objid + * @desc 파일 미리보기 (이미지 등) - 공개 접근 허용 + * @access Public + */ +router.get("/preview/:objid", previewFile); + // 모든 파일 API는 인증 필요 router.use(authenticateToken); @@ -64,12 +71,7 @@ router.get("/linked/:tableName/:recordId", getLinkedFiles); */ router.delete("/:objid", deleteFile); -/** - * @route GET /api/files/preview/:objid - * @desc 파일 미리보기 (이미지 등) - * @access Private - */ -router.get("/preview/:objid", previewFile); +// preview 라우트는 상단 공개 접근 구역으로 이동됨 /** * @route GET /api/files/download/:objid diff --git a/docs/v2-sales-order-modal-layout.json b/docs/v2-sales-order-modal-layout.json new file mode 100644 index 00000000..6c8287e0 --- /dev/null +++ b/docs/v2-sales-order-modal-layout.json @@ -0,0 +1,557 @@ +{ + "version": "2.0", + "screenResolution": { + "width": 1400, + "height": 900, + "name": "수주등록 모달", + "category": "modal" + }, + "components": [ + { + "id": "section-options", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 20, "z": 1 }, + "size": { "width": 1360, "height": 80 }, + "overrides": { + "componentConfig": { + "title": "", + "showHeader": false, + "padding": "md", + "borderStyle": "solid" + } + }, + "displayOrder": 0 + }, + { + "id": "select-input-method", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 40, "y": 35, "z": 2 }, + "size": { "width": 300, "height": 40 }, + "overrides": { + "label": "입력 방식", + "columnName": "input_method", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "customer_first", "label": "거래처 우선" }, + { "value": "item_first", "label": "품목 우선" } + ], + "placeholder": "입력 방식 선택" + }, + "displayOrder": 1 + }, + { + "id": "select-sales-type", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 360, "y": 35, "z": 2 }, + "size": { "width": 300, "height": 40 }, + "overrides": { + "label": "판매 유형", + "columnName": "sales_type", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "domestic", "label": "국내 판매" }, + { "value": "overseas", "label": "해외 판매" } + ], + "placeholder": "판매 유형 선택" + }, + "displayOrder": 2 + }, + { + "id": "select-price-method", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 680, "y": 35, "z": 2 }, + "size": { "width": 250, "height": 40 }, + "overrides": { + "label": "단가 방식", + "columnName": "price_method", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "standard", "label": "기준 단가" }, + { "value": "contract", "label": "계약 단가" }, + { "value": "custom", "label": "개별 입력" } + ], + "placeholder": "단가 방식" + }, + "displayOrder": 3 + }, + { + "id": "checkbox-price-edit", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 950, "y": 35, "z": 2 }, + "size": { "width": 150, "height": 40 }, + "overrides": { + "label": "단가 수정 허용", + "columnName": "allow_price_edit", + "mode": "check", + "source": "static", + "options": [{ "value": "Y", "label": "허용" }] + }, + "displayOrder": 4 + }, + + { + "id": "section-customer-info", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 110, "z": 1 }, + "size": { "width": 1360, "height": 120 }, + "overrides": { + "componentConfig": { + "title": "거래처 정보", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 5 + }, + { + "id": "select-customer", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 40, "y": 155, "z": 3 }, + "size": { "width": 320, "height": 40 }, + "overrides": { + "label": "거래처 *", + "columnName": "partner_id", + "mode": "dropdown", + "source": "entity", + "entityTable": "customer_mng", + "entityValueColumn": "customer_code", + "entityLabelColumn": "customer_name", + "searchable": true, + "placeholder": "거래처명 입력하여 검색", + "required": true, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 6 + }, + { + "id": "input-manager", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 380, "y": 155, "z": 3 }, + "size": { "width": 240, "height": 40 }, + "overrides": { + "label": "담당자", + "columnName": "manager_name", + "placeholder": "담당자", + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 7 + }, + { + "id": "input-delivery-partner", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 640, "y": 155, "z": 3 }, + "size": { "width": 240, "height": 40 }, + "overrides": { + "label": "납품처", + "columnName": "delivery_partner_id", + "placeholder": "납품처", + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 8 + }, + { + "id": "input-delivery-address", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 900, "y": 155, "z": 3 }, + "size": { "width": 460, "height": 40 }, + "overrides": { + "label": "납품장소", + "columnName": "delivery_address", + "placeholder": "납품장소", + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 9 + }, + + { + "id": "section-item-first", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 110, "z": 1 }, + "size": { "width": 1360, "height": 200 }, + "overrides": { + "componentConfig": { + "title": "품목 및 거래처별 수주", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "item_first", + "action": "show" + } + }, + "displayOrder": 10 + }, + + { + "id": "section-items", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 240, "z": 1 }, + "size": { "width": 1360, "height": 280 }, + "overrides": { + "componentConfig": { + "title": "추가된 품목", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 11 + }, + { + "id": "btn-item-search", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1140, "y": 245, "z": 5 }, + "size": { "width": 100, "height": 36 }, + "overrides": { + "label": "품목 검색", + "action": { + "type": "openModal", + "modalType": "itemSelection" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 12 + }, + { + "id": "btn-shipping-plan", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1250, "y": 245, "z": 5 }, + "size": { "width": 100, "height": 36 }, + "overrides": { + "label": "출하계획", + "webTypeConfig": { + "variant": "destructive" + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 13 + }, + { + "id": "repeater-items", + "url": "@/lib/registry/components/v2-repeater", + "position": { "x": 40, "y": 290, "z": 3 }, + "size": { "width": 1320, "height": 200 }, + "overrides": { + "renderMode": "modal", + "dataSource": { + "tableName": "sales_order_detail", + "foreignKey": "order_no", + "referenceKey": "order_no" + }, + "columns": [ + { "field": "part_code", "header": "품번", "width": 100 }, + { "field": "part_name", "header": "품명", "width": 150 }, + { "field": "spec", "header": "규격", "width": 100 }, + { "field": "unit", "header": "단위", "width": 80 }, + { "field": "qty", "header": "수량", "width": 100, "editable": true }, + { "field": "unit_price", "header": "단가", "width": 100, "editable": true }, + { "field": "amount", "header": "금액", "width": 100 }, + { "field": "due_date", "header": "납기일", "width": 120, "editable": true } + ], + "modal": { + "sourceTable": "item_info", + "sourceColumns": ["part_code", "part_name", "spec", "material", "unit_price"], + "filterCondition": {} + }, + "features": { + "showAddButton": false, + "showDeleteButton": true, + "inlineEdit": true + }, + "conditionalConfig": { + "enabled": true, + "field": "input_method", + "operator": "=", + "value": "customer_first", + "action": "show" + } + }, + "displayOrder": 14 + }, + + { + "id": "section-trade-info", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 530, "z": 1 }, + "size": { "width": 1360, "height": 150 }, + "overrides": { + "componentConfig": { + "title": "무역 정보", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + }, + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 15 + }, + { + "id": "select-incoterms", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 40, "y": 575, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "인코텀즈", + "columnName": "incoterms", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "FOB", "label": "FOB" }, + { "value": "CIF", "label": "CIF" }, + { "value": "EXW", "label": "EXW" }, + { "value": "DDP", "label": "DDP" } + ], + "placeholder": "선택", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 16 + }, + { + "id": "select-payment-term", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 260, "y": 575, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "결제 조건", + "columnName": "payment_term", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "TT", "label": "T/T" }, + { "value": "LC", "label": "L/C" }, + { "value": "DA", "label": "D/A" }, + { "value": "DP", "label": "D/P" } + ], + "placeholder": "선택", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 17 + }, + { + "id": "select-currency", + "url": "@/lib/registry/components/v2-select", + "position": { "x": 480, "y": 575, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "통화", + "columnName": "currency", + "mode": "dropdown", + "source": "static", + "options": [ + { "value": "KRW", "label": "KRW (원)" }, + { "value": "USD", "label": "USD (달러)" }, + { "value": "EUR", "label": "EUR (유로)" }, + { "value": "JPY", "label": "JPY (엔)" }, + { "value": "CNY", "label": "CNY (위안)" } + ], + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 18 + }, + { + "id": "input-port-loading", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 40, "y": 625, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "선적항", + "columnName": "port_of_loading", + "placeholder": "선적항", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 19 + }, + { + "id": "input-port-discharge", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 260, "y": 625, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "도착항", + "columnName": "port_of_discharge", + "placeholder": "도착항", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 20 + }, + { + "id": "input-hs-code", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 480, "y": 625, "z": 3 }, + "size": { "width": 200, "height": 40 }, + "overrides": { + "label": "HS Code", + "columnName": "hs_code", + "placeholder": "HS Code", + "conditionalConfig": { + "enabled": true, + "field": "sales_type", + "operator": "=", + "value": "overseas", + "action": "show" + } + }, + "displayOrder": 21 + }, + + { + "id": "section-additional", + "url": "@/lib/registry/components/v2-section-card", + "position": { "x": 20, "y": 690, "z": 1 }, + "size": { "width": 1360, "height": 130 }, + "overrides": { + "componentConfig": { + "title": "추가 정보", + "showHeader": true, + "padding": "md", + "borderStyle": "solid" + } + }, + "displayOrder": 22 + }, + { + "id": "input-memo", + "url": "@/lib/registry/components/v2-input", + "position": { "x": 40, "y": 735, "z": 3 }, + "size": { "width": 1320, "height": 70 }, + "overrides": { + "label": "메모", + "columnName": "memo", + "type": "textarea", + "placeholder": "메모를 입력하세요" + }, + "displayOrder": 23 + }, + + { + "id": "btn-cancel", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1180, "y": 840, "z": 5 }, + "size": { "width": 90, "height": 40 }, + "overrides": { + "label": "취소", + "webTypeConfig": { + "variant": "outline" + }, + "action": { + "type": "close" + } + }, + "displayOrder": 24 + }, + { + "id": "btn-save", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1280, "y": 840, "z": 5 }, + "size": { "width": 90, "height": 40 }, + "overrides": { + "label": "저장", + "action": { + "type": "save" + } + }, + "displayOrder": 25 + } + ], + "gridSettings": { + "columns": 12, + "gap": 16, + "padding": 20, + "snapToGrid": true, + "showGrid": false + } +} diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 828d1aca..0ce2bae5 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -238,7 +238,8 @@ function ScreenViewPage() { compType?.includes("select") || compType?.includes("textarea") || compType?.includes("v2-input") || - compType?.includes("v2-select"); + compType?.includes("v2-select") || + compType?.includes("v2-media"); // 🆕 미디어 컴포넌트 추가 const hasColumnName = !!(comp as any).columnName; return isInputType && hasColumnName; }); diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 68fa0cb1..d991e553 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -604,23 +604,135 @@ export const ScreenModal: React.FC = ({ className }) => { transformOrigin: "center center", }} > - {screenData.components.map((component) => { + {(() => { + // 🆕 동적 y 좌표 조정을 위해 먼저 숨겨지는 컴포넌트들 파악 + const isComponentHidden = (comp: any) => { + const cc = comp.componentConfig?.conditionalConfig || comp.conditionalConfig; + if (!cc?.enabled || !formData) return false; + + const { field, operator, value, action } = cc; + const fieldValue = formData[field]; + + let conditionMet = false; + switch (operator) { + case "=": + case "==": + case "===": + conditionMet = fieldValue === value; + break; + case "!=": + case "!==": + conditionMet = fieldValue !== value; + break; + default: + conditionMet = fieldValue === value; + } + + return (action === "show" && !conditionMet) || (action === "hide" && conditionMet); + }; + + // 표시되는 컴포넌트들의 y 범위 수집 + const visibleRanges: { y: number; bottom: number }[] = []; + screenData.components.forEach((comp: any) => { + if (!isComponentHidden(comp)) { + const y = parseFloat(comp.position?.y?.toString() || "0"); + const height = parseFloat(comp.size?.height?.toString() || "0"); + visibleRanges.push({ y, bottom: y + height }); + } + }); + + // 숨겨지는 컴포넌트의 "실제 빈 공간" 계산 (표시되는 컴포넌트와 겹치지 않는 영역) + const getActualGap = (hiddenY: number, hiddenBottom: number): number => { + // 숨겨지는 영역 중 표시되는 컴포넌트와 겹치는 부분을 제외 + let gapStart = hiddenY; + let gapEnd = hiddenBottom; + + for (const visible of visibleRanges) { + // 겹치는 영역 확인 + if (visible.y < gapEnd && visible.bottom > gapStart) { + // 겹치는 부분을 제외 + if (visible.y <= gapStart && visible.bottom >= gapEnd) { + // 완전히 덮힘 - 빈 공간 없음 + return 0; + } else if (visible.y <= gapStart) { + // 위쪽이 덮힘 + gapStart = visible.bottom; + } else if (visible.bottom >= gapEnd) { + // 아래쪽이 덮힘 + gapEnd = visible.y; + } + } + } + + return Math.max(0, gapEnd - gapStart); + }; + + // 숨겨지는 컴포넌트들의 실제 빈 공간 수집 + const hiddenGaps: { bottom: number; gap: number }[] = []; + screenData.components.forEach((comp: any) => { + if (isComponentHidden(comp)) { + const y = parseFloat(comp.position?.y?.toString() || "0"); + const height = parseFloat(comp.size?.height?.toString() || "0"); + const bottom = y + height; + const gap = getActualGap(y, bottom); + if (gap > 0) { + hiddenGaps.push({ bottom, gap }); + } + } + }); + + // bottom 기준으로 정렬 및 중복 제거 (같은 bottom은 가장 큰 gap만 유지) + const mergedGaps = new Map(); + hiddenGaps.forEach(({ bottom, gap }) => { + const existing = mergedGaps.get(bottom) || 0; + mergedGaps.set(bottom, Math.max(existing, gap)); + }); + + const sortedGaps = Array.from(mergedGaps.entries()) + .map(([bottom, gap]) => ({ bottom, gap })) + .sort((a, b) => a.bottom - b.bottom); + + console.log('🔍 [Y조정] visibleRanges:', visibleRanges.filter(r => r.bottom - r.y > 50).map(r => `${r.y}~${r.bottom}`)); + console.log('🔍 [Y조정] hiddenGaps:', sortedGaps); + + // 각 컴포넌트의 y 조정값 계산 함수 + const getYOffset = (compY: number, compId?: string) => { + let offset = 0; + for (const { bottom, gap } of sortedGaps) { + // 컴포넌트가 숨겨진 영역 아래에 있으면 그 빈 공간만큼 위로 이동 + if (compY > bottom) { + offset += gap; + } + } + if (offset > 0 && compId) { + console.log(`🔍 [Y조정] ${compId}: y=${compY} → ${compY - offset} (offset=${offset})`); + } + return offset; + }; + + return screenData.components.map((component: any) => { + // 숨겨지는 컴포넌트는 렌더링 안함 + if (isComponentHidden(component)) { + return null; + } + // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 const offsetX = screenDimensions?.offsetX || 0; const offsetY = screenDimensions?.offsetY || 0; + + // 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동) + const compY = parseFloat(component.position?.y?.toString() || "0"); + const yAdjustment = getYOffset(compY, component.id); // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) - const adjustedComponent = - offsetX === 0 && offsetY === 0 - ? component - : { - ...component, - position: { - ...component.position, - x: parseFloat(component.position?.x?.toString() || "0") - offsetX, - y: parseFloat(component.position?.y?.toString() || "0") - offsetY, - }, - }; + const adjustedComponent = { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용 + }, + }; return ( = ({ className }) => { companyCode={user?.companyCode} /> ); - })} + }); + })()} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 735fb53c..8dc5da89 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -335,13 +335,42 @@ export const InteractiveScreenViewerDynamic: React.FC { - // 조건부 표시 평가 + // 조건부 표시 평가 (기존 conditional 시스템) const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents); // 조건에 따라 숨김 처리 if (!conditionalResult.visible) { return null; } + + // 🆕 conditionalConfig 시스템 체크 (V2 레이아웃용) + const conditionalConfig = (comp as any).componentConfig?.conditionalConfig; + if (conditionalConfig?.enabled && formData) { + const { field, operator, value, action } = conditionalConfig; + const fieldValue = formData[field]; + + let conditionMet = false; + switch (operator) { + case "=": + case "==": + case "===": + conditionMet = fieldValue === value; + break; + case "!=": + case "!==": + conditionMet = fieldValue !== value; + break; + default: + conditionMet = fieldValue === value; + } + + if (action === "show" && !conditionMet) { + return null; + } + if (action === "hide" && conditionMet) { + return null; + } + } // 데이터 테이블 컴포넌트 처리 if (isDataTableComponent(comp)) { @@ -533,11 +562,26 @@ export const InteractiveScreenViewerDynamic: React.FC = {}; + + // v2-media 컴포넌트의 columnName 목록 수집 + const mediaColumnNames = new Set( + allComponents + .filter((c: any) => c.componentType === "v2-media" || c.url?.includes("v2-media")) + .map((c: any) => c.columnName || c.componentConfig?.columnName) + .filter(Boolean) + ); + Object.entries(formData).forEach(([key, value]) => { - // 배열 데이터는 리피터 데이터이므로 제외 if (!Array.isArray(value)) { + // 배열이 아닌 값은 그대로 저장 masterFormData[key] = value; + } else if (mediaColumnNames.has(key)) { + // v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응) + // 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용 + masterFormData[key] = value.length > 0 ? value[0] : null; + console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); } else { console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`); } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index c73e6598..192bd16c 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1623,55 +1623,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU }; }, [MIN_ZOOM, MAX_ZOOM]); - // 격자 설정 업데이트 및 컴포넌트 자동 스냅 + // 격자 설정 업데이트 (컴포넌트 자동 조정 제거됨) const updateGridSettings = useCallback( (newGridSettings: GridSettings) => { const newLayout = { ...layout, gridSettings: newGridSettings }; - - // 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정 - if (newGridSettings.snapToGrid && screenResolution.width > 0) { - // 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준) - const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: newGridSettings.columns, - gap: newGridSettings.gap, - padding: newGridSettings.padding, - snapToGrid: newGridSettings.snapToGrid || false, - }); - - const gridUtilSettings = { - columns: newGridSettings.columns, - gap: newGridSettings.gap, - padding: newGridSettings.padding, - snapToGrid: true, // 항상 10px 스냅 활성화 - }; - - const adjustedComponents = layout.components.map((comp) => { - const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings); - const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings); - - // gridColumns가 없거나 범위를 벗어나면 자동 조정 - let adjustedGridColumns = comp.gridColumns; - if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) { - adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings); - } - - return { - ...comp, - position: snappedPosition, - size: snappedSize, - gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정 - }; - }); - - newLayout.components = adjustedComponents; - // console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개"); - // console.log("새로운 격자 정보:", newGridInfo); - } - + // 🆕 격자 설정 변경 시 컴포넌트 크기/위치 자동 조정 로직 제거됨 + // 사용자가 명시적으로 "격자 재조정" 버튼을 클릭해야만 조정됨 setLayout(newLayout); saveToHistory(newLayout); }, - [layout, screenResolution, saveToHistory], + [layout, saveToHistory], ); // 해상도 변경 핸들러 (컴포넌트 크기/위치 유지) diff --git a/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx index f59171d1..e83c17f6 100644 --- a/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx @@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Plus, X } from "lucide-react"; import { SelectTypeConfig } from "@/types/screen"; @@ -22,6 +23,7 @@ export const SelectTypeConfigPanel: React.FC = ({ co placeholder: "", allowClear: false, maxSelections: undefined, + defaultValue: "", ...config, }; @@ -32,6 +34,7 @@ export const SelectTypeConfigPanel: React.FC = ({ co placeholder: safeConfig.placeholder, allowClear: safeConfig.allowClear, maxSelections: safeConfig.maxSelections?.toString() || "", + defaultValue: safeConfig.defaultValue || "", }); const [newOption, setNewOption] = useState({ label: "", value: "" }); @@ -53,6 +56,7 @@ export const SelectTypeConfigPanel: React.FC = ({ co placeholder: safeConfig.placeholder, allowClear: safeConfig.allowClear, maxSelections: safeConfig.maxSelections?.toString() || "", + defaultValue: safeConfig.defaultValue || "", }); setLocalOptions( @@ -68,6 +72,7 @@ export const SelectTypeConfigPanel: React.FC = ({ co safeConfig.placeholder, safeConfig.allowClear, safeConfig.maxSelections, + safeConfig.defaultValue, JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지 ]); @@ -174,6 +179,30 @@ export const SelectTypeConfigPanel: React.FC = ({ co /> + {/* 기본값 설정 */} +
+ + +

화면 로드 시 자동으로 선택될 값

+
+ {/* 다중 선택 */}