From e5abd93600266ad26be242562649da559ce9be8d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 4 Mar 2026 14:40:48 +0900 Subject: [PATCH 01/29] =?UTF-8?q?fix(pop):=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=ED=8A=B8=EB=A6=AC=20=EC=A0=91=EA=B8=B0/=ED=8E=BC?= =?UTF-8?q?=EC=B9=98=EA=B8=B0=20=EC=83=81=ED=83=9C=EB=A5=BC=20sessionStora?= =?UTF-8?q?ge=EB=A1=9C=20=EC=9C=A0=EC=A7=80=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=A7=84=EC=9E=85=ED=96=88?= =?UTF-8?q?=EB=8B=A4=20=EB=8F=8C=EC=95=84=EC=98=AC=20=EB=95=8C=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=8A=B8=EB=A6=AC=EC=99=80=20?= =?UTF-8?q?=EB=AF=B8=EB=B6=84=EB=A5=98=20=ED=9A=8C=EC=82=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=91=EA=B8=B0/=ED=8E=BC=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EA=B0=80=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4.=20expandedGroups,=20expandedCompan?= =?UTF-8?q?yCodes=EB=A5=BC=20sessionStorage=EC=97=90=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EA=B0=99=EC=9D=80=20=ED=83=AD=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=82=B4=EC=97=90=EC=84=9C=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EA=B0=80=20=EC=9C=A0=EC=A7=80=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop/management/PopCategoryTree.tsx | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/frontend/components/pop/management/PopCategoryTree.tsx b/frontend/components/pop/management/PopCategoryTree.tsx index 705d7163..37e4f0ba 100644 --- a/frontend/components/pop/management/PopCategoryTree.tsx +++ b/frontend/components/pop/management/PopCategoryTree.tsx @@ -471,7 +471,15 @@ export function PopCategoryTree({ // 상태 관리 const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); - const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [expandedGroups, setExpandedGroups] = useState>(() => { + if (typeof window === "undefined") return new Set(); + try { + const saved = sessionStorage.getItem("pop-tree-expanded-groups"); + return saved ? new Set(JSON.parse(saved) as number[]) : new Set(); + } catch { + return new Set(); + } + }); const [selectedGroupId, setSelectedGroupId] = useState(null); // 그룹 모달 상태 @@ -500,7 +508,15 @@ export function PopCategoryTree({ const [moveSearchTerm, setMoveSearchTerm] = useState(""); // 미분류 회사코드별 접기/펼치기 - const [expandedCompanyCodes, setExpandedCompanyCodes] = useState>(new Set()); + const [expandedCompanyCodes, setExpandedCompanyCodes] = useState>(() => { + if (typeof window === "undefined") return new Set(); + try { + const saved = sessionStorage.getItem("pop-tree-expanded-companies"); + return saved ? new Set(JSON.parse(saved) as string[]) : new Set(); + } catch { + return new Set(); + } + }); // 화면 맵 생성 (screen_id로 빠르게 조회) const screensMap = useMemo(() => { @@ -544,6 +560,9 @@ export function PopCategoryTree({ } else { next.add(groupId); } + try { + sessionStorage.setItem("pop-tree-expanded-groups", JSON.stringify([...next])); + } catch { /* noop */ } return next; }); }; @@ -1013,6 +1032,9 @@ export function PopCategoryTree({ } else { next.add(code); } + try { + sessionStorage.setItem("pop-tree-expanded-companies", JSON.stringify([...next])); + } catch { /* noop */ } return next; }); }; From a6c0ab566431514bb1e31915bfc8cc27fdc099df Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 4 Mar 2026 19:12:22 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat(pop):=20=EC=9E=85=EA=B3=A0=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88=20=EC=8B=A4=ED=96=89=20+=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20UX=20+=20=EC=85=80=EB=A0=89=ED=8A=B8=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=ED=86=B5=EC=9D=BC=20=EC=9E=85=EA=B3=A0=20?= =?UTF-8?q?=ED=99=95=EC=A0=95(inbound-confirm)=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=20=EC=B1=84=EB=B2=88=20=EA=B7=9C=EC=B9=99=EC=9D=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EB=90=98=EC=96=B4=20=EC=9E=88=EC=96=B4?= =?UTF-8?q?=EB=8F=84=20inbound=5Fnumber=EA=B0=80=20null=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=ED=95=B4=EA=B2=B0=ED=95=9C=EB=8B=A4.=20[=EC=B1=84?= =?UTF-8?q?=EB=B2=88=20=EC=8B=A4=ED=96=89=20(FIX-1)]=20-=20types.ts:=20Sav?= =?UTF-8?q?eMapping=EC=97=90=20autoGenMappings=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(numberingRuleId,=20=20=20targetColumn,=20?= =?UTF-8?q?showResultModal)=20-=20PopFieldComponent:=20collect=5Fdata=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20autoGenMappings=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=ED=95=98=EC=97=AC=20=20=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=B1=84=EB=B2=88=20=EA=B7=9C=EC=B9=99=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=84=EB=8B=AC=20-=20popActionRoutes:=20INSERT?= =?UTF-8?q?=20=EC=A0=84=20numberingRuleService.allocateCode()=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C,=20=20=20=EC=83=9D=EC=84=B1=EB=90=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20generatedCodes=20=EB=B0=B0=EC=97=B4?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=8F=AC=ED=95=A8=20?= =?UTF-8?q?[=EA=B2=B0=EA=B3=BC=20=EB=AA=A8=EB=8B=AC=20UX]=20-=20pop-button?= =?UTF-8?q?:=20showResultModal=20=ED=86=A0=EA=B8=80=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20=EC=B1=84=EB=B2=88=20=EA=B2=B0=EA=B3=BC=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=ED=91=9C=EC=8B=9C=20=EB=B6=84=EA=B8=B0=20-=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EC=9D=B4=20=EC=97=B4=EB=A0=A4=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=8F=99=EC=95=88=20followUpActions(refresh/naviga?= =?UTF-8?q?te)=20=EC=A7=80=EC=97=B0=ED=95=98=EC=97=AC=20=20=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EA=B0=80=20=ED=99=95=EC=9D=B8=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=9D=84=20=EB=88=8C=EB=9F=AC=EC=95=BC=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D=20=EC=95=A1=EC=85=98=20=EC=8B=A4=ED=96=89=20[?= =?UTF-8?q?=EC=85=80=EB=A0=89=ED=8A=B8=20=EB=86=92=EC=9D=B4=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=EC=84=B1]=20-=20SelectTrigger=20hasCustomHeight?= =?UTF-8?q?=EC=97=90=20/\bh-\d/=20=ED=8C=A8=ED=84=B4=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=20=20className=EC=9D=98=20h-9=20=EB=93=B1?= =?UTF-8?q?=EC=9D=B4=20=EA=B8=B0=EB=B3=B8=20data-size=3D"xs"(h-6)=EC=99=80?= =?UTF-8?q?=20=EC=B6=A9=EB=8F=8C=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20[=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95]=20-=20SelectFieldInput:=20Set=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20dedup=EC=9C=BC=EB=A1=9C=20React=20key=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20-=20PopFieldConfig:=20AutoNumb?= =?UTF-8?q?erEditor=20=EC=A0=9C=EA=B1=B0,=20=EC=B1=84=EB=B2=88=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EC=9D=84=20=EC=A0=80=EC=9E=A5=20=ED=83=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B4=80=EB=A6=AC=20-=20PopFieldConfig:=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=B1=84=EB=B2=88=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=ED=86=A0=EA=B8=80=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20PopCardListComponent:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EB=AA=A9=EB=A1=9D=20=EB=AA=A8=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EB=9F=89=20=EC=9E=90=EB=8F=99=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=B0=A9=EC=A7=80=20-=20PopCardListConfig?= =?UTF-8?q?:=20=EC=88=98=EC=8B=9D=20=ED=95=84=EB=93=9C=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EB=85=B8=EC=B6=9C=20+=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=9E=90=EB=8F=99=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/routes/popActionRoutes.ts | 42 ++++- frontend/components/ui/select.tsx | 2 +- .../registry/pop-components/pop-button.tsx | 104 ++++++++--- .../pop-card-list/PopCardListComponent.tsx | 5 +- .../pop-card-list/PopCardListConfig.tsx | 24 ++- .../pop-field/PopFieldComponent.tsx | 30 ++-- .../pop-field/PopFieldConfig.tsx | 170 ++++++------------ frontend/lib/registry/pop-components/types.ts | 5 + 8 files changed, 230 insertions(+), 152 deletions(-) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 24ef3af0..f71c4495 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from "express"; import { getPool } from "../database/db"; import logger from "../utils/logger"; import { authenticateToken } from "../middleware/authMiddleware"; +import { numberingRuleService } from "../services/numberingRuleService"; const router = Router(); @@ -12,9 +13,16 @@ function isSafeIdentifier(name: string): boolean { return SAFE_IDENTIFIER.test(name); } +interface AutoGenMappingInfo { + numberingRuleId: string; + targetColumn: string; + showResultModal?: boolean; +} + interface MappingInfo { targetTable: string; columnMapping: Record; + autoGenMappings?: AutoGenMappingInfo[]; } interface StatusConditionRule { @@ -114,6 +122,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp let processedCount = 0; let insertedCount = 0; + const generatedCodes: Array<{ targetColumn: string; code: string }> = []; if (action === "inbound-confirm") { // 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블) @@ -144,6 +153,37 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + for (const ag of allAutoGen) { + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + if (columns.includes(`"${ag.targetColumn}"`)) continue; + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, + companyCode, + { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, + targetColumn: ag.targetColumn, + generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { + ruleId: ag.numberingRuleId, + error: err.message, + }); + } + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -263,7 +303,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp return res.json({ success: true, message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`, - data: { processedCount, insertedCount }, + data: { processedCount, insertedCount, generatedCodes }, }); } catch (error: any) { await client.query("ROLLBACK"); diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index 64fef9c4..1ebfdfed 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -28,7 +28,7 @@ function SelectTrigger({ size?: "xs" | "sm" | "default"; }) { // className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시 - const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height; + const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || /\bh-\d/.test(className ?? "") || !!style?.height; return ( >([]); + + const handleCloseGeneratedCodesModal = useCallback(() => { + setGeneratedCodesResult([]); + toast.success("입고 확정 완료"); + publish(`__comp_output__${componentId}__action_completed`, { + action: "inbound-confirm", + success: true, + }); + const followUps = config?.followUpActions ?? []; + for (const fa of followUps) { + switch (fa.type) { + case "navigate": + if (fa.targetScreenId) { + publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params }); + } + break; + case "refresh": + publish("__pop_refresh__"); + break; + case "event": + if (fa.eventName) publish(fa.eventName, fa.eventPayload); + break; + } + } + }, [componentId, publish, config?.followUpActions]); // 입고 확정 모드: 선택 항목 수 수신 useEffect(() => { @@ -576,28 +602,32 @@ export function PopButtonComponent({ }); if (result.data?.success) { - toast.success(`${selectedItems.length}건 입고 확정 완료`); - publish(`__comp_output__${componentId}__action_completed`, { - action: "inbound-confirm", - success: true, - count: selectedItems.length, - }); - - // 후속 액션 실행 (navigate, refresh 등) - const followUps = config?.followUpActions ?? []; - for (const fa of followUps) { - switch (fa.type) { - case "navigate": - if (fa.targetScreenId) { - publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params }); - } - break; - case "refresh": - publish("__pop_refresh__"); - break; - case "event": - if (fa.eventName) publish(fa.eventName, fa.eventPayload); - break; + const codes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = result.data.data?.generatedCodes ?? []; + const modalCodes = codes.filter((c) => c.showResultModal); + if (modalCodes.length > 0) { + setGeneratedCodesResult(modalCodes); + } else { + toast.success(`${selectedItems.length}건 입고 확정 완료`); + publish(`__comp_output__${componentId}__action_completed`, { + action: "inbound-confirm", + success: true, + count: selectedItems.length, + }); + const followUps = config?.followUpActions ?? []; + for (const fa of followUps) { + switch (fa.type) { + case "navigate": + if (fa.targetScreenId) { + publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params }); + } + break; + case "refresh": + publish("__pop_refresh__"); + break; + case "event": + if (fa.eventName) publish(fa.eventName, fa.eventPayload); + break; + } } } } else { @@ -811,6 +841,36 @@ export function PopButtonComponent({ + {/* 채번 결과 다이얼로그 - 사용자가 확인 누를 때까지 유지 */} + 0} onOpenChange={(open) => { if (!open) handleCloseGeneratedCodesModal(); }}> + + + + 자동 생성 완료 + + +
+

다음 번호가 자동 생성되었습니다.

+ {generatedCodesResult.map((c, i) => ( +
+ {c.targetColumn} + {c.code} +
+ ))} +
+
+
+ + + 확인 + + +
+
+ {/* 일반 확인 다이얼로그 */} { if (!open) cancelConfirm(); }}> diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index 4ba1abc1..0c599aab 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -998,12 +998,13 @@ function Card({ return 999999; }, [limitCol, row]); - // 제한 컬럼이 있으면 최대값으로 자동 초기화 + // 제한 컬럼이 있으면 최대값으로 자동 초기화 (장바구니 목록 모드에서는 cart 수량 유지) useEffect(() => { + if (isCartListMode) return; if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { setInputValue(effectiveMax); } - }, [effectiveMax, inputField?.enabled, limitCol]); + }, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]); const cardStyle: React.CSSProperties = { height: `${scaled.cardHeight}px`, diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 0e868711..106ad796 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -2784,6 +2784,13 @@ function SaveMappingSection({ label: f.label || f.columnName, badge: "본문", }); + } else if (f.valueType === "formula" && f.label) { + const formulaKey = `__formula_${f.id || f.label}`; + displayed.push({ + sourceField: formulaKey, + label: f.label, + badge: "수식", + }); } } if (inputFieldConfig?.enabled) { @@ -2855,6 +2862,21 @@ function SaveMappingSection({ [mapping.mappings] ); + // 카드에 표시된 필드가 로드되면 매핑에 누락된 필드를 자동 추가 (매핑 안함으로) + useEffect(() => { + if (!mapping.targetTable || cardDisplayedFields.length === 0) return; + const existing = new Set(mapping.mappings.map((m) => m.sourceField)); + const missing = cardDisplayedFields.filter((f) => !existing.has(f.sourceField)); + if (missing.length === 0) return; + onUpdate({ + ...mapping, + mappings: [ + ...mapping.mappings, + ...missing.map((f) => ({ sourceField: f.sourceField, targetColumn: "" })), + ], + }); + }, [cardDisplayedFields]); // eslint-disable-line react-hooks/exhaustive-deps + // 카드에 표시된 필드 중 아직 매핑되지 않은 것 const unmappedCardFields = useMemo( () => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)), @@ -2937,7 +2959,7 @@ function SaveMappingSection({ {isCartMeta(entry.sourceField) ? ( !badge && 장바구니 - ) : ( + ) : entry.sourceField.startsWith("__formula_") ? null : ( {entry.sourceField} diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index c646dfd6..0fbcf6f1 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -211,6 +211,13 @@ export function PopFieldComponent({ columnMapping: Object.fromEntries( (cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn]) ), + autoGenMappings: (cfg.saveConfig.autoGenMappings || []) + .filter((m) => m.numberingRuleId) + .map((m) => ({ + numberingRuleId: m.numberingRuleId!, + targetColumn: m.targetColumn, + showResultModal: m.showResultModal, + })), } : null, }; @@ -585,18 +592,21 @@ function SelectFieldInput({ dataApi .getTableData(source.tableName, { page: 1, - pageSize: 500, - sortColumn: source.labelColumn, - sortDirection: "asc", + size: 500, + sortBy: source.labelColumn, + sortOrder: "asc", }) .then((res) => { - if (res.data?.success && Array.isArray(res.data.data?.data)) { - setOptions( - res.data.data.data.map((row: Record) => ({ - value: String(row[source.valueColumn!] ?? ""), - label: String(row[source.labelColumn!] ?? ""), - })) - ); + if (Array.isArray(res.data)) { + const seen = new Set(); + const deduped: { value: string; label: string }[] = []; + for (const row of res.data) { + const v = String(row[source.valueColumn!] ?? ""); + if (!v || seen.has(v)) continue; + seen.add(v); + deduped.push({ value: v, label: String(row[source.labelColumn!] ?? "") }); + } + setOptions(deduped); } }) .catch(() => { diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx index 4a285f20..41b5ad65 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx @@ -74,7 +74,7 @@ import { type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; import { dataApi } from "@/lib/api/data"; -import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; +import { getAvailableNumberingRulesForScreen, getNumberingRules } from "@/lib/api/numberingRule"; // ======================================== // Props @@ -462,6 +462,8 @@ function SaveTabContent({ // --- 자동생성 필드 로직 --- const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]); + const [allNumberingRules, setAllNumberingRules] = useState<{ ruleId: string; ruleName: string; tableName: string }[]>([]); + const [showAllRules, setShowAllRules] = useState(false); // 레이아웃 auto 필드 → autoGenMappings 자동 동기화 const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(","); @@ -478,7 +480,7 @@ function SaveTabContent({ label: field.labelText || "", targetColumn: "", numberingRuleId: field.autoNumber?.numberingRuleId ?? "", - showInForm: true, + showInForm: false, showResultModal: true, }); } @@ -513,6 +515,24 @@ function SaveTabContent({ } }, [saveTableName]); + useEffect(() => { + if (!showAllRules) return; + if (allNumberingRules.length > 0) return; + getNumberingRules() + .then((res) => { + if (res.success && Array.isArray(res.data)) { + setAllNumberingRules( + res.data.map((r: any) => ({ + ruleId: String(r.ruleId ?? r.rule_id ?? ""), + ruleName: String(r.ruleName ?? r.rule_name ?? ""), + tableName: String(r.tableName ?? r.table_name ?? ""), + })) + ); + } + }) + .catch(() => setAllNumberingRules([])); + }, [showAllRules, allNumberingRules.length]); + const addAutoGenMapping = useCallback(() => { const newMapping: PopFieldAutoGenMapping = { id: `autogen_${Date.now()}`, @@ -1248,7 +1268,19 @@ function SaveTabContent({
- +
+ +
+ + +
+
@@ -1690,12 +1730,13 @@ function FieldItemEditor({ /> )} - {/* auto 전용: 채번 설정 */} + {/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */} {field.inputType === "auto" && ( - onUpdate({ autoNumber })} - /> +
+

+ 채번 규칙은 [저장] 탭 > 자동생성 필드에서 설정합니다. +

+
)} )} @@ -1948,108 +1989,7 @@ function TableSourceEditor({ ); } -// ======================================== -// AutoNumberEditor: 자동 채번 설정 -// ======================================== - -function AutoNumberEditor({ - config, - onUpdate, -}: { - config?: AutoNumberConfig; - onUpdate: (config: AutoNumberConfig) => void; -}) { - const current: AutoNumberConfig = config || { - prefix: "", - dateFormat: "YYYYMMDD", - separator: "-", - sequenceDigits: 3, - }; - - return ( -
- - -
-
- - onUpdate({ ...current, prefix: e.target.value })} - placeholder="IN-" - className="mt-0.5 h-7 text-xs" - /> -
-
- - -
-
- -
-
- - onUpdate({ ...current, separator: e.target.value })} - placeholder="-" - className="mt-0.5 h-7 text-xs" - /> -
-
- - - onUpdate({ - ...current, - sequenceDigits: Number(e.target.value) || 3, - }) - } - min={1} - max={10} - className="mt-0.5 h-7 text-xs" - /> -
-
- - {/* 미리보기 */} -
- 미리보기:{" "} - - {current.prefix || ""} - {current.separator || ""} - {current.dateFormat === "YYMM" - ? "2602" - : current.dateFormat === "YYMMDD" - ? "260226" - : "20260226"} - {current.separator || ""} - {"0".repeat(current.sequenceDigits || 3).slice(0, -1)}1 - -
-
- ); -} +// AutoNumberEditor 삭제됨: 채번 규칙은 저장 탭 > 자동생성 필드에서 관리 // ======================================== // JsonKeySelect: JSON 키 드롭다운 (자동 추출) diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 9dc54978..172acecd 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -638,6 +638,11 @@ export interface CollectedDataResponse { export interface SaveMapping { targetTable: string; columnMapping: Record; + autoGenMappings?: Array<{ + numberingRuleId: string; + targetColumn: string; + showResultModal?: boolean; + }>; } export interface StatusChangeRule { From 4f639dec345302633f05dd935bc0e38a9e8dc755 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 10:09:37 +0900 Subject: [PATCH 03/29] feat: Implement screen group screens duplication in menu copy service - Added a new method `copyScreenGroupScreens` to handle the duplication of screen group screens during the menu copy process. - Implemented logic to create a mapping of screen group IDs from the source to the target company. - Enhanced the existing menu copy functionality to include the copying of screen group screens, ensuring that the screen-role and display order are preserved. - Added logging for better traceability of the duplication process. This update improves the menu copy service by allowing for a more comprehensive duplication of associated screen group screens, enhancing the overall functionality of the menu management system. --- backend-node/src/services/menuCopyService.ts | 108 ++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 747d5427..f67e09a3 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -1127,6 +1127,16 @@ export class MenuCopyService { logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑"); await this.updateMenuUrls(menuIdMap, screenIdMap, client); + // === 6.7단계: screen_group_screens 복제 === + logger.info("\n🏷️ [6.7단계] screen_group_screens 복제"); + await this.copyScreenGroupScreens( + screenIds, + screenIdMap, + sourceCompanyCode, + targetCompanyCode, + client + ); + // === 7단계: 테이블 타입 설정 복사 === if (additionalCopyOptions?.copyTableTypeColumns) { logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); @@ -2108,6 +2118,26 @@ export class MenuCopyService { logger.info(`📂 메뉴 복사 중: ${menus.length}개`); + // screen_group_id 재매핑 맵 생성 (source company → target company) + const screenGroupIdMap = new Map(); + const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[]; + if (sourceGroupIds.length > 0) { + const sourceGroups = await client.query<{ id: number; group_name: string }>( + `SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`, + [sourceGroupIds] + ); + for (const sg of sourceGroups.rows) { + const targetGroup = await client.query<{ id: number }>( + `SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`, + [sg.group_name, targetCompanyCode] + ); + if (targetGroup.rows.length > 0) { + screenGroupIdMap.set(sg.id, targetGroup.rows[0].id); + } + } + logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}개`); + } + // 위상 정렬 (부모 먼저 삽입) const sortedMenus = this.topologicalSortMenus(menus); @@ -2252,7 +2282,7 @@ export class MenuCopyService { menu.menu_code, sourceMenuObjid, menu.menu_icon, - menu.screen_group_id, + menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null, ] ); @@ -2500,6 +2530,82 @@ export class MenuCopyService { logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}개`); } + /** + * screen_group_screens 복제 (화면-스크린그룹 매핑) + */ + private async copyScreenGroupScreens( + screenIds: Set, + screenIdMap: Map, + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + if (screenIds.size === 0 || screenIdMap.size === 0) { + logger.info("📭 screen_group_screens 복제 대상 없음"); + return; + } + + // 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리) + await client.query( + `DELETE FROM screen_group_screens WHERE company_code = $1`, + [targetCompanyCode] + ); + + // 소스 회사의 screen_group_screens 조회 + const sourceScreenIds = Array.from(screenIds); + const sourceResult = await client.query<{ + group_id: number; + screen_id: number; + screen_role: string; + display_order: number; + is_default: string; + }>( + `SELECT group_id, screen_id, screen_role, display_order, is_default + FROM screen_group_screens + WHERE company_code = $1 AND screen_id = ANY($2)`, + [sourceCompanyCode, sourceScreenIds] + ); + + if (sourceResult.rows.length === 0) { + logger.info("📭 소스에 screen_group_screens 없음"); + return; + } + + // screen_group ID 매핑 (source group_name → target group_id) + const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))]; + const sourceGroups = await client.query<{ id: number; group_name: string }>( + `SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`, + [sourceGroupIds] + ); + const groupIdMap = new Map(); + for (const sg of sourceGroups.rows) { + const targetGroup = await client.query<{ id: number }>( + `SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`, + [sg.group_name, targetCompanyCode] + ); + if (targetGroup.rows.length > 0) { + groupIdMap.set(sg.id, targetGroup.rows[0].id); + } + } + + let insertedCount = 0; + for (const row of sourceResult.rows) { + const newGroupId = groupIdMap.get(row.group_id); + const newScreenId = screenIdMap.get(row.screen_id); + if (!newGroupId || !newScreenId) continue; + + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, 'system') + ON CONFLICT DO NOTHING`, + [newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode] + ); + insertedCount++; + } + + logger.info(`✅ screen_group_screens 복제: ${insertedCount}개`); + } + /** * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) */ From 4b8f2b783966b25e603e0df682eb3b3ea5abd7a1 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 11:30:31 +0900 Subject: [PATCH 04/29] feat: Update screen reference handling in V2 layouts - Enhanced the `ScreenManagementService` to include updates for V2 layouts in the `screen_layouts_v2` table. - Implemented logic to remap `screenId`, `targetScreenId`, `modalScreenId`, and other related IDs in layout data. - Added logging for the number of layouts updated in both V1 and V2, improving traceability of the update process. - This update ensures that screen references are correctly maintained across different layout versions, enhancing the overall functionality of the screen management system. --- .../src/services/screenManagementService.ts | 123 ++- .../screen/config-panels/button/ActionTab.tsx | 17 + .../screen/config-panels/button/BasicTab.tsx | 40 + .../screen/config-panels/button/DataTab.tsx | 872 ++++++++++++++++++ .../components/common/ConfigField.tsx | 264 ++++++ .../components/common/ConfigPanelBuilder.tsx | 76 ++ .../components/common/ConfigPanelTypes.ts | 56 ++ .../components/common/ConfigSection.tsx | 52 ++ .../config-panels/CommonConfigTab.tsx | 90 ++ .../config-panels/LeftPanelConfigTab.tsx | 606 ++++++++++++ .../config-panels/RightPanelConfigTab.tsx | 801 ++++++++++++++++ .../config-panels/SharedComponents.tsx | 256 +++++ .../config-panels/BasicConfigPanel.tsx | 125 +++ .../config-panels/ColumnsConfigPanel.tsx | 534 +++++++++++ .../config-panels/OptionsConfigPanel.tsx | 290 ++++++ .../config-panels/StyleConfigPanel.tsx | 129 +++ 16 files changed, 4328 insertions(+), 3 deletions(-) create mode 100644 frontend/components/screen/config-panels/button/ActionTab.tsx create mode 100644 frontend/components/screen/config-panels/button/BasicTab.tsx create mode 100644 frontend/components/screen/config-panels/button/DataTab.tsx create mode 100644 frontend/lib/registry/components/common/ConfigField.tsx create mode 100644 frontend/lib/registry/components/common/ConfigPanelBuilder.tsx create mode 100644 frontend/lib/registry/components/common/ConfigPanelTypes.ts create mode 100644 frontend/lib/registry/components/common/ConfigSection.tsx create mode 100644 frontend/lib/registry/components/v2-split-panel-layout/config-panels/CommonConfigTab.tsx create mode 100644 frontend/lib/registry/components/v2-split-panel-layout/config-panels/LeftPanelConfigTab.tsx create mode 100644 frontend/lib/registry/components/v2-split-panel-layout/config-panels/RightPanelConfigTab.tsx create mode 100644 frontend/lib/registry/components/v2-split-panel-layout/config-panels/SharedComponents.tsx create mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/BasicConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/OptionsConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/StyleConfigPanel.tsx diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index a75fc431..4c5bdc57 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -3482,8 +3482,74 @@ export class ScreenManagementService { } console.log( - `✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, + `✅ V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, ); + + // V2 레이아웃(screen_layouts_v2)도 동일하게 처리 + const v2LayoutsResult = await client.query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE screen_id IN (${placeholders}) + AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`, + targetScreenIds, + ); + + console.log( + `🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}개`, + ); + + let v2Updated = 0; + for (const v2Layout of v2LayoutsResult.rows) { + let layoutData = v2Layout.layout_data; + if (!layoutData) continue; + + let v2HasChanges = false; + + const updateV2References = (obj: any): void => { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) updateV2References(item); + return; + } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ( + (key === "screenId" || key === "targetScreenId" || key === "modalScreenId" || + key === "leftScreenId" || key === "rightScreenId" || + key === "addModalScreenId" || key === "editModalScreenId") + ) { + const numVal = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numVal) && numVal > 0) { + const newId = screenMap.get(numVal); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); + v2HasChanges = true; + console.log(`🔗 V2 ${key} 매핑: ${numVal} → ${newId}`); + } + } + } + if (typeof value === "object" && value !== null) { + updateV2References(value); + } + } + }; + + updateV2References(layoutData); + + if (v2HasChanges) { + await client.query( + `UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`, + [JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code], + ); + v2Updated++; + } + } + + console.log( + `✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`, + ); + result.updated += v2Updated; }); return result; @@ -4610,9 +4676,60 @@ export class ScreenManagementService { } console.log( - `✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`, + `✅ V1: ${updateCount}개 레이아웃 업데이트 완료`, ); - return updateCount; + + // V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑 + const v2Layouts = await query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE screen_id = $1 + AND layout_data IS NOT NULL`, + [screenId], + ); + + let v2UpdateCount = 0; + for (const v2Layout of v2Layouts) { + const layoutData = v2Layout.layout_data; + if (!layoutData?.components) continue; + + let v2Changed = false; + const updateV2Refs = (obj: any): void => { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ( + (key === "targetScreenId" || key === "screenId" || key === "modalScreenId" || + key === "leftScreenId" || key === "rightScreenId" || + key === "addModalScreenId" || key === "editModalScreenId") + ) { + const numVal = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numVal) && screenIdMapping.has(numVal)) { + obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString(); + v2Changed = true; + } + } + if (typeof value === "object" && value !== null) updateV2Refs(value); + } + }; + updateV2Refs(layoutData); + + if (v2Changed) { + await query( + `UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`, + [JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code], + ); + v2UpdateCount++; + } + } + + const total = updateCount + v2UpdateCount; + console.log( + `✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`, + ); + return total; } /** diff --git a/frontend/components/screen/config-panels/button/ActionTab.tsx b/frontend/components/screen/config-panels/button/ActionTab.tsx new file mode 100644 index 00000000..f6c872e6 --- /dev/null +++ b/frontend/components/screen/config-panels/button/ActionTab.tsx @@ -0,0 +1,17 @@ +"use client"; + +import React from "react"; + +export interface ActionTabProps { + config: any; + onChange: (key: string, value: any) => void; + children: React.ReactNode; +} + +/** + * 동작 탭: 클릭 이벤트, 네비게이션, 모달 열기, 확인 다이얼로그 등 동작 설정 + * 실제 UI는 메인 ButtonConfigPanel에서 렌더링 후 children으로 전달 + */ +export const ActionTab: React.FC = ({ children }) => { + return
{children}
; +}; diff --git a/frontend/components/screen/config-panels/button/BasicTab.tsx b/frontend/components/screen/config-panels/button/BasicTab.tsx new file mode 100644 index 00000000..1eb7d2f7 --- /dev/null +++ b/frontend/components/screen/config-panels/button/BasicTab.tsx @@ -0,0 +1,40 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +export interface BasicTabProps { + config: any; + onChange: (key: string, value: any) => void; + localText?: string; + onTextChange?: (value: string) => void; +} + +export const BasicTab: React.FC = ({ + config, + onChange, + localText, + onTextChange, +}) => { + const text = localText !== undefined ? localText : (config.text !== undefined ? config.text : "버튼"); + + const handleChange = (newValue: string) => { + onTextChange?.(newValue); + onChange("componentConfig.text", newValue); + }; + + return ( +
+
+ + handleChange(e.target.value)} + placeholder="버튼 텍스트를 입력하세요" + /> +
+
+ ); +}; diff --git a/frontend/components/screen/config-panels/button/DataTab.tsx b/frontend/components/screen/config-panels/button/DataTab.tsx new file mode 100644 index 00000000..29b35c78 --- /dev/null +++ b/frontend/components/screen/config-panels/button/DataTab.tsx @@ -0,0 +1,872 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { QuickInsertConfigSection } from "../QuickInsertConfigSection"; +import { ComponentData } from "@/types/screen"; + +export interface DataTabProps { + config: any; + onChange: (key: string, value: any) => void; + component: ComponentData; + allComponents: ComponentData[]; + currentTableName?: string; + availableTables: Array<{ name: string; label: string }>; + mappingTargetColumns: Array<{ name: string; label: string }>; + mappingSourceColumnsMap: Record>; + currentTableColumns: Array<{ name: string; label: string }>; + mappingSourcePopoverOpen: Record; + setMappingSourcePopoverOpen: React.Dispatch>>; + mappingTargetPopoverOpen: Record; + setMappingTargetPopoverOpen: React.Dispatch>>; + activeMappingGroupIndex: number; + setActiveMappingGroupIndex: React.Dispatch>; + loadMappingColumns: (tableName: string) => Promise>; + setMappingSourceColumnsMap: React.Dispatch< + React.SetStateAction>> + >; +} + +export const DataTab: React.FC = ({ + config, + onChange, + component, + allComponents, + currentTableName, + availableTables, + mappingTargetColumns, + mappingSourceColumnsMap, + currentTableColumns, + mappingSourcePopoverOpen, + setMappingSourcePopoverOpen, + mappingTargetPopoverOpen, + setMappingTargetPopoverOpen, + activeMappingGroupIndex, + setActiveMappingGroupIndex, + loadMappingColumns, + setMappingSourceColumnsMap, +}) => { + const actionType = config.action?.type; + const onUpdateProperty = (path: string, value: any) => onChange(path, value); + + if (actionType === "quickInsert") { + return ( +
+ +
+ ); + } + + if (actionType !== "transferData") { + return ( +
+ 데이터 전달 또는 즉시 저장 액션을 선택하면 설정할 수 있습니다. +
+ ); + } + + return ( +
+
+

데이터 전달 설정

+ +
+ + +

+ 레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다 +

+
+ +
+ + + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +

+ 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다. +

+ )} +
+ + {config.action?.dataTransfer?.targetType === "component" && ( +
+ + +

테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트

+
+ )} + + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +
+ + + onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value) + } + placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달" + className="h-8 text-xs" + /> +

