From e5abd93600266ad26be242562649da559ce9be8d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 4 Mar 2026 14:40:48 +0900 Subject: [PATCH 1/7] =?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 2/7] =?UTF-8?q?feat(pop):=20=EC=9E=85=EA=B3=A0=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EC=B1=84=EB=B2=88?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=20+=20=EA=B2=B0=EA=B3=BC=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20UX=20+=20=EC=85=80=EB=A0=89=ED=8A=B8=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=ED=86=B5=EC=9D=BC=20=EC=9E=85=EA=B3=A0=20=ED=99=95?= =?UTF-8?q?=EC=A0=95(inbound-confirm)=20=EC=8B=A4=ED=96=89=20=EC=8B=9C=20?= =?UTF-8?q?=EC=B1=84=EB=B2=88=20=EA=B7=9C=EC=B9=99=EC=9D=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EB=90=98=EC=96=B4=20=EC=9E=88=EC=96=B4=EB=8F=84=20inb?= =?UTF-8?q?ound=5Fnumber=EA=B0=80=20null=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4.=20[=EC=B1=84=EB=B2=88=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20(FIX-1)]=20-=20types.ts:=20SaveMapping=EC=97=90=20a?= =?UTF-8?q?utoGenMappings=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20(num?= =?UTF-8?q?beringRuleId,=20=20=20targetColumn,=20showResultModal)=20-=20Po?= =?UTF-8?q?pFieldComponent:=20collect=5Fdata=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20autoGenMappings=20=ED=8F=AC=ED=95=A8=ED=95=98=EC=97=AC=20=20?= =?UTF-8?q?=20=EB=B0=B1=EC=97=94=EB=93=9C=EC=97=90=20=EC=B1=84=EB=B2=88=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A0=95=EB=B3=B4=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?-=20popActionRoutes:=20INSERT=20=EC=A0=84=20numberingRuleServic?= =?UTF-8?q?e.allocateCode()=20=ED=98=B8=EC=B6=9C,=20=20=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EB=90=9C=20=EC=BD=94=EB=93=9C=EB=A5=BC=20generatedCod?= =?UTF-8?q?es=20=EB=B0=B0=EC=97=B4=EB=A1=9C=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8=20[=EA=B2=B0=EA=B3=BC=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20UX]=20-=20pop-button:=20showResultModal=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=B1=84=EB=B2=88=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=AA=A8=EB=8B=AC=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20-=20=EB=AA=A8=EB=8B=AC=EC=9D=B4=20?= =?UTF-8?q?=EC=97=B4=EB=A0=A4=20=EC=9E=88=EB=8A=94=20=EB=8F=99=EC=95=88=20?= =?UTF-8?q?followUpActions(refresh/navigate)=20=EC=A7=80=EC=97=B0=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=20=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EB=B2=84=ED=8A=BC=EC=9D=84=20=EB=88=8C?= =?UTF-8?q?=EB=9F=AC=EC=95=BC=20=ED=9B=84=EC=86=8D=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20[=EC=85=80=EB=A0=89=ED=8A=B8=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EC=9D=BC=EA=B4=80=EC=84=B1]=20-=20SelectTrigger=20?= =?UTF-8?q?hasCustomHeight=EC=97=90=20/\bh-\d/=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=20=20className=EC=9D=98?= =?UTF-8?q?=20h-9=20=EB=93=B1=EC=9D=B4=20=EA=B8=B0=EB=B3=B8=20data-size=3D?= =?UTF-8?q?"xs"(h-6)=EC=99=80=20=EC=B6=A9=EB=8F=8C=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20[=EA=B8=B0?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95]=20-=20SelectFieldInput:=20Set?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20dedup=EC=9C=BC=EB=A1=9C=20React=20key?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20-=20PopFieldConfig?= =?UTF-8?q?:=20AutoNumberEditor=20=EC=A0=9C=EA=B1=B0,=20=EC=B1=84=EB=B2=88?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EC=A0=80=EC=9E=A5=20=ED=83=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A6=AC=20-=20PopFieldConfig:?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=B1=84=EB=B2=88=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=ED=86=A0=EA=B8=80=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20-=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 91c9dda6ae837568901619d5b434cd5bc4c1a7ec Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 5 Mar 2026 12:13:07 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(pop-field):=20=EC=88=A8=EC=9D=80=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EA=B3=A0=EC=A0=95=EA=B0=92=20+=20Select?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99(linkedFilte?= =?UTF-8?q?rs)=20=EA=B5=AC=ED=98=84=20=EC=9E=85=EA=B3=A0=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20status/inbound=5Fstatus=EA=B0=80=20?= =?UTF-8?q?=EB=B9=88=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C(FIX-3)=EC=99=80=20?= =?UTF-8?q?=EC=B0=BD=EA=B3=A0=EB=82=B4=20=EC=9C=84=EC=B9=98=20=EC=85=80?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=EA=B0=80=20=EC=A0=84=EC=B2=B4=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=EB=A5=BC=20=EB=B3=B4=EC=97=AC=EC=A3=BC=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=ED=95=B4=EA=B2=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20[FIX-3:=20=EC=88=A8=EC=9D=80=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95=EA=B0=92]=20-=20types.ts:=20HiddenValueSo?= =?UTF-8?q?urce=EC=97=90=20"static"=20=EC=B6=94=EA=B0=80,=20staticValue=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20-=20PopFieldConfig:=20=EC=88=A8=EC=9D=80?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=84=A4=EC=A0=95=20UI=EC=97=90=20"?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=EA=B0=92"=20=EB=AA=A8=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20PopFieldComponent:=20collected=5Fdata=EC=97=90?= =?UTF-8?q?=20hiddenMappings=20=ED=8F=AC=ED=95=A8=20-=20popActionRoutes:?= =?UTF-8?q?=20INSERT=20=EC=8B=9C=20hiddenMappings=20=EA=B0=92=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=20[Select=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20-=20BLOCK=20L]=20-=20types.ts:=20SelectLinkedFilter?= =?UTF-8?q?=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20+=20FieldSel?= =?UTF-8?q?ectSource.linkedFilters=20-=20PopFieldConfig:=20"=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99"=20=ED=86=A0=EA=B8=80?= =?UTF-8?q?=20+=20LinkedFiltersEditor=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=20=20(=EC=84=B9=EC=85=98=20=EB=82=B4=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=84=A0=ED=83=9D=20=E2=86=92=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EB=A7=A4=ED=95=91)=20-=20PopFieldCompo?= =?UTF-8?q?nent:=20fieldIdToName=20=EB=A7=B5=EC=9C=BC=EB=A1=9C=20id-name?= =?UTF-8?q?=20=EB=B3=80=ED=99=98,=20=20=20SelectFieldInput=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=B0=EB=8F=99=20=ED=95=84=EB=93=9C=20=EA=B0=92?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=9E=AC=EC=A1=B0=ED=9A=8C,=20=20=20?= =?UTF-8?q?=EC=83=81=EC=9C=84=20=EB=AF=B8=EC=84=A0=ED=83=9D=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80,=20=EC=83=81?= =?UTF-8?q?=EC=9C=84=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=ED=95=98=EC=9C=84?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/popActionRoutes.ts | 39 +++- .../pop-field/PopFieldComponent.tsx | 100 +++++++- .../pop-field/PopFieldConfig.tsx | 216 ++++++++++++++++-- .../pop-components/pop-field/types.ts | 9 +- frontend/lib/registry/pop-components/types.ts | 8 + 5 files changed, 343 insertions(+), 29 deletions(-) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index f71c4495..94b7c5dd 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -19,10 +19,20 @@ interface AutoGenMappingInfo { showResultModal?: boolean; } +interface HiddenMappingInfo { + valueSource: "json_extract" | "db_column" | "static"; + targetColumn: string; + staticValue?: string; + sourceJsonColumn?: string; + sourceJsonKey?: string; + sourceDbColumn?: string; +} + interface MappingInfo { targetTable: string; columnMapping: Record; autoGenMappings?: AutoGenMappingInfo[]; + hiddenMappings?: HiddenMappingInfo[]; } interface StatusConditionRule { @@ -122,7 +132,7 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp let processedCount = 0; let insertedCount = 0; - const generatedCodes: Array<{ targetColumn: string; code: string }> = []; + const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = []; if (action === "inbound-confirm") { // 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블) @@ -153,6 +163,33 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) + const allHidden = [ + ...(fieldMapping?.hiddenMappings ?? []), + ...(cardMapping?.hiddenMappings ?? []), + ]; + for (const hm of allHidden) { + if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue; + if (columns.includes(`"${hm.targetColumn}"`)) continue; + + let value: unknown = null; + if (hm.valueSource === "static") { + value = hm.staticValue ?? null; + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const jsonCol = item[hm.sourceJsonColumn]; + if (typeof jsonCol === "object" && jsonCol !== null) { + value = (jsonCol as Record)[hm.sourceJsonKey] ?? null; + } else if (typeof jsonCol === "string") { + try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ } + } + } else if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null; + } + + columns.push(`"${hm.targetColumn}"`); + values.push(value); + } + // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 const allAutoGen = [ ...(fieldMapping?.autoGenMappings ?? []), diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index 0fbcf6f1..dace22f6 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -20,6 +20,7 @@ import type { FieldSectionStyle, PopFieldReadSource, PopFieldAutoGenMapping, + SelectLinkedFilter, } from "./types"; import type { CollectDataRequest, CollectedDataResponse } from "../types"; import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types"; @@ -60,6 +61,16 @@ export function PopFieldComponent({ const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm); + const fieldIdToName = useMemo(() => { + const map: Record = {}; + for (const section of cfg.sections) { + for (const f of section.fields ?? []) { + map[f.id] = f.fieldName || f.id; + } + } + return map; + }, [cfg.sections]); + // ResizeObserver로 컨테이너 너비 감시 useEffect(() => { if (typeof window === "undefined" || !containerRef.current) return; @@ -218,6 +229,16 @@ export function PopFieldComponent({ targetColumn: m.targetColumn, showResultModal: m.showResultModal, })), + hiddenMappings: (cfg.saveConfig.hiddenMappings || []) + .filter((m) => m.targetColumn) + .map((m) => ({ + valueSource: m.valueSource, + targetColumn: m.targetColumn, + staticValue: m.staticValue, + sourceJsonColumn: m.sourceJsonColumn, + sourceJsonKey: m.sourceJsonKey, + sourceDbColumn: m.sourceDbColumn, + })), } : null, }; @@ -367,6 +388,8 @@ export function PopFieldComponent({ error={errors[fKey]} onChange={handleFieldChange} sectionStyle={section.style} + allValues={allValues} + fieldIdToName={fieldIdToName} /> ); })} @@ -401,6 +424,8 @@ interface FieldRendererProps { error?: string; onChange: (fieldName: string, value: unknown) => void; sectionStyle: FieldSectionStyle; + allValues?: Record; + fieldIdToName?: Record; } function FieldRenderer({ @@ -410,6 +435,8 @@ function FieldRenderer({ error, onChange, sectionStyle, + allValues, + fieldIdToName, }: FieldRendererProps) { const handleChange = useCallback( (v: unknown) => onChange(field.fieldName, v), @@ -436,7 +463,7 @@ function FieldRenderer({ )} )} - {renderByType(field, value, handleChange, inputClassName)} + {renderByType(field, value, handleChange, inputClassName, allValues, fieldIdToName)} {error &&

{error}

} ); @@ -450,7 +477,9 @@ function renderByType( field: PopFieldItem, value: unknown, onChange: (v: unknown) => void, - className: string + className: string, + allValues?: Record, + fieldIdToName?: Record, ) { switch (field.inputType) { case "text": @@ -489,6 +518,8 @@ function renderByType( value={value} onChange={onChange} className={className} + allValues={allValues} + fieldIdToName={fieldIdToName} /> ); case "auto": @@ -561,11 +592,15 @@ function SelectFieldInput({ value, onChange, className, + allValues, + fieldIdToName, }: { field: PopFieldItem; value: unknown; onChange: (v: unknown) => void; className: string; + allValues?: Record; + fieldIdToName?: Record; }) { const [options, setOptions] = useState<{ value: string; label: string }[]>( [] @@ -573,6 +608,30 @@ function SelectFieldInput({ const [loading, setLoading] = useState(false); const source = field.selectSource; + const linkedFilters = source?.linkedFilters; + const hasLinkedFilters = !!linkedFilters?.length; + + // 연동 필터에서 참조하는 필드의 현재 값들을 안정적인 문자열로 직렬화 + const linkedFilterKey = useMemo(() => { + if (!hasLinkedFilters || !allValues || !fieldIdToName) return ""; + return linkedFilters! + .map((lf) => { + const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId; + const val = allValues[fieldName] ?? ""; + return `${lf.filterColumn}=${String(val)}`; + }) + .join("&"); + }, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]); + + // 연동 필터의 소스 값이 모두 채워졌는지 확인 + const linkedFiltersFilled = useMemo(() => { + if (!hasLinkedFilters || !allValues || !fieldIdToName) return true; + return linkedFilters!.every((lf) => { + const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId; + const val = allValues[fieldName]; + return val != null && val !== ""; + }); + }, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]); useEffect(() => { if (!source) return; @@ -588,6 +647,24 @@ function SelectFieldInput({ source.valueColumn && source.labelColumn ) { + // 연동 필터가 있는데 소스 값이 비어있으면 빈 옵션 표시 + if (hasLinkedFilters && !linkedFiltersFilled) { + setOptions([]); + return; + } + + // 동적 필터 구성 + const dynamicFilters: Record = {}; + if (hasLinkedFilters && allValues && fieldIdToName) { + for (const lf of linkedFilters!) { + const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId; + const val = allValues[fieldName]; + if (val != null && val !== "" && lf.filterColumn) { + dynamicFilters[lf.filterColumn] = String(val); + } + } + } + setLoading(true); dataApi .getTableData(source.tableName, { @@ -595,6 +672,7 @@ function SelectFieldInput({ size: 500, sortBy: source.labelColumn, sortOrder: "asc", + ...(Object.keys(dynamicFilters).length > 0 ? { filters: dynamicFilters } : {}), }) .then((res) => { if (Array.isArray(res.data)) { @@ -614,7 +692,16 @@ function SelectFieldInput({ }) .finally(() => setLoading(false)); } - }, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]); + }, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions, linkedFilterKey, linkedFiltersFilled]); + + // W3: 옵션이 바뀌었을 때 현재 선택값이 유효하지 않으면 자동 초기화 + useEffect(() => { + if (!hasLinkedFilters || !value || loading) return; + const currentStr = String(value); + if (options.length > 0 && !options.some((o) => o.value === currentStr)) { + onChange(""); + } + }, [options, hasLinkedFilters]); if (loading) { return ( @@ -641,6 +728,11 @@ function SelectFieldInput({ ); } + // W2: 연동 필터의 소스 값이 비어있으면 안내 메시지 + const emptyMessage = hasLinkedFilters && !linkedFiltersFilled + ? "상위 필드를 먼저 선택하세요" + : "옵션이 없습니다"; + return ( - {!isJson && ( - <> - - + {isDbColumn && ( + + )} + {isStatic && ( + updateHiddenMapping(m.id, { staticValue: e.target.value })} + placeholder="고정값 입력" + className="h-7 flex-1 text-xs" + /> )} {isJson && ( @@ -1365,6 +1377,7 @@ interface SectionEditorProps { onUpdate: (partial: Partial) => void; onRemove: () => void; onMoveUp: () => void; + allSections: PopFieldSection[]; } function migrateStyle(style: string): FieldSectionStyle { @@ -1381,6 +1394,7 @@ function SectionEditor({ onUpdate, onRemove, onMoveUp, + allSections, }: SectionEditorProps) { const [collapsed, setCollapsed] = useState(false); const resolvedStyle = migrateStyle(section.style); @@ -1562,6 +1576,7 @@ function SectionEditor({ sectionStyle={resolvedStyle} onUpdate={(partial) => updateField(field.id, partial)} onRemove={() => removeField(field.id)} + allSections={allSections} /> ))} + )} + + ))} + + + ); +} + // ======================================== // AppearanceEditor: 섹션 외관 설정 // ======================================== diff --git a/frontend/lib/registry/pop-components/pop-field/types.ts b/frontend/lib/registry/pop-components/pop-field/types.ts index 6d9e1734..7118d0a6 100644 --- a/frontend/lib/registry/pop-components/pop-field/types.ts +++ b/frontend/lib/registry/pop-components/pop-field/types.ts @@ -44,6 +44,11 @@ export const DEFAULT_SECTION_APPEARANCES: Record; + hiddenMappings?: Array<{ + valueSource: "json_extract" | "db_column" | "static"; + targetColumn: string; + staticValue?: string; + sourceJsonColumn?: string; + sourceJsonKey?: string; + sourceDbColumn?: string; + }>; } export interface StatusChangeRule { From b2b0b575df26da7e013603a76e0637ed2a633f43 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 5 Mar 2026 17:22:30 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(pop):=20=EB=B2=84=ED=8A=BC=20v2=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20+?= =?UTF-8?q?=20data-update=20=EC=97=B0=EC=82=B0=20=ED=99=95=EC=9E=A5=20(BLO?= =?UTF-8?q?CK=20M=20+=20N)=20=EB=B2=84=ED=8A=BC=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=9D=98=20=EC=8B=A4=ED=96=89=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20=ED=94=84=EB=A6=AC=EC=85=8B=EB=B3=84=20?= =?UTF-8?q?=ED=8C=8C=ED=8E=B8=ED=99=94=EC=97=90=EC=84=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=91=EC=97=85=20=EB=AA=A9=EB=A1=9D(task-list)?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=ED=95=98=EA=B3=A0,=20=EB=B6=80=EB=B6=84=EC=9E=85=EA=B3=A0=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20data-update=20=EC=97=B0?= =?UTF-8?q?=EC=82=B0=EC=9D=84=20=ED=99=95=EC=9E=A5=ED=95=9C=EB=8B=A4.=20[B?= =?UTF-8?q?LOCK=20M:=20=EB=B2=84=ED=8A=BC=20v2=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98]=20-=20ButtonTask=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=B2=B4=EA=B3=84=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?(10=EC=A2=85=20=EC=9E=91=EC=97=85=20=ED=83=80=EC=9E=85=20+=20Up?= =?UTF-8?q?dateOperation)=20-=20PopButtonConfigV2=20+=20migrateButtonConfi?= =?UTF-8?q?g=20=EC=9E=90=EB=8F=99=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20-=20=EC=84=A4=EC=A0=95=20UI:=20=EB=B9=A0?= =?UTF-8?q?=EB=A5=B8=20=EC=8B=9C=EC=9E=91=20+=20=EC=99=B8=ED=98=95=20+=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=AA=A9=EB=A1=9D=20=EC=97=90=EB=94=94?= =?UTF-8?q?=ED=84=B0=20-=20executeTaskList=20=EB=B2=94=EC=9A=A9=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=ED=95=A8=EC=88=98=20(=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=9E=91=EC=97=85=20=EC=9D=BC=EA=B4=84=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=A0=84=EB=8B=AC)=20-=20collect=5Fdata?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=EC=97=90=20cartChanges?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8=20-=20=EB=B0=B1=EC=97=94=EB=93=9C=20tasks?= =?UTF-8?q?=20=EB=B0=B0=EC=97=B4=20=EA=B8=B0=EB=B0=98=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20(data-save/update/delete/cart-save)=20-=20useCartSync.getCha?= =?UTF-8?q?nges()=20=EC=B6=94=EC=B6=9C=20+=20=EC=B9=B4=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9D=91=EB=8B=B5=20=ED=8F=AC=ED=95=A8=20?= =?UTF-8?q?[BLOCK=20N:=20data-update=20=EC=97=B0=EC=82=B0=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5]=20-=20UpdateOperationType=EC=97=90=20multiply,=20div?= =?UTF-8?q?ide,=20db-conditional=20=EC=B6=94=EA=B0=80=20-=20ButtonTask?= =?UTF-8?q?=EC=97=90=20db-conditional=20=EC=A0=84=EC=9A=A9=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=205=EA=B0=9C=20=EC=B6=94=EA=B0=80=20=20=20(compareCol?= =?UTF-8?q?umn,=20compareOperator,=20compareWith,=20dbThenValue,=20dbElseV?= =?UTF-8?q?alue)=20-=20=EC=84=A4=EC=A0=95=20UI:=20=EB=93=9C=EB=A1=AD?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=203=EA=B0=9C=20=EC=98=B5=EC=85=98=20+=20DB?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EB=B9=84=EA=B5=90=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8F=BC=20-=20=EB=B0=B1=EC=97=94=EB=93=9C=20SQL:=20multipl?= =?UTF-8?q?y,=20divide(0-division=20=EB=B0=A9=EC=96=B4),=20=20=20db-condit?= =?UTF-8?q?ional(CASE=20WHEN=20=EB=B0=B0=EC=B9=98=20UPDATE)=20-=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20add/subtract=EC=97=90=20::numeric=20?= =?UTF-8?q?=EC=BA=90=EC=8A=A4=ED=8C=85=20=EC=9D=BC=EA=B4=80=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/popActionRoutes.ts | 319 +++- frontend/hooks/pop/executePopAction.ts | 155 +- frontend/hooks/pop/useCartSync.ts | 31 + .../registry/pop-components/pop-button.tsx | 1464 ++++++++++++----- .../pop-card-list/PopCardListComponent.tsx | 6 +- 5 files changed, 1524 insertions(+), 451 deletions(-) diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 94b7c5dd..730572d8 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -62,7 +62,8 @@ interface StatusChangeRuleBody { } interface ExecuteActionBody { - action: string; + action?: string; + tasks?: TaskBody[]; data: { items?: Record[]; fieldValues?: Record; @@ -72,6 +73,36 @@ interface ExecuteActionBody { field?: MappingInfo | null; }; statusChanges?: StatusChangeRuleBody[]; + cartChanges?: { + toCreate?: Record[]; + toUpdate?: Record[]; + toDelete?: (string | number)[]; + }; +} + +interface TaskBody { + id: string; + type: string; + targetTable?: string; + targetColumn?: string; + operationType?: "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional"; + valueSource?: "fixed" | "linked" | "reference"; + fixedValue?: string; + sourceField?: string; + referenceTable?: string; + referenceColumn?: string; + referenceJoinKey?: string; + conditionalValue?: ConditionalValueRule; + // db-conditional 전용 (DB 컬럼 간 비교 후 값 판정) + compareColumn?: string; + compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<="; + compareWith?: string; + dbThenValue?: string; + dbElseValue?: string; + lookupMode?: "auto" | "manual"; + manualItemField?: string; + manualPkColumn?: string; + cartScreenId?: string; } function resolveStatusValue( @@ -114,27 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); } - const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody; + const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody; const items = data?.items ?? []; const fieldValues = data?.fieldValues ?? {}; logger.info("[pop/execute-action] 요청", { - action, + action: action ?? "task-list", companyCode, userId, itemCount: items.length, hasFieldValues: Object.keys(fieldValues).length > 0, hasMappings: !!mappings, statusChangeCount: statusChanges?.length ?? 0, + taskCount: tasks?.length ?? 0, + hasCartChanges: !!cartChanges, }); await client.query("BEGIN"); let processedCount = 0; let insertedCount = 0; + let deletedCount = 0; const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = []; - if (action === "inbound-confirm") { + // ======== v2: tasks 배열 기반 처리 ======== + if (tasks && tasks.length > 0) { + for (const task of tasks) { + switch (task.type) { + case "data-save": { + // 매핑 기반 INSERT (기존 inbound-confirm INSERT 로직 재사용) + const cardMapping = mappings?.cardList; + const fieldMapping = mappings?.field; + + if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) { + if (!isSafeIdentifier(cardMapping.targetTable)) { + throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); + } + + for (const item of items) { + const columns: string[] = ["company_code"]; + const values: unknown[] = [companyCode]; + + for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) { + if (!isSafeIdentifier(targetColumn)) continue; + columns.push(`"${targetColumn}"`); + values.push(item[sourceField] ?? null); + } + + if (fieldMapping?.targetTable === cardMapping.targetTable) { + for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) { + if (!isSafeIdentifier(targetColumn)) continue; + if (columns.includes(`"${targetColumn}"`)) continue; + columns.push(`"${targetColumn}"`); + values.push(fieldValues[sourceField] ?? null); + } + } + + const allHidden = [ + ...(fieldMapping?.hiddenMappings ?? []), + ...(cardMapping?.hiddenMappings ?? []), + ]; + for (const hm of allHidden) { + if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue; + if (columns.includes(`"${hm.targetColumn}"`)) continue; + let value: unknown = null; + if (hm.valueSource === "static") { + value = hm.staticValue ?? null; + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const jsonCol = item[hm.sourceJsonColumn]; + if (typeof jsonCol === "object" && jsonCol !== null) { + value = (jsonCol as Record)[hm.sourceJsonKey] ?? null; + } else if (typeof jsonCol === "string") { + try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ } + } + } else if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null; + } + columns.push(`"${hm.targetColumn}"`); + values.push(value); + } + + 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 }); + } 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(", "); + await client.query( + `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`, + values, + ); + insertedCount++; + } + } + } + break; + } + + case "data-update": { + if (!task.targetTable || !task.targetColumn) break; + if (!isSafeIdentifier(task.targetTable) || !isSafeIdentifier(task.targetColumn)) break; + + const opType = task.operationType ?? "assign"; + const valSource = task.valueSource ?? "fixed"; + const lookupMode = task.lookupMode ?? "auto"; + + let itemField: string; + let pkColumn: string; + + if (lookupMode === "manual" && task.manualItemField && task.manualPkColumn) { + if (!isSafeIdentifier(task.manualPkColumn)) break; + itemField = task.manualItemField; + pkColumn = task.manualPkColumn; + } else if (task.targetTable === "cart_items") { + itemField = "__cart_id"; + pkColumn = "id"; + } else { + itemField = "__cart_row_key"; + const pkResult = await client.query( + `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [task.targetTable], + ); + pkColumn = pkResult.rows[0]?.attname || "id"; + } + + const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean); + if (lookupValues.length === 0) break; + + if (opType === "conditional" && task.conditionalValue) { + for (let i = 0; i < lookupValues.length; i++) { + const item = items[i] ?? {}; + const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); + await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + [resolved, companyCode, lookupValues[i]], + ); + processedCount++; + } + } else if (opType === "db-conditional") { + // DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중') + if (!task.compareColumn || !task.compareOperator || !task.compareWith) break; + if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break; + + const thenVal = task.dbThenValue ?? ""; + const elseVal = task.dbElseValue ?? ""; + const op = task.compareOperator; + const validOps = ["=", "!=", ">", "<", ">=", "<="]; + if (!validOps.includes(op)) break; + + const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`; + + const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", "); + await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, + [thenVal, elseVal, companyCode, ...lookupValues], + ); + processedCount += lookupValues.length; + } else { + for (let i = 0; i < lookupValues.length; i++) { + const item = items[i] ?? {}; + let value: unknown; + + if (valSource === "linked") { + value = item[task.sourceField ?? ""] ?? null; + } else { + value = task.fixedValue ?? ""; + } + + let setSql: string; + if (opType === "add") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) + $1::numeric`; + } else if (opType === "subtract") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) - $1::numeric`; + } else if (opType === "multiply") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) * $1::numeric`; + } else if (opType === "divide") { + setSql = `"${task.targetColumn}" = CASE WHEN $1::numeric = 0 THEN COALESCE("${task.targetColumn}"::numeric, 0) ELSE COALESCE("${task.targetColumn}"::numeric, 0) / $1::numeric END`; + } else { + setSql = `"${task.targetColumn}" = $1`; + } + + await client.query( + `UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`, + [value, companyCode, lookupValues[i]], + ); + processedCount++; + } + } + + logger.info("[pop/execute-action] data-update 실행", { + table: task.targetTable, + column: task.targetColumn, + opType, + count: lookupValues.length, + }); + break; + } + + case "data-delete": { + if (!task.targetTable) break; + if (!isSafeIdentifier(task.targetTable)) break; + + const pkResult = await client.query( + `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [task.targetTable], + ); + const pkCol = pkResult.rows[0]?.attname || "id"; + const deleteKeys = items.map((item) => item[pkCol] ?? item["id"]).filter(Boolean); + + if (deleteKeys.length > 0) { + const placeholders = deleteKeys.map((_, i) => `$${i + 2}`).join(", "); + await client.query( + `DELETE FROM "${task.targetTable}" WHERE company_code = $1 AND "${pkCol}" IN (${placeholders})`, + [companyCode, ...deleteKeys], + ); + deletedCount += deleteKeys.length; + } + break; + } + + case "cart-save": { + // cartChanges 처리 (M-9에서 확장) + if (!cartChanges) break; + const { toCreate, toUpdate, toDelete } = cartChanges; + + if (toCreate && toCreate.length > 0) { + for (const item of toCreate) { + const cols = Object.keys(item).filter(isSafeIdentifier); + if (cols.length === 0) continue; + const allCols = ["company_code", ...cols.map((c) => `"${c}"`)]; + const allVals = [companyCode, ...cols.map((c) => item[c])]; + const placeholders = allVals.map((_, i) => `$${i + 1}`).join(", "); + await client.query( + `INSERT INTO "cart_items" (${allCols.join(", ")}) VALUES (${placeholders})`, + allVals, + ); + insertedCount++; + } + } + + if (toUpdate && toUpdate.length > 0) { + for (const item of toUpdate) { + const id = item.id; + if (!id) continue; + const cols = Object.keys(item).filter((c) => c !== "id" && isSafeIdentifier(c)); + if (cols.length === 0) continue; + const setClauses = cols.map((c, i) => `"${c}" = $${i + 3}`).join(", "); + await client.query( + `UPDATE "cart_items" SET ${setClauses} WHERE id = $1 AND company_code = $2`, + [id, companyCode, ...cols.map((c) => item[c])], + ); + processedCount++; + } + } + + if (toDelete && toDelete.length > 0) { + const placeholders = toDelete.map((_, i) => `$${i + 2}`).join(", "); + await client.query( + `DELETE FROM "cart_items" WHERE company_code = $1 AND id IN (${placeholders})`, + [companyCode, ...toDelete], + ); + deletedCount += toDelete.length; + } + + logger.info("[pop/execute-action] cart-save 실행", { + created: toCreate?.length ?? 0, + updated: toUpdate?.length ?? 0, + deleted: toDelete?.length ?? 0, + }); + break; + } + + default: + logger.warn("[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시", { type: task.type }); + } + } + } + // ======== v1 레거시: action 기반 처리 ======== + else if (action === "inbound-confirm") { // 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블) const cardMapping = mappings?.cardList; const fieldMapping = mappings?.field; @@ -331,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp await client.query("COMMIT"); logger.info("[pop/execute-action] 완료", { - action, + action: action ?? "task-list", companyCode, processedCount, insertedCount, + deletedCount, }); return res.json({ success: true, - message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`, - data: { processedCount, insertedCount, generatedCodes }, + message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`, + data: { processedCount, insertedCount, deletedCount, generatedCodes }, }); } catch (error: any) { await client.query("ROLLBACK"); diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts index 5800125f..ada6ad77 100644 --- a/frontend/hooks/pop/executePopAction.ts +++ b/frontend/hooks/pop/executePopAction.ts @@ -10,7 +10,7 @@ * - 향후 pop-table 행 액션 등 */ -import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button"; +import type { ButtonMainAction, ButtonTask } from "@/lib/registry/pop-components/pop-button"; import { apiClient } from "@/lib/api/client"; import { dataApi } from "@/lib/api/data"; @@ -197,3 +197,156 @@ export async function executePopAction( return { success: false, error: message }; } } + +// ======================================== +// v2: 작업 목록 실행 +// ======================================== + +/** 수집된 데이터 구조 */ +export interface CollectedPayload { + items?: Record[]; + fieldValues?: Record; + mappings?: { + cardList?: Record | null; + field?: Record | null; + }; + cartChanges?: { + toCreate?: Record[]; + toUpdate?: Record[]; + toDelete?: (string | number)[]; + }; +} + +/** 작업 목록 실행 옵션 */ +interface ExecuteTaskListOptions { + publish: PublishFn; + componentId: string; + collectedData?: CollectedPayload; +} + +/** + * 작업 목록을 순차 실행한다. + * 데이터 관련 작업(data-save, data-update, data-delete, cart-save)은 + * 하나의 API 호출로 묶어 백엔드에서 트랜잭션 처리한다. + * 나머지 작업(modal-open, navigate 등)은 프론트엔드에서 직접 처리한다. + */ +export async function executeTaskList( + tasks: ButtonTask[], + options: ExecuteTaskListOptions, +): Promise { + const { publish, componentId, collectedData } = options; + + // 데이터 작업과 프론트 전용 작업 분리 + const DATA_TASK_TYPES = new Set(["data-save", "data-update", "data-delete", "cart-save"]); + const dataTasks = tasks.filter((t) => DATA_TASK_TYPES.has(t.type)); + const frontTasks = tasks.filter((t) => !DATA_TASK_TYPES.has(t.type)); + + let backendData: Record | null = null; + + try { + // 1. 데이터 작업이 있으면 백엔드에 일괄 전송 + if (dataTasks.length > 0) { + const result = await apiClient.post("/pop/execute-action", { + tasks: dataTasks, + data: { + items: collectedData?.items ?? [], + fieldValues: collectedData?.fieldValues ?? {}, + }, + mappings: collectedData?.mappings ?? {}, + cartChanges: collectedData?.cartChanges, + }); + + if (!result.data?.success) { + return { + success: false, + error: result.data?.message || "데이터 작업 실행에 실패했습니다.", + data: result.data, + }; + } + backendData = result.data; + } + + const innerData = (backendData as Record)?.data as Record | undefined; + const generatedCodes = innerData?.generatedCodes as + Array<{ targetColumn: string; code: string; showResultModal?: boolean }> | undefined; + const hasResultModal = generatedCodes?.some((g) => g.showResultModal); + + // 2. 프론트엔드 전용 작업 순차 실행 (채번 모달이 있으면 navigate 보류) + const deferredNavigateTasks: ButtonTask[] = []; + for (const task of frontTasks) { + switch (task.type) { + case "modal-open": + publish("__pop_modal_open__", { + modalId: task.modalScreenId, + title: task.modalTitle, + mode: task.modalMode, + items: task.modalItems, + }); + break; + + case "navigate": + if (hasResultModal) { + deferredNavigateTasks.push(task); + } else if (task.targetScreenId) { + publish("__pop_navigate__", { screenId: task.targetScreenId, params: task.params }); + } + break; + + case "close-modal": + publish("__pop_close_modal__"); + break; + + case "refresh": + if (!hasResultModal) { + publish("__pop_refresh__"); + } + break; + + case "api-call": { + if (!task.apiEndpoint) break; + const method = (task.apiMethod || "POST").toUpperCase(); + switch (method) { + case "GET": + await apiClient.get(task.apiEndpoint); + break; + case "PUT": + await apiClient.put(task.apiEndpoint); + break; + case "DELETE": + await apiClient.delete(task.apiEndpoint); + break; + default: + await apiClient.post(task.apiEndpoint); + } + break; + } + + case "custom-event": + if (task.eventName) { + publish(task.eventName, task.eventPayload ?? {}); + } + break; + } + } + + // 3. 완료 이벤트 + if (!hasResultModal) { + publish(`__comp_output__${componentId}__action_completed`, { + action: "task-list", + success: true, + }); + } + + return { + success: true, + data: { + generatedCodes, + deferredTasks: deferredNavigateTasks, + ...(backendData ?? {}), + }, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "작업 실행 중 오류가 발생했습니다."; + return { success: false, error: message }; + } +} diff --git a/frontend/hooks/pop/useCartSync.ts b/frontend/hooks/pop/useCartSync.ts index 8060f67d..8228a013 100644 --- a/frontend/hooks/pop/useCartSync.ts +++ b/frontend/hooks/pop/useCartSync.ts @@ -34,6 +34,12 @@ import type { // ===== 반환 타입 ===== +export interface CartChanges { + toCreate: Record[]; + toUpdate: Record[]; + toDelete: (string | number)[]; +} + export interface UseCartSyncReturn { cartItems: CartItemWithId[]; savedItems: CartItemWithId[]; @@ -48,6 +54,7 @@ export interface UseCartSyncReturn { isItemInCart: (rowKey: string) => boolean; getCartItem: (rowKey: string) => CartItemWithId | undefined; + getChanges: (selectedColumns?: string[]) => CartChanges; saveToDb: (selectedColumns?: string[]) => Promise; loadFromDb: () => Promise; resetToSaved: () => void; @@ -252,6 +259,29 @@ export function useCartSync( [cartItems], ); + // ----- diff 계산 (백엔드 전송용) ----- + const getChanges = useCallback((selectedColumns?: string[]): CartChanges => { + const currentScreenId = screenIdRef.current; + + const cartRowKeys = new Set(cartItems.map((i) => i.rowKey)); + const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey)); + const toCreateItems = cartItems.filter((c) => !c.cartId); + + const savedMap = new Map(savedItems.map((s) => [s.rowKey, s])); + const toUpdateItems = cartItems.filter((c) => { + if (!c.cartId) return false; + const saved = savedMap.get(c.rowKey); + if (!saved) return false; + return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status; + }); + + return { + toCreate: toCreateItems.map((item) => cartItemToDbRecord(item, currentScreenId, selectedColumns)), + toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, ...cartItemToDbRecord(item, currentScreenId, selectedColumns) })), + toDelete: toDeleteItems.map((item) => item.cartId!), + }; + }, [cartItems, savedItems]); + // ----- DB 저장 (일괄) ----- const saveToDb = useCallback(async (selectedColumns?: string[]): Promise => { setSyncStatus("saving"); @@ -324,6 +354,7 @@ export function useCartSync( updateItemQuantity, isItemInCart, getCartItem, + getChanges, saveToDb, loadFromDb, resetToSaved, diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index 06699a5a..ae6d05d9 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -24,8 +24,8 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; -import { DataFlowAPI } from "@/lib/api/dataflow"; import { usePopAction } from "@/hooks/pop/usePopAction"; +import { executeTaskList, type CollectedPayload } from "@/hooks/pop/executePopAction"; import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { @@ -49,10 +49,12 @@ import { ShoppingCart, ShoppingBag, PackageCheck, + ChevronRight, + GripVertical, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; -import type { CollectedDataResponse, StatusChangeRule } from "./types"; +import type { CollectedDataResponse, StatusChangeRule, ConditionalValue } from "./types"; import { apiClient } from "@/lib/api/client"; import { TableCombobox } from "./pop-shared/TableCombobox"; import { ColumnCombobox } from "./pop-shared/ColumnCombobox"; @@ -158,6 +160,244 @@ export interface PopButtonConfig { inboundConfirm?: { statusChangeRules?: StatusChangeRule[] }; } +// ======================================== +// STEP 1-B: 통합 작업 목록 타입 (v2) +// ======================================== + +/** 작업 타입 (10종) */ +export type ButtonTaskType = + | "data-save" + | "data-update" + | "data-delete" + | "cart-save" + | "modal-open" + | "navigate" + | "close-modal" + | "refresh" + | "api-call" + | "custom-event"; + +/** 데이터 수정 연산 */ +export type UpdateOperationType = "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional"; + +/** 데이터 수정 값 출처 */ +export type UpdateValueSource = "fixed" | "linked" | "reference"; + +/** 작업 1건 설정 */ +export interface ButtonTask { + id: string; + type: ButtonTaskType; + label?: string; + + // data-update / data-delete + targetTable?: string; + targetColumn?: string; + operationType?: UpdateOperationType; + valueSource?: UpdateValueSource; + fixedValue?: string; + sourceField?: string; + referenceTable?: string; + referenceColumn?: string; + referenceJoinKey?: string; + conditionalValue?: ConditionalValue; + // db-conditional (DB 컬럼 간 비교 후 값 판정) + compareColumn?: string; + compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<="; + compareWith?: string; + dbThenValue?: string; + dbElseValue?: string; + lookupMode?: "auto" | "manual"; + manualItemField?: string; + manualPkColumn?: string; + + // cart-save + cartScreenId?: string; + + // modal-open + modalMode?: ModalMode; + modalScreenId?: string; + modalTitle?: string; + modalItems?: ModalMenuItem[]; + + // navigate + targetScreenId?: string; + params?: Record; + + // api-call + apiEndpoint?: string; + apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; + + // custom-event + eventName?: string; + eventPayload?: Record; +} + +/** 빠른 시작 템플릿 */ +export type QuickStartTemplate = "save" | "delete" | "confirm" | "cart" | "modal" | "custom"; + +/** pop-button 설정 v2 (작업 목록 기반) */ +export interface PopButtonConfigV2 { + label: string; + variant: ButtonVariant; + icon?: string; + iconOnly?: boolean; + confirm?: ConfirmConfig; + tasks: ButtonTask[]; +} + +/** 기존 config(v1) → v2 변환. tasks 필드가 이미 있으면 그대로 반환. */ +export function migrateButtonConfig(old: PopButtonConfig): PopButtonConfigV2 { + if ("tasks" in old && Array.isArray((old as unknown as PopButtonConfigV2).tasks)) { + return old as unknown as PopButtonConfigV2; + } + + const tasks: ButtonTask[] = []; + let tid = 1; + const nextId = () => `t${tid++}`; + + // 메인 액션 → task 변환 + if (old.preset === "cart") { + tasks.push({ id: nextId(), type: "cart-save", cartScreenId: old.cart?.cartScreenId }); + } else if (old.action?.type === "modal") { + tasks.push({ + id: nextId(), + type: "modal-open", + modalMode: old.action.modalMode, + modalScreenId: old.action.modalScreenId, + modalTitle: old.action.modalTitle, + modalItems: old.action.modalItems, + }); + } else if (old.action?.type === "delete") { + tasks.push({ id: nextId(), type: "data-delete", targetTable: old.action.targetTable }); + } else if (old.action?.type === "api") { + tasks.push({ + id: nextId(), + type: "api-call", + apiEndpoint: old.action.apiEndpoint, + apiMethod: old.action.apiMethod, + }); + } else if (old.action?.type === "event" && old.preset !== "inbound-confirm") { + tasks.push({ + id: nextId(), + type: "custom-event", + eventName: old.action.eventName, + eventPayload: old.action.eventPayload, + }); + } else { + // save / inbound-confirm / 기본 + tasks.push({ id: nextId(), type: "data-save" }); + } + + // 상태변경 규칙 → data-update task + const rules = old.statusChangeRules ?? old.inboundConfirm?.statusChangeRules ?? []; + for (const rule of rules) { + tasks.push({ + id: nextId(), + type: "data-update", + targetTable: rule.targetTable, + targetColumn: rule.targetColumn, + operationType: "assign", + valueSource: rule.valueType === "conditional" ? "fixed" : "fixed", + fixedValue: rule.fixedValue, + conditionalValue: rule.conditionalValue, + lookupMode: rule.lookupMode, + manualItemField: rule.manualItemField, + manualPkColumn: rule.manualPkColumn, + }); + } + + // 후속 액션 → task + for (const fa of old.followUpActions ?? []) { + switch (fa.type) { + case "refresh": + tasks.push({ id: nextId(), type: "refresh" }); + break; + case "navigate": + tasks.push({ id: nextId(), type: "navigate", targetScreenId: fa.targetScreenId, params: fa.params }); + break; + case "close-modal": + tasks.push({ id: nextId(), type: "close-modal" }); + break; + case "event": + tasks.push({ id: nextId(), type: "custom-event", eventName: fa.eventName, eventPayload: fa.eventPayload }); + break; + } + } + + return { + label: old.label, + variant: old.variant, + icon: old.icon, + iconOnly: old.iconOnly, + confirm: old.confirm, + tasks, + }; +} + +/** 작업 타입 한글 라벨 */ +export const TASK_TYPE_LABELS: Record = { + "data-save": "데이터 저장", + "data-update": "데이터 수정", + "data-delete": "데이터 삭제", + "cart-save": "장바구니 저장", + "modal-open": "모달 열기", + "navigate": "페이지 이동", + "close-modal": "모달 닫기", + "refresh": "새로고침", + "api-call": "API 호출", + "custom-event": "커스텀 이벤트", +}; + +/** 빠른 시작 템플릿별 기본 작업 목록 + 외형 */ +export const QUICK_START_DEFAULTS: Record = { + save: { + label: "저장", + variant: "default", + icon: "Save", + confirm: { enabled: false }, + tasks: [{ id: "t1", type: "data-save" }], + }, + delete: { + label: "삭제", + variant: "destructive", + icon: "Trash2", + confirm: { enabled: true, message: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." }, + tasks: [{ id: "t1", type: "data-delete" }], + }, + confirm: { + label: "확정", + variant: "default", + icon: "PackageCheck", + confirm: { enabled: true, message: "선택한 항목을 확정하시겠습니까?" }, + tasks: [ + { id: "t1", type: "data-save" }, + { id: "t2", type: "data-update" }, + { id: "t3", type: "refresh" }, + ], + }, + cart: { + label: "장바구니 저장", + variant: "default", + icon: "ShoppingCart", + confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" }, + tasks: [{ id: "t1", type: "cart-save" }], + }, + modal: { + label: "열기", + variant: "outline", + icon: "ExternalLink", + confirm: { enabled: false }, + tasks: [{ id: "t1", type: "modal-open" }], + }, + custom: { + label: "버튼", + variant: "default", + icon: "none", + confirm: { enabled: false }, + tasks: [], + }, +}; + // ======================================== // 상수 // ======================================== @@ -365,6 +605,8 @@ const LUCIDE_ICON_MAP: Record = { ShoppingCart, ShoppingBag, PackageCheck, + ChevronRight, + GripVertical, }; /** Lucide 아이콘 동적 렌더링 */ @@ -423,13 +665,28 @@ export function PopButtonComponent({ const [inboundSelectedCount, setInboundSelectedCount] = useState(0); const [generatedCodesResult, setGeneratedCodesResult] = useState>([]); + const [deferredV2Tasks, setDeferredV2Tasks] = useState }>>([]); + const handleCloseGeneratedCodesModal = useCallback(() => { setGeneratedCodesResult([]); - toast.success("입고 확정 완료"); + toast.success("작업 완료"); publish(`__comp_output__${componentId}__action_completed`, { - action: "inbound-confirm", + action: "task-list", success: true, }); + + // v2 보류된 작업 실행 + if (deferredV2Tasks.length > 0) { + for (const task of deferredV2Tasks) { + if (task.type === "navigate" && task.targetScreenId) { + publish("__pop_navigate__", { screenId: task.targetScreenId, params: task.params }); + } + } + setDeferredV2Tasks([]); + return; + } + + // v1 후속 액션 const followUps = config?.followUpActions ?? []; for (const fa of followUps) { switch (fa.type) { @@ -446,11 +703,26 @@ export function PopButtonComponent({ break; } } - }, [componentId, publish, config?.followUpActions]); + }, [componentId, publish, config?.followUpActions, deferredV2Tasks]); + + // v2 작업 목록 감지 (선택 항목 구독보다 먼저 선언) + const v2Config = useMemo(() => { + if (!config) return null; + if ("tasks" in config && Array.isArray((config as unknown as PopButtonConfigV2).tasks)) { + return config as unknown as PopButtonConfigV2; + } + return null; + }, [config]); + + // 선택 항목 수 수신 (v1 inbound-confirm + v2 모두 활성) + const hasDataTasks = useMemo(() => { + if (isInboundConfirmMode) return true; + if (!v2Config) return false; + return v2Config.tasks.some((t) => t.type === "data-save" || t.type === "data-update"); + }, [isInboundConfirmMode, v2Config]); - // 입고 확정 모드: 선택 항목 수 수신 useEffect(() => { - if (!isInboundConfirmMode || !componentId) return; + if (!hasDataTasks || !componentId) return; const unsub = subscribe( `__comp_input__${componentId}__selected_items`, (payload: unknown) => { @@ -460,7 +732,7 @@ export function PopButtonComponent({ } ); return unsub; - }, [isInboundConfirmMode, componentId, subscribe]); + }, [hasDataTasks, componentId, subscribe]); // 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달) useEffect(() => { @@ -642,14 +914,94 @@ export function PopButtonComponent({ } }, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules, config?.followUpActions]); + // v2: 데이터 수집 → executeTaskList 호출 + const handleV2Execute = useCallback(async () => { + if (!v2Config || !componentId) return; + setConfirmProcessing(true); + + try { + const responses: CollectedDataResponse[] = []; + const unsub = subscribe( + `__comp_input__${componentId}__collected_data`, + (payload: unknown) => { + const enriched = payload as { value?: CollectedDataResponse }; + if (enriched?.value) responses.push(enriched.value); + }, + ); + + publish(`__comp_output__${componentId}__collect_data`, { + requestId: crypto.randomUUID(), + action: "task-list", + }); + unsub(); + + const cardListData = responses.find((r) => r.componentType === "pop-card-list"); + const fieldData = responses.find((r) => r.componentType === "pop-field"); + + const collectedData: CollectedPayload = { + items: cardListData?.data?.items ?? [], + fieldValues: fieldData?.data?.values ?? {}, + mappings: { + cardList: cardListData?.mapping ?? null, + field: fieldData?.mapping ?? null, + }, + cartChanges: (cardListData?.data as Record)?.cartChanges as CollectedPayload["cartChanges"], + }; + + const result = await executeTaskList(v2Config.tasks, { + publish, + componentId, + collectedData, + }); + + if (result.success) { + const resultData = result.data as Record | undefined; + const generatedCodes = resultData?.generatedCodes as + Array<{ targetColumn: string; code: string; showResultModal?: boolean }> | undefined; + const deferred = resultData?.deferredTasks as + Array<{ type: string; targetScreenId?: string; params?: Record }> | undefined; + + if (generatedCodes?.some((g) => g.showResultModal)) { + setGeneratedCodesResult(generatedCodes); + if (deferred && deferred.length > 0) { + setDeferredV2Tasks(deferred); + } + } else { + toast.success("작업 완료"); + } + } else { + toast.error(result.error || "작업 실행에 실패했습니다."); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "작업 실행 중 오류가 발생했습니다."; + toast.error(message); + } finally { + setConfirmProcessing(false); + } + }, [v2Config, componentId, subscribe, publish]); + // 클릭 핸들러 const handleClick = useCallback(async () => { if (isDesignMode) { - const modeLabel = isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"]; - toast.info(`[디자인 모드] ${modeLabel} 액션`); + const modeLabel = v2Config + ? v2Config.tasks.map((t) => TASK_TYPE_LABELS[t.type]).join(" → ") + : isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"]; + toast.info(`[디자인 모드] ${modeLabel}`); return; } + // v2 경로: tasks 배열이 있으면 새 실행 엔진 사용 + if (v2Config) { + if (v2Config.confirm?.enabled) { + setShowInboundConfirm(true); + } else { + await handleV2Execute(); + } + return; + } + + // === 이하 v1 레거시 경로 === + // 입고 확정 모드: confirm 다이얼로그 후 데이터 수집 → API 호출 if (isInboundConfirmMode) { if (config?.confirm?.enabled !== false) { @@ -668,10 +1020,8 @@ export function PopButtonComponent({ } if (cartIsDirty) { - // 새로 담은 항목이 있음 → 확인 후 저장 setShowCartConfirm(true); } else { - // 이미 저장된 상태 → 바로 장바구니 화면 이동 const targetScreenId = config?.cart?.cartScreenId; if (targetScreenId) { const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim(); @@ -690,7 +1040,7 @@ export function PopButtonComponent({ confirm: config?.confirm, followUpActions: config?.followUpActions, }); - }, [isDesignMode, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm]); + }, [isDesignMode, v2Config, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm, handleV2Execute]); // 외형 const buttonLabel = config?.label || label || "버튼"; @@ -718,19 +1068,19 @@ export function PopButtonComponent({ return ""; }, [isCartMode, cartCount, cartIsDirty]); - // 입고 확정 2상태 아이콘: 미선택(기본 아이콘) / 선택됨(체크 아이콘) + // 데이터 작업 버튼 2상태 아이콘: 미선택(기본) / 선택됨(아이콘 유지) const inboundIconName = useMemo(() => { - if (!isInboundConfirmMode) return iconName; - return inboundSelectedCount > 0 ? (config?.icon || "PackageCheck") : (config?.icon || "PackageCheck"); - }, [isInboundConfirmMode, inboundSelectedCount, config?.icon, iconName]); + if (!hasDataTasks && !isInboundConfirmMode) return iconName; + return config?.icon || iconName || "PackageCheck"; + }, [hasDataTasks, isInboundConfirmMode, config?.icon, iconName]); - // 입고 확정 2상태 버튼 색상: 미선택(기본) / 선택됨(초록) + // 데이터 작업 버튼 2상태 색상: 미선택(기본) / 선택됨(초록) const inboundButtonClass = useMemo(() => { - if (!isInboundConfirmMode) return ""; + if (isCartMode) return ""; return inboundSelectedCount > 0 ? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600" : ""; - }, [isInboundConfirmMode, inboundSelectedCount]); + }, [isCartMode, inboundSelectedCount]); return ( <> @@ -747,9 +1097,9 @@ export function PopButtonComponent({ inboundButtonClass, )} > - {(isCartMode ? cartIconName : isInboundConfirmMode ? inboundIconName : iconName) && ( + {(isCartMode ? cartIconName : hasDataTasks ? inboundIconName : iconName) && ( @@ -772,8 +1122,8 @@ export function PopButtonComponent({ )} - {/* 입고 확정 선택 개수 배지 */} - {isInboundConfirmMode && inboundSelectedCount > 0 && ( + {/* 선택 개수 배지 (v1 inbound-confirm + v2 data tasks) */} + {!isCartMode && hasDataTasks && inboundSelectedCount > 0 && (
- {/* 입고 확정 확인 다이얼로그 */} + {/* 확정/실행 확인 다이얼로그 (v2 + v1 입고확정 공용) */} - 입고 확정 + {v2Config ? "실행 확인" : "입고 확정"} - {config?.confirm?.message || "선택한 품목을 입고 확정하시겠습니까?"} + {v2Config + ? (v2Config.confirm?.message || "작업을 실행하시겠습니까?") + : (config?.confirm?.message || "선택한 품목을 입고 확정하시겠습니까?")} @@ -831,11 +1183,11 @@ export function PopButtonComponent({ 취소 { handleInboundConfirm(); }} + onClick={() => { v2Config ? handleV2Execute() : handleInboundConfirm(); }} disabled={confirmProcessing} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" > - {confirmProcessing ? "처리 중..." : "확정"} + {confirmProcessing ? "처리 중..." : "확인"} @@ -904,7 +1256,7 @@ export function PopButtonComponent({ } // ======================================== -// STEP 3: 설정 패널 +// STEP 3: 설정 패널 (v2 작업 목록 기반) // ======================================== interface PopButtonConfigPanelProps { @@ -918,201 +1270,127 @@ interface PopButtonConfigPanelProps { export function PopButtonConfigPanel({ config, onUpdate, - allComponents, - connections, - componentId, }: PopButtonConfigPanelProps) { - const isCustom = config?.preset === "custom"; + const v2 = useMemo(() => migrateButtonConfig(config), [config]); - // 컬럼 불러오기용 상태 - const [loadedColumns, setLoadedColumns] = useState<{ name: string; label: string }[]>([]); - const [colLoading, setColLoading] = useState(false); - const [connectedTableName, setConnectedTableName] = useState(null); + const updateV2 = useCallback( + (partial: Partial) => { + const merged = { ...v2, ...partial }; + onUpdate(merged as unknown as PopButtonConfig); + }, + [v2, onUpdate], + ); - // 연결된 카드 목록의 테이블명 자동 탐색 - useEffect(() => { - if (config?.preset !== "cart" || !componentId || !connections || !allComponents) { - setConnectedTableName(null); - return; - } + const updateTask = useCallback( + (taskId: string, partial: Partial) => { + const next = v2.tasks.map((t) => (t.id === taskId ? { ...t, ...partial } : t)); + updateV2({ tasks: next }); + }, + [v2.tasks, updateV2], + ); - // 방법 1: 버튼(source) -> 카드목록(target) cart_save_trigger 연결 - let cardListId: string | undefined; - const outConn = connections.find( - (c) => - c.sourceComponent === componentId && - c.sourceOutput === "cart_save_trigger", - ); - if (outConn) { - cardListId = outConn.targetComponent; - } + const removeTask = useCallback( + (taskId: string) => { + updateV2({ tasks: v2.tasks.filter((t) => t.id !== taskId) }); + }, + [v2.tasks, updateV2], + ); - // 방법 2: 카드목록(source) -> 버튼(target) cart_updated 연결 (역방향) - if (!cardListId) { - const inConn = connections.find( - (c) => - c.targetComponent === componentId && - (c.sourceOutput === "cart_updated" || c.sourceOutput === "cart_save_completed"), - ); - if (inConn) { - cardListId = inConn.sourceComponent; - } - } + const addTask = useCallback( + (type: ButtonTaskType) => { + const id = `t${Date.now()}`; + updateV2({ tasks: [...v2.tasks, { id, type }] }); + }, + [v2.tasks, updateV2], + ); - // 방법 3: 버튼과 연결된 pop-card-list 타입 컴포넌트 탐색 - if (!cardListId) { - const anyConn = connections.find( - (c) => - (c.sourceComponent === componentId || c.targetComponent === componentId), - ); - if (anyConn) { - const otherId = anyConn.sourceComponent === componentId - ? anyConn.targetComponent - : anyConn.sourceComponent; - const otherComp = allComponents.find((c) => c.id === otherId); - if (otherComp?.type === "pop-card-list") { - cardListId = otherId; - } - } - } - - if (!cardListId) { - setConnectedTableName(null); - return; - } - - const cardList = allComponents.find((c) => c.id === cardListId); - const cfg = cardList?.config as Record | undefined; - const dataSource = cfg?.dataSource as Record | undefined; - const tableName = (dataSource?.tableName as string) || (cfg?.tableName as string) || undefined; - setConnectedTableName(tableName || null); - }, [config?.preset, componentId, connections, allComponents]); - - // 선택 저장 모드 + 연결 테이블명이 있으면 컬럼 자동 로드 - useEffect(() => { - if (config?.cart?.rowDataMode !== "selected" || !connectedTableName) { - return; - } - // 이미 같은 테이블의 컬럼이 로드되어 있으면 스킵 - if (loadedColumns.length > 0) return; - - let cancelled = false; - setColLoading(true); - DataFlowAPI.getTableColumns(connectedTableName) - .then((cols) => { - if (cancelled) return; - setLoadedColumns( - cols - .filter((c: { columnName: string }) => - !["id", "created_at", "updated_at", "created_by", "updated_by"].includes(c.columnName), - ) - .map((c: { columnName: string; displayName?: string }) => ({ - name: c.columnName, - label: c.displayName || c.columnName, - })), - ); - }) - .catch(() => { - if (!cancelled) setLoadedColumns([]); - }) - .finally(() => { - if (!cancelled) setColLoading(false); + // 빠른 시작 + const applyQuickStart = useCallback( + (template: QuickStartTemplate) => { + const defaults = QUICK_START_DEFAULTS[template]; + updateV2({ + label: defaults.label, + variant: defaults.variant, + icon: defaults.icon === "none" ? undefined : defaults.icon, + confirm: defaults.confirm, + tasks: defaults.tasks.map((t) => ({ ...t, id: `t${Date.now()}_${Math.random().toString(36).slice(2, 6)}` })), }); - return () => { cancelled = true; }; - }, [config?.cart?.rowDataMode, connectedTableName, loadedColumns.length]); + }, + [updateV2], + ); - // 프리셋 변경 핸들러 - const handlePresetChange = (preset: ButtonPreset) => { - const defaults = PRESET_DEFAULTS[preset]; - onUpdate({ - ...config, - preset, - label: defaults.label || config.label, - variant: defaults.variant || config.variant, - icon: defaults.icon ?? config.icon, - confirm: defaults.confirm || config.confirm, - action: (defaults.action as ButtonMainAction) || config.action, - // 후속 액션은 프리셋 변경 시 유지 - }); - }; - - // 메인 액션 업데이트 헬퍼 - const updateAction = (updates: Partial) => { - onUpdate({ - ...config, - action: { ...config.action, ...updates }, - }); - }; + // 작업 순서 이동 + const moveTask = useCallback( + (taskId: string, direction: "up" | "down") => { + const idx = v2.tasks.findIndex((t) => t.id === taskId); + if (idx < 0) return; + const swapIdx = direction === "up" ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= v2.tasks.length) return; + const next = [...v2.tasks]; + [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]; + updateV2({ tasks: next }); + }, + [v2.tasks, updateV2], + ); return (
- {/* 프리셋 선택 */} - - - {!isCustom && ( -

- 프리셋 변경 시 외형과 액션이 자동 설정됩니다 -

- )} + {/* 빠른 시작 */} + +
+ {(Object.keys(QUICK_START_DEFAULTS) as QuickStartTemplate[]).map((key) => ( + + ))} +
+

+ 클릭하면 외형과 작업 목록이 자동 설정됩니다 +

{/* 외형 설정 */}
- {/* 라벨 */}
onUpdate({ ...config, label: e.target.value })} + value={v2.label || ""} + onChange={(e) => updateV2({ label: e.target.value })} placeholder="버튼 텍스트" className="h-8 text-xs" />
- {/* variant */}
- {/* 아이콘 */}
- {/* 아이콘 전용 모드 */}
- onUpdate({ ...config, iconOnly: checked === true }) - } + id="iconOnlyV2" + checked={v2.iconOnly || false} + onCheckedChange={(checked) => updateV2({ iconOnly: checked === true })} /> -
- {/* 장바구니 설정 (cart 프리셋 전용) */} - {config?.preset === "cart" && ( - <> - -
-
- - - onUpdate({ - ...config, - cart: { ...config.cart, cartScreenId: e.target.value }, - }) - } - placeholder="저장 후 이동할 POP 화면 ID" - className="h-8 text-xs" - /> -

- 저장 완료 후 이동할 장바구니 리스트 화면 ID입니다. - 비어있으면 이동 없이 저장만 합니다. -

-
-
- - {/* 데이터 저장 흐름 시각화 */} - -
-

- 카드 목록에서 "담기" 클릭 시 아래와 같이 cart_items 테이블에 저장됩니다. -

- -
- {/* 사용자 입력 데이터 */} -
-

사용자 입력

- - - - -
- - {/* 원본 데이터 */} -
-

원본 행 데이터

- - {/* 저장 모드 선택 */} -
- 저장 모드: - -
- - {config?.cart?.rowDataMode === "selected" ? ( - <> - {/* 선택 저장 모드: 컬럼 목록 관리 */} -
- {connectedTableName ? ( -

- 연결: {connectedTableName} -

- ) : ( -

- 카드 목록과 연결(cart_save_trigger)하면 컬럼이 자동으로 표시됩니다. -

- )} - - {colLoading && ( -

컬럼 불러오는 중...

- )} - - {/* 불러온 컬럼 체크박스 */} - {loadedColumns.length > 0 && ( -
- {loadedColumns.map((col) => { - const isChecked = (config?.cart?.selectedColumns || []).includes(col.name); - return ( - - ); - })} -
- )} - - {/* 선택된 컬럼 요약 */} - {(config?.cart?.selectedColumns?.length ?? 0) > 0 ? ( - - ) : ( -

- 저장할 컬럼을 선택하세요. 미선택 시 전체 저장됩니다. -

- )} -
- - ) : ( - - )} - - - -
- - {/* 시스템 자동 */} -
-

자동 설정

- - - - - -
-
- -

- 장바구니 목록 화면에서 row_data의 JSON을 풀어서 - 최종 대상 테이블로 매핑할 수 있습니다. -

-
- - )} - - {/* 메인 액션 (cart 프리셋에서는 숨김) */} - {config?.preset !== "cart" && ( - <> - -
-
- - - {!isCustom && ( -

- 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 -

- )} -
- -
- - )} - - {/* 확인 다이얼로그 */} + {/* 확인 메시지 */}
- onUpdate({ - ...config, - confirm: { - ...config?.confirm, - enabled: checked === true, - }, - }) + updateV2({ confirm: { ...v2.confirm, enabled: checked === true } }) } /> -
- {config?.confirm?.enabled && ( -
- - onUpdate({ - ...config, - confirm: { - ...config?.confirm, - enabled: true, - message: e.target.value, - }, - }) - } - placeholder="비워두면 기본 메시지 사용" - className="h-8 text-xs" - /> -

- 기본:{" "} - {DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]} -

-
+ {v2.confirm?.enabled && ( + + updateV2({ confirm: { ...v2.confirm, enabled: true, message: e.target.value } }) + } + placeholder="비워두면 기본 메시지 사용" + className="h-8 text-xs" + /> )}
- {/* 상태 변경 규칙 (cart 프리셋 제외 모두 표시) */} - {config?.preset !== "cart" && ( - <> - - onUpdate({ ...config, statusChangeRules: rules })} + {/* 작업 목록 */} + +
+ {v2.tasks.length === 0 && ( +

+ 작업이 없습니다. 빠른 시작 또는 아래 버튼으로 추가하세요. +

+ )} + + {v2.tasks.map((task, idx) => ( + updateTask(task.id, partial)} + onRemove={() => removeTask(task.id)} + onMove={(dir) => moveTask(task.id, dir)} /> - + ))} + + {/* 작업 추가 */} + +
+
+ ); +} + +// ======================================== +// 작업 항목 에디터 (접힘/펼침) +// ======================================== + +function TaskItemEditor({ + task, + index, + totalCount, + onUpdate, + onRemove, + onMove, +}: { + task: ButtonTask; + index: number; + totalCount: number; + onUpdate: (partial: Partial) => void; + onRemove: () => void; + onMove: (direction: "up" | "down") => void; +}) { + const [expanded, setExpanded] = useState(false); + const designerCtx = usePopDesignerContext(); + + return ( +
+ {/* 헤더: 타입 + 순서 + 삭제 */} +
setExpanded(!expanded)} + > + + + + {index + 1}. {TASK_TYPE_LABELS[task.type]} + + {task.label && ( + + ({task.label}) + + )} +
+ {index > 0 && ( + + )} + {index < totalCount - 1 && ( + + )} + +
+
+ + {/* 펼침: 타입별 설정 폼 */} + {expanded && ( +
+ +
+ )} +
+ ); +} + +// ======================================== +// 작업별 설정 폼 (M-4) +// ======================================== + +function TaskDetailForm({ + task, + onUpdate, + designerCtx, +}: { + task: ButtonTask; + onUpdate: (partial: Partial) => void; + designerCtx: ReturnType; +}) { + // 테이블/컬럼 조회 (data-update, data-delete용) + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const needsTable = task.type === "data-update" || task.type === "data-delete"; + + useEffect(() => { + if (needsTable) fetchTableList().then(setTables); + }, [needsTable]); + + useEffect(() => { + if (needsTable && task.targetTable) { + fetchTableColumns(task.targetTable).then(setColumns); + } else { + setColumns([]); + } + }, [needsTable, task.targetTable]); + + switch (task.type) { + case "data-save": + return ( +

