From b2b0b575df26da7e013603a76e0637ed2a633f43 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 5 Mar 2026 17:22:30 +0900 Subject: [PATCH] =?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(() => {