+ 반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다. +

+
+ )} + +
+ + +

기존 데이터를 어떻게 처리할지 선택

+
+ +
+
+ +

데이터 전달 후 소스의 선택을 해제합니다

+
+ + onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked) + } + /> +
+ +
+
+ +

데이터 전달 전 확인 다이얼로그를 표시합니다

+
+ + onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked) + } + /> +
+ + {config.action?.dataTransfer?.confirmBeforeTransfer && ( +
+ + onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)} + className="h-8 text-xs" + /> +
+ )} + +
+ +
+
+ + + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.minSelection", + parseInt(e.target.value) || 0, + ) + } + className="h-8 w-20 text-xs" + /> +
+
+ + + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.maxSelection", + parseInt(e.target.value) || undefined, + ) + } + className="h-8 w-20 text-xs" + /> +
+
+
+ +
+ +

+ 조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다 +

+
+
+ + +

+ 조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용) +

+
+
+ + + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: "" }); + } else { + newSources[0] = { ...newSources[0], fieldName: "" }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + 선택 안 함 (전체 데이터 병합) + + {(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => ( + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: col.name }); + } else { + newSources[0] = { ...newSources[0], fieldName: col.name }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + {col.label || col.name} + {col.label && col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +

추가 데이터가 저장될 타겟 테이블 컬럼

+
+
+
+ +
+ +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ +
+
+ + +
+

+ 여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다. +

+ + {!config.action?.dataTransfer?.targetTable ? ( +
+

먼저 타겟 테이블을 선택하세요.

+
+ ) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? ( +
+

매핑 그룹이 없습니다. 소스 테이블을 추가하세요.

+
+ ) : ( +
+
+ {(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => ( +
+ + +
+ ))} +
+ + {(() => { + const multiMappings = config.action?.dataTransfer?.multiTableMappings || []; + const activeGroup = multiMappings[activeMappingGroupIndex]; + if (!activeGroup) return null; + + const activeSourceTable = activeGroup.sourceTable || ""; + const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; + const activeRules: any[] = activeGroup.mappingRules || []; + + const updateGroupField = (field: string, value: any) => { + const mappings = [...multiMappings]; + mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; + onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings); + }; + + return ( +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + updateGroupField("sourceTable", table.name); + if (!mappingSourceColumnsMap[table.name]) { + const cols = await loadMappingColumns(table.name); + setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); + } + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ +
+
+ + +
+ + {!activeSourceTable ? ( +

소스 테이블을 먼저 선택하세요.

+ ) : activeRules.length === 0 ? ( +

매핑 없음 (동일 필드명 자동 매핑)

+ ) : ( + activeRules.map((rule: any, rIdx: number) => { + const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +
+
+ + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) + } + > + + + + + + + + 컬럼 없음 + + {activeSourceColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingSourcePopoverOpen((prev) => ({ + ...prev, + [popoverKeyS]: false, + })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + + +
+ + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open })) + } + > + + + + + + + + 컬럼 없음 + + {mappingTargetColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingTargetPopoverOpen((prev) => ({ + ...prev, + [popoverKeyT]: false, + })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + +
+ ); + }) + )} +
+
+ ); + })()} +
+ )} +
+
+ +
+