+ 연결된 입력 컴포넌트의 저장 매핑을 사용합니다. 별도 설정 불필요. +

+ ); + + case "data-update": + return ( + + ); + + case "data-delete": + return ( +
+ + onUpdate({ targetTable: v })} + /> +
+ ); + + case "cart-save": + return ( +
+ + onUpdate({ cartScreenId: e.target.value })} + placeholder="비워두면 이동 없이 저장만" + className="h-7 text-xs" + /> +
+ ); + + case "modal-open": + return ( +
+
+ + +
+ {task.modalMode === "screen-ref" && ( +
+ + onUpdate({ modalScreenId: e.target.value })} + placeholder="화면 ID" + className="h-7 text-xs" + /> +
+ )} +
+ + onUpdate({ modalTitle: e.target.value })} + placeholder="모달 제목 (선택)" + className="h-7 text-xs" + /> +
+ {task.modalMode === "fullscreen" && designerCtx && ( +
+ {task.modalScreenId ? ( + + ) : ( + + )} +
+ )} +
+ ); + + case "navigate": + return ( +
+ + onUpdate({ targetScreenId: e.target.value })} + placeholder="이동할 화면 ID" + className="h-7 text-xs" + /> +
+ ); + + case "api-call": + return ( +
+
+ + onUpdate({ apiEndpoint: e.target.value })} + placeholder="/api/..." + className="h-7 text-xs" + /> +
+
+ + +
+
+ ); + + case "custom-event": + return ( +
+ + onUpdate({ eventName: e.target.value })} + placeholder="예: data-saved, item-selected" + className="h-7 text-xs" + /> +
+ ); + + case "refresh": + case "close-modal": + return ( +

설정 불필요

+ ); + + default: + return null; + } +} + +// ======================================== +// 데이터 수정 작업 폼 (data-update 전용) +// ======================================== + +function DataUpdateTaskForm({ + task, + onUpdate, + tables, + columns, +}: { + task: ButtonTask; + onUpdate: (partial: Partial) => void; + tables: TableInfo[]; + columns: ColumnInfo[]; +}) { + const conditions = task.conditionalValue?.conditions ?? []; + const defaultValue = task.conditionalValue?.defaultValue ?? ""; + + const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => { + const next = [...conditions]; + next[cIdx] = { ...next[cIdx], ...partial }; + onUpdate({ conditionalValue: { conditions: next, defaultValue } }); + }; + + const removeCondition = (cIdx: number) => { + const next = [...conditions]; + next.splice(cIdx, 1); + onUpdate({ conditionalValue: { conditions: next, defaultValue } }); + }; + + const addCondition = () => { + onUpdate({ + conditionalValue: { + conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }], + defaultValue, + }, + }); + }; + + return ( +
+ {/* 대상 테이블 */} +
+ + onUpdate({ targetTable: v, targetColumn: "" })} + /> +
+ + {/* 변경 컬럼 */} + {task.targetTable && ( +
+ + onUpdate({ targetColumn: v })} + /> +
)} - {/* 후속 액션 */} - - - onUpdate({ ...config, followUpActions: actions }) - } - /> + {/* 연산 타입 */} + {task.targetColumn && ( + <> +
+ + +
+ + {/* 값 출처 (conditional/db-conditional이 아닐 때) */} + {task.operationType !== "conditional" && task.operationType !== "db-conditional" && ( +
+ + +
+ )} + + {/* 고정값 입력 */} + {task.valueSource === "fixed" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && ( + onUpdate({ fixedValue: e.target.value })} + className="h-7 text-xs" + placeholder="변경할 값" + /> + )} + + {/* 연결 데이터 필드명 */} + {task.valueSource === "linked" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && ( + onUpdate({ sourceField: e.target.value })} + className="h-7 text-xs" + placeholder="연결 필드명 (예: qty)" + /> + )} + + {/* DB 컬럼 비교 조건부 설정 */} + {task.operationType === "db-conditional" && ( +
+

DB에서 컬럼 A와 컬럼 B를 비교하여 값을 판정합니다

+
+ onUpdate({ compareColumn: v })} placeholder="비교 컬럼 A" /> + + onUpdate({ compareWith: v })} placeholder="비교 컬럼 B" /> +
+
+ 참 -> + onUpdate({ dbThenValue: e.target.value })} className="h-7 flex-1 text-[10px]" placeholder="예: 입고완료" /> +
+
+ 거짓 -> + onUpdate({ dbElseValue: e.target.value })} className="h-7 flex-1 text-[10px]" placeholder="예: 부분입고" /> +
+
+ )} + + {/* 조건부 값 설정 */} + {task.operationType === "conditional" && ( +
+ {conditions.map((cond, cIdx) => ( +
+
+ 만약 + updateCondition(cIdx, { whenColumn: v })} placeholder="컬럼" /> + + updateCondition(cIdx, { whenValue: e.target.value })} className="h-7 w-16 text-[10px]" placeholder="값" /> + +
+
+ 이면 -> + updateCondition(cIdx, { thenValue: e.target.value })} className="h-7 text-[10px]" placeholder="변경할 값" /> +
+
+ ))} + +
+ 그 외 -> + onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })} + className="h-7 text-[10px]" + placeholder="기본값" + /> +
+
+ )} + + {/* 조회 키 */} +
+
+ + +
+ {task.lookupMode === "manual" && ( +
+ + -> + onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" /> +
+ )} +
+ + )}
); } 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 0c599aab..d9e38285 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -702,11 +702,13 @@ export function PopCardListComponent({ } : null; + const cartChanges = cart.isDirty ? cart.getChanges() : undefined; + const response: CollectedDataResponse = { requestId: request?.requestId ?? "", componentId: componentId, componentType: "pop-card-list", - data: { items: selectedItems }, + data: { items: selectedItems, cartChanges }, mapping, }; @@ -714,7 +716,7 @@ export function PopCardListComponent({ } ); return unsub; - }, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]); + }, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]); // 장바구니 목록 모드: 선택 항목 이벤트 발행 useEffect(() => { From 85bf4882a8ea7c4da5eb40009a1a83a1123e4cd7 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 5 Mar 2026 18:00:17 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(pop-card-list):=20=EB=AF=B8=EC=9E=85?= =?UTF-8?q?=EA=B3=A0=20formula=20=ED=95=84=EB=93=9C=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EA=B0=92=20=EC=97=B0=EB=8F=99=20=EB=B3=B5=EC=9B=90=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20UI=EC=97=90=EC=84=9C=20formulaRightType=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=EC=9D=84=20"input"=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=98=EC=A7=80=EB=A7=8C=20DB=EC=97=90=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EC=A7=80=20=EC=95=8A=EC=95=84,=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=8B=9C=20undefined=20=3D=3D=3D=20"input?= =?UTF-8?q?"=EC=9D=B4=20false=EA=B0=80=20=EB=90=98=EC=96=B4=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=ED=95=84=EB=93=9C=20=EC=97=B0=EB=8F=99=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=91=EB=8F=99=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20-=20FieldRow:=20(field.formulaRightType=20||=20"in?= =?UTF-8?q?put")=20=3D=3D=3D=20"input"=EC=9C=BC=EB=A1=9C=20=20=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20fallback=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop-components/pop-card-list/PopCardListComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d9e38285..c3bfb661 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -1307,7 +1307,7 @@ function FieldRow({ // 구조화된 수식 우선 if (field.formulaLeft && field.formulaOperator) { - const rightVal = field.formulaRightType === "input" + const rightVal = (field.formulaRightType || "input") === "input" ? (inputValue ?? 0) : Number(row[field.formulaRight || ""] ?? 0); const leftVal = Number(row[field.formulaLeft] ?? 0); From 7a9a705f19709464ff9960c995187c179b0b63e6 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 5 Mar 2026 18:34:45 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat(pop-card-list):=20=ED=8F=AC=EC=9E=A5?= =?UTF-8?q?=20=EC=9A=94=EC=95=BD=20=EB=B0=94=20UI=20+=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20flex=20column=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=ED=8F=AC=EC=9E=A5=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=EC=B9=B4=EB=93=9C=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=EC=97=90=20=ED=8F=AC=EC=9E=A5=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=EC=9D=84=20=ED=91=9C=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=EA=B0=80=20?= =?UTF-8?q?=ED=8F=AC=EC=9E=A5=20=EA=B3=84=EC=82=B0=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=A5=BC=20=EC=A6=89=EC=8B=9C=20=ED=99=95=EC=9D=B8=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=ED=95=9C=EB=8B=A4.?= =?UTF-8?q?=20-=20=EC=B9=B4=EB=93=9C=20=ED=95=98=EB=8B=A8=EC=97=90=20?= =?UTF-8?q?=ED=8F=AC=EC=9E=A5=20=EC=9A=94=EC=95=BD=20=EB=B0=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(emerald=20=ED=85=8C=EB=A7=88,=20=ED=8F=AC=EC=9E=A5?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B1=83=EC=A7=80)=20-=20height(=EA=B3=A0?= =?UTF-8?q?=EC=A0=95)=20->=20minHeight(=EC=9C=A0=EB=8F=99)=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B9=B4=EB=93=9C=20=EC=9E=90=EC=97=B0=20=EC=84=B1?= =?UTF-8?q?=EC=9E=A5=20=ED=97=88=EC=9A=A9=20-=20gridAutoRows=EB=A5=BC=20mi?= =?UTF-8?q?nmax(=EB=86=92=EC=9D=B4,=20auto)=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20(=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=85=80=EB=8F=84=20?= =?UTF-8?q?=EC=84=B1=EC=9E=A5)=20-=20=EC=B9=B4=EB=93=9C=20flex=20flex-col?= =?UTF-8?q?=20+=20=EB=B3=B8=EB=AC=B8=20flex-1=20overflow-hidden=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20-=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=98=81=EC=97=AD=20justify-center=20->?= =?UTF-8?q?=20justify-start=20(=EC=9C=84=EC=AA=BD=20=EC=A0=95=EB=A0=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop-card-list/PopCardListComponent.tsx | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) 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 c3bfb661..f6a1c5c3 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -730,14 +730,13 @@ export function PopCardListComponent({ gap: `${scaled.gap}px`, ...(isHorizontalMode ? { - gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`, + gridTemplateRows: `repeat(${gridRows}, minmax(${scaled.cardHeight}px, auto))`, gridAutoFlow: "column", gridAutoColumns: `${scaled.cardWidth}px`, } : { - // 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지 gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, - gridAutoRows: `${scaled.cardHeight}px`, + gridAutoRows: `minmax(${scaled.cardHeight}px, auto)`, }), }; @@ -1008,9 +1007,10 @@ function Card({ } }, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]); + const hasPackageEntries = packageEntries.length > 0; + const cardStyle: React.CSSProperties = { - height: `${scaled.cardHeight}px`, - overflow: "hidden", + minHeight: `${scaled.cardHeight}px`, }; const headerStyle: React.CSSProperties = { @@ -1116,7 +1116,7 @@ function Card({ return (
+
{/* 이미지 (왼쪽) */} {image?.enabled && (
@@ -1199,7 +1199,7 @@ function Card({ {/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */} {(inputField?.enabled || cartAction || isCartListMode) && (
{/* 수량 버튼 (입력 필드 ON일 때만) */} @@ -1268,6 +1268,37 @@ function Card({ )}
+ {/* 포장 요약 바: 본문 아래에 표시 */} + {hasPackageEntries && ( +
+ {packageEntries.map((entry, idx) => ( +
+
+ + 포장완료 + + + + {entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit} + +
+ + = {entry.totalQuantity.toLocaleString()}{inputField?.unit || "EA"} + +
+ ))} +
+ )} + {inputField?.enabled && ( Date: Thu, 5 Mar 2026 18:54:29 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat(pop):=20=EC=84=A4=EC=A0=95=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=95=84=EC=BD=94=EB=94=94=EC=96=B8=20=EC=A0=91?= =?UTF-8?q?=EA=B8=B0/=ED=8E=BC=EC=B9=98=EA=B8=B0=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=EC=84=B1=20+=20sessionStorage=20=EC=83=81=ED=83=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=EC=9D=84=20?= =?UTF-8?q?=EC=97=B4=20=EB=95=8C=20=EC=84=B9=EC=85=98=EC=9D=B4=20=EC=9D=BC?= =?UTF-8?q?=EB=B6=80=EB=8A=94=20=ED=8E=BC=EC=B3=90=EC=A0=B8=20=EC=9E=88?= =?UTF-8?q?=EA=B3=A0=20=EC=9D=BC=EB=B6=80=EB=8A=94=20=EC=A0=91=ED=98=80=20?= =?UTF-8?q?=EC=9E=88=EC=96=B4=20=EC=9D=BC=EA=B4=80=EC=84=B1=EC=9D=B4=20?= =?UTF-8?q?=EC=97=86=EB=8D=98=20UX=EB=A5=BC=20=EA=B0=9C=EC=84=A0=ED=95=98?= =?UTF-8?q?=EA=B3=A0,=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20=ED=8E=BC?= =?UTF-8?q?=EC=B9=9C=20=EC=84=B9=EC=85=98=EC=9D=84=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=EA=B8=B0=EC=96=B5?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20-=20useCollapsibleSections=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=ED=9B=85=20=EC=83=9D=EC=84=B1=20(sessionS?= =?UTF-8?q?torage=20=EA=B8=B0=EB=B0=98,=20=EC=B4=88=EA=B8=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=EC=A0=91=ED=9E=98)=20-=20PopCardListConfig:=20Coll?= =?UTF-8?q?apsibleSection=EC=97=90=20sectionKey/sections=20prop=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9=20-=20PopFieldConfig:?= =?UTF-8?q?=20SaveTabContent=205=EA=B0=9C=20=EA=B3=A0=EC=A0=95=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=ED=9B=85=20=EC=A0=81=EC=9A=A9,=20=20=20SectionEdit?= =?UTF-8?q?or=20=EC=B4=88=EA=B8=B0=EA=B0=92=20=EC=A0=91=ED=9E=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-=20PopDashboardConfig:=20Page?= =?UTF-8?q?Editor=20=EC=B4=88=EA=B8=B0=EA=B0=92=20=EC=A0=91=ED=9E=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/hooks/pop/index.ts | 3 + frontend/hooks/pop/useCollapsibleSections.ts | 58 +++++++++++++++++++ .../pop-card-list/PopCardListConfig.tsx | 50 +++++++++++----- .../pop-dashboard/PopDashboardConfig.tsx | 2 +- .../pop-field/PopFieldConfig.tsx | 58 +++++++++---------- 5 files changed, 125 insertions(+), 46 deletions(-) create mode 100644 frontend/hooks/pop/useCollapsibleSections.ts diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts index 7ae7e953..893f09e9 100644 --- a/frontend/hooks/pop/index.ts +++ b/frontend/hooks/pop/index.ts @@ -26,5 +26,8 @@ export { useConnectionResolver } from "./useConnectionResolver"; export { useCartSync } from "./useCartSync"; export type { UseCartSyncReturn } from "./useCartSync"; +// 설정 패널 접기/펼치기 상태 관리 +export { useCollapsibleSections } from "./useCollapsibleSections"; + // SQL 빌더 유틸 (고급 사용 시) export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; diff --git a/frontend/hooks/pop/useCollapsibleSections.ts b/frontend/hooks/pop/useCollapsibleSections.ts new file mode 100644 index 00000000..e5636d65 --- /dev/null +++ b/frontend/hooks/pop/useCollapsibleSections.ts @@ -0,0 +1,58 @@ +import { useState, useCallback, useRef } from "react"; + +/** + * 설정 패널 접기/펼치기 상태를 sessionStorage로 기억하는 훅 + * + * - 초기 상태: 모든 섹션 접힘 + * - 사용자가 펼친 섹션은 같은 탭 세션 내에서 기억 + * - 탭 닫으면 초기화 + * + * @param storageKey sessionStorage 키 (예: "pop-card-list") + */ +export function useCollapsibleSections(storageKey: string) { + const fullKey = `pop-config-sections-${storageKey}`; + + const [openSections, setOpenSections] = useState>(() => { + if (typeof window === "undefined") return new Set(); + try { + const saved = sessionStorage.getItem(fullKey); + if (saved) return new Set(JSON.parse(saved)); + } catch {} + return new Set(); + }); + + const openSectionsRef = useRef(openSections); + openSectionsRef.current = openSections; + + const persist = useCallback( + (next: Set) => { + try { + sessionStorage.setItem(fullKey, JSON.stringify([...next])); + } catch {} + }, + [fullKey], + ); + + const isOpen = useCallback( + (key: string) => openSectionsRef.current.has(key), + [], + ); + + const toggle = useCallback( + (key: string) => { + setOpenSections((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + persist(next); + return next; + }); + }, + [persist], + ); + + return { isOpen, toggle }; +} 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 106ad796..6383974b 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -10,6 +10,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react"; +import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections"; import type { GridMode } from "@/components/pop/designer/types/pop-layout"; import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { Button } from "@/components/ui/button"; @@ -135,6 +136,7 @@ const COLOR_OPTIONS = [ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) { const [activeTab, setActiveTab] = useState<"basic" | "template">("basic"); + const sections = useCollapsibleSections("pop-card-list"); const cfg: PopCardListConfig = config || DEFAULT_CONFIG; @@ -184,6 +186,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC onUpdate={updateConfig} currentMode={currentMode} currentColSpan={currentColSpan} + sections={sections} /> )} {activeTab === "template" && ( @@ -195,7 +198,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
) : ( - + ) )}
@@ -205,16 +208,20 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC // ===== 기본 설정 탭 (테이블 + 레이아웃 통합) ===== +type SectionsApi = { isOpen: (key: string) => boolean; toggle: (key: string) => void }; + function BasicSettingsTab({ config, onUpdate, currentMode, currentColSpan, + sections, }: { config: PopCardListConfig; onUpdate: (partial: Partial) => void; currentMode?: GridMode; currentColSpan?: number; + sections: SectionsApi; }) { const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const [tables, setTables] = useState([]); @@ -321,7 +328,7 @@ function BasicSettingsTab({ return (
{/* 장바구니 목록 모드 */} - + onUpdate({ cartListMode })} @@ -330,7 +337,7 @@ function BasicSettingsTab({ {/* 테이블 선택 (장바구니 모드 시 숨김) */} {!isCartListMode && ( - +
@@ -365,7 +372,9 @@ function BasicSettingsTab({ {/* 조인 설정 (장바구니 모드 시 숨김) */} {!isCartListMode && dataSource.tableName && ( 0 ? `${dataSource.joins.length}개` @@ -383,7 +392,9 @@ function BasicSettingsTab({ {/* 정렬 기준 (장바구니 모드 시 숨김) */} {!isCartListMode && dataSource.tableName && ( 0 ? `${dataSource.filters.length}개` @@ -421,7 +434,9 @@ function BasicSettingsTab({ {/* 저장 매핑 (장바구니 모드일 때만) */} {isCartListMode && ( 0 ? `${config.saveMapping.mappings.length}개` @@ -437,7 +452,7 @@ function BasicSettingsTab({ )} {/* 레이아웃 설정 */} - +
{modeLabel && (
@@ -526,9 +541,11 @@ function BasicSettingsTab({ function CardTemplateTab({ config, onUpdate, + sections, }: { config: PopCardListConfig; onUpdate: (partial: Partial) => void; + sections: SectionsApi; }) { const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const template = config.cardTemplate || DEFAULT_TEMPLATE; @@ -634,7 +651,7 @@ function CardTemplateTab({ return (
{/* 헤더 설정 */} - + {/* 이미지 설정 */} - + {/* 입력 필드 설정 */} - + {/* 포장등록 설정 */} - + onUpdate({ packageConfig })} @@ -683,7 +701,7 @@ function CardTemplateTab({ {/* 담기 버튼 설정 */} - + onUpdate({ cartAction })} @@ -693,7 +711,7 @@ function CardTemplateTab({ {/* 반응형 표시 설정 */} - +