+ 사용 방법: +
+ 1. 소스 컴포넌트에서 데이터를 선택합니다 +
+ 2. 소스 테이블별로 필드 매핑 규칙을 설정합니다 +
+ 3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다 +

+
+
+
+ ); +}; diff --git a/frontend/lib/registry/components/common/ConfigField.tsx b/frontend/lib/registry/components/common/ConfigField.tsx new file mode 100644 index 00000000..0b11780d --- /dev/null +++ b/frontend/lib/registry/components/common/ConfigField.tsx @@ -0,0 +1,264 @@ +"use client"; + +import React from "react"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Plus, X } from "lucide-react"; +import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes"; + +interface ConfigFieldProps { + field: ConfigFieldDefinition; + value: any; + onChange: (key: string, value: any) => void; + tableColumns?: ConfigOption[]; +} + +export function ConfigField({ + field, + value, + onChange, + tableColumns, +}: ConfigFieldProps) { + const handleChange = (newValue: any) => { + onChange(field.key, newValue); + }; + + const renderField = () => { + switch (field.type) { + case "text": + return ( + handleChange(e.target.value)} + placeholder={field.placeholder} + className="h-8 text-xs" + /> + ); + + case "number": + return ( + + handleChange( + e.target.value === "" ? undefined : Number(e.target.value), + ) + } + placeholder={field.placeholder} + min={field.min} + max={field.max} + step={field.step} + className="h-8 text-xs" + /> + ); + + case "switch": + return ( + + ); + + case "select": + return ( + + ); + + case "textarea": + return ( +