diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 84a8729c..d5e0ca4b 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,9 +1,5 @@ { "mcpServers": { - "agent-orchestrator": { - "command": "node", - "args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"] - }, "Framelink Figma MCP": { "command": "npx", "args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"] diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 1fbefea5..f5944bd0 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -112,6 +112,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 +import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 @@ -238,6 +239,7 @@ app.use("/api/table-management", tableManagementRoutes); app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 +app.use("/api/pop", popActionRoutes); // POP 액션 실행 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts new file mode 100644 index 00000000..24ef3af0 --- /dev/null +++ b/backend-node/src/routes/popActionRoutes.ts @@ -0,0 +1,280 @@ +import { Router, Request, Response } from "express"; +import { getPool } from "../database/db"; +import logger from "../utils/logger"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// SQL 인젝션 방지: 테이블명/컬럼명 패턴 검증 +const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +function isSafeIdentifier(name: string): boolean { + return SAFE_IDENTIFIER.test(name); +} + +interface MappingInfo { + targetTable: string; + columnMapping: Record; +} + +interface StatusConditionRule { + whenColumn: string; + operator: string; + whenValue: string; + thenValue: string; +} + +interface ConditionalValueRule { + conditions: StatusConditionRule[]; + defaultValue?: string; +} + +interface StatusChangeRuleBody { + targetTable: string; + targetColumn: string; + lookupMode?: "auto" | "manual"; + manualItemField?: string; + manualPkColumn?: string; + valueType: "fixed" | "conditional"; + fixedValue?: string; + conditionalValue?: ConditionalValueRule; + // 하위호환: 기존 형식 + value?: string; + condition?: string; +} + +interface ExecuteActionBody { + action: string; + data: { + items?: Record[]; + fieldValues?: Record; + }; + mappings?: { + cardList?: MappingInfo | null; + field?: MappingInfo | null; + }; + statusChanges?: StatusChangeRuleBody[]; +} + +function resolveStatusValue( + valueType: string, + fixedValue: string, + conditionalValue: ConditionalValueRule | undefined, + item: Record +): string { + if (valueType !== "conditional" || !conditionalValue) return fixedValue; + + for (const cond of conditionalValue.conditions) { + const actual = String(item[cond.whenColumn] ?? ""); + const expected = cond.whenValue; + let match = false; + + switch (cond.operator) { + case "=": match = actual === expected; break; + case "!=": match = actual !== expected; break; + case ">": match = parseFloat(actual) > parseFloat(expected); break; + case "<": match = parseFloat(actual) < parseFloat(expected); break; + case ">=": match = parseFloat(actual) >= parseFloat(expected); break; + case "<=": match = parseFloat(actual) <= parseFloat(expected); break; + default: match = actual === expected; + } + + if (match) return cond.thenValue; + } + + return conditionalValue.defaultValue ?? fixedValue; +} + +router.post("/execute-action", authenticateToken, async (req: Request, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = (req as any).user?.companyCode; + const userId = (req as any).user?.userId; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody; + const items = data?.items ?? []; + const fieldValues = data?.fieldValues ?? {}; + + logger.info("[pop/execute-action] 요청", { + action, + companyCode, + userId, + itemCount: items.length, + hasFieldValues: Object.keys(fieldValues).length > 0, + hasMappings: !!mappings, + statusChangeCount: statusChanges?.length ?? 0, + }); + + await client.query("BEGIN"); + + let processedCount = 0; + let insertedCount = 0; + + if (action === "inbound-confirm") { + // 1. 매핑 기반 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); + } + } + + if (columns.length > 1) { + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; + + logger.info("[pop/execute-action] INSERT 실행", { + table: cardMapping.targetTable, + columnCount: columns.length, + }); + + await client.query(sql, values); + insertedCount++; + } + } + } + + if ( + fieldMapping?.targetTable && + Object.keys(fieldMapping.columnMapping).length > 0 && + fieldMapping.targetTable !== cardMapping?.targetTable + ) { + if (!isSafeIdentifier(fieldMapping.targetTable)) { + throw new Error(`유효하지 않은 테이블명: ${fieldMapping.targetTable}`); + } + + const columns: string[] = ["company_code"]; + const values: unknown[] = [companyCode]; + + for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) { + if (!isSafeIdentifier(targetColumn)) continue; + columns.push(`"${targetColumn}"`); + values.push(fieldValues[sourceField] ?? null); + } + + if (columns.length > 1) { + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; + await client.query(sql, values); + } + } + + // 2. 상태 변경 규칙 실행 (설정 기반) + if (statusChanges && statusChanges.length > 0) { + for (const rule of statusChanges) { + if (!rule.targetTable || !rule.targetColumn) continue; + if (!isSafeIdentifier(rule.targetTable) || !isSafeIdentifier(rule.targetColumn)) { + logger.warn("[pop/execute-action] 유효하지 않은 식별자, 건너뜀", { table: rule.targetTable, column: rule.targetColumn }); + continue; + } + + const valueType = rule.valueType ?? "fixed"; + const fixedValue = rule.fixedValue ?? rule.value ?? ""; + const lookupMode = rule.lookupMode ?? "auto"; + + // 조회 키 결정: 아이템 필드(itemField) -> 대상 테이블 PK 컬럼(pkColumn) + let itemField: string; + let pkColumn: string; + + if (lookupMode === "manual" && rule.manualItemField && rule.manualPkColumn) { + if (!isSafeIdentifier(rule.manualPkColumn)) { + logger.warn("[pop/execute-action] 수동 PK 컬럼 유효하지 않음", { manualPkColumn: rule.manualPkColumn }); + continue; + } + itemField = rule.manualItemField; + pkColumn = rule.manualPkColumn; + logger.info("[pop/execute-action] 수동 조회 키", { itemField, pkColumn, table: rule.targetTable }); + } else if (rule.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`, + [rule.targetTable] + ); + pkColumn = pkResult.rows[0]?.attname || "id"; + } + + const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean); + if (lookupValues.length === 0) { + logger.warn("[pop/execute-action] 조회 키 값 없음, 건너뜀", { table: rule.targetTable, itemField }); + continue; + } + + if (valueType === "fixed") { + const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", "); + const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`; + await client.query(sql, [fixedValue, companyCode, ...lookupValues]); + processedCount += lookupValues.length; + } else { + for (let i = 0; i < lookupValues.length; i++) { + const item = items[i] ?? {}; + const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item); + await client.query( + `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + [resolvedValue, companyCode, lookupValues[i]] + ); + processedCount++; + } + } + + logger.info("[pop/execute-action] 상태 변경 실행", { + table: rule.targetTable, column: rule.targetColumn, lookupMode, itemField, pkColumn, count: lookupValues.length, + }); + } + } + } + + await client.query("COMMIT"); + + logger.info("[pop/execute-action] 완료", { + action, + companyCode, + processedCount, + insertedCount, + }); + + return res.json({ + success: true, + message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`, + data: { processedCount, insertedCount }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("[pop/execute-action] 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "처리 중 오류가 발생했습니다.", + }); + } finally { + client.release(); + } +}); + +export default router; diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 725b4f3f..52c8102f 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -36,6 +36,15 @@ interface ConnectionEditorProps { onRemoveConnection?: (connectionId: string) => void; } +// ======================================== +// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단 +// ======================================== + +function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean { + if (!meta?.sendable) return false; + return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value"); +} + // ======================================== // ConnectionEditor // ======================================== @@ -75,6 +84,8 @@ export default function ConnectionEditor({ ); } + const isFilterSource = hasFilterSendable(meta); + return (
{hasSendable && ( @@ -83,6 +94,7 @@ export default function ConnectionEditor({ meta={meta!} allComponents={allComponents} outgoing={outgoing} + isFilterSource={isFilterSource} onAddConnection={onAddConnection} onUpdateConnection={onUpdateConnection} onRemoveConnection={onRemoveConnection} @@ -92,7 +104,6 @@ export default function ConnectionEditor({ {hasReceivable && ( @@ -105,7 +116,6 @@ export default function ConnectionEditor({ // 대상 컴포넌트에서 정보 추출 // ======================================== -/** 화면에 표시 중인 컬럼만 추출 */ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] { if (!comp?.config) return []; const cfg = comp.config as Record; @@ -126,7 +136,6 @@ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): stri return cols; } -/** 대상 컴포넌트의 데이터소스 테이블명 추출 */ function extractTableName(comp: PopComponentDefinitionV5 | undefined): string { if (!comp?.config) return ""; const cfg = comp.config as Record; @@ -143,6 +152,7 @@ interface SendSectionProps { meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; outgoing: PopDataConnection[]; + isFilterSource: boolean; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; onRemoveConnection?: (connectionId: string) => void; @@ -153,6 +163,7 @@ function SendSection({ meta, allComponents, outgoing, + isFilterSource, onAddConnection, onUpdateConnection, onRemoveConnection, @@ -163,29 +174,42 @@ function SendSection({
- {/* 기존 연결 목록 */} {outgoing.map((conn) => (
{editingId === conn.id ? ( - { - onUpdateConnection?.(conn.id, data); - setEditingId(null); - }} - onCancel={() => setEditingId(null)} - submitLabel="수정" - /> + isFilterSource ? ( + { + onUpdateConnection?.(conn.id, data); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + submitLabel="수정" + /> + ) : ( + { + onUpdateConnection?.(conn.id, data); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + submitLabel="수정" + /> + ) ) : (
- {conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`} + {conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
))} - {/* 새 연결 추가 */} - onAddConnection?.(data)} - submitLabel="연결 추가" - /> + {isFilterSource ? ( + onAddConnection?.(data)} + submitLabel="연결 추가" + /> + ) : ( + onAddConnection?.(data)} + submitLabel="연결 추가" + /> + )}
); } // ======================================== -// 연결 폼 (추가/수정 공용) +// 단순 연결 폼 (이벤트 타입: "어디로" 1개만) // ======================================== -interface ConnectionFormProps { +interface SimpleConnectionFormProps { + component: PopComponentDefinitionV5; + allComponents: PopComponentDefinitionV5[]; + initial?: PopDataConnection; + onSubmit: (data: Omit) => void; + onCancel?: () => void; + submitLabel: string; +} + +function SimpleConnectionForm({ + component, + allComponents, + initial, + onSubmit, + onCancel, + submitLabel, +}: SimpleConnectionFormProps) { + const [selectedTargetId, setSelectedTargetId] = React.useState( + initial?.targetComponent || "" + ); + + const targetCandidates = allComponents.filter((c) => { + if (c.id === component.id) return false; + const reg = PopComponentRegistry.getComponent(c.type); + return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; + }); + + const handleSubmit = () => { + if (!selectedTargetId) return; + + const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const srcLabel = component.label || component.id; + const tgtLabel = targetComp?.label || targetComp?.id || "?"; + + onSubmit({ + sourceComponent: component.id, + sourceField: "", + sourceOutput: "_auto", + targetComponent: selectedTargetId, + targetField: "", + targetInput: "_auto", + label: `${srcLabel} → ${tgtLabel}`, + }); + + if (!initial) { + setSelectedTargetId(""); + } + }; + + return ( +
+ {onCancel && ( +
+

연결 수정

+ +
+ )} + {!onCancel && ( +

새 연결 추가

+ )} + +
+ 어디로? + +
+ + +
+ ); +} + +// ======================================== +// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지) +// ======================================== + +interface FilterConnectionFormProps { component: PopComponentDefinitionV5; meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; @@ -232,7 +364,7 @@ interface ConnectionFormProps { submitLabel: string; } -function ConnectionForm({ +function FilterConnectionForm({ component, meta, allComponents, @@ -240,7 +372,7 @@ function ConnectionForm({ onSubmit, onCancel, submitLabel, -}: ConnectionFormProps) { +}: FilterConnectionFormProps) { const [selectedOutput, setSelectedOutput] = React.useState( initial?.sourceOutput || meta.sendable[0]?.key || "" ); @@ -272,32 +404,26 @@ function ConnectionForm({ ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta : null; - // 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭 React.useEffect(() => { if (!selectedOutput || !targetMeta?.receivable?.length) return; - // 이미 선택된 값이 있으면 건드리지 않음 if (selectedTargetInput) return; const receivables = targetMeta.receivable; - // 1) 같은 key가 있으면 자동 매칭 const exactMatch = receivables.find((r) => r.key === selectedOutput); if (exactMatch) { setSelectedTargetInput(exactMatch.key); return; } - // 2) receivable이 1개뿐이면 자동 선택 if (receivables.length === 1) { setSelectedTargetInput(receivables[0].key); } }, [selectedOutput, targetMeta, selectedTargetInput]); - // 화면에 표시 중인 컬럼 const displayColumns = React.useMemo( () => extractDisplayColumns(targetComp || undefined), [targetComp] ); - // DB 테이블 전체 컬럼 (비동기 조회) const tableName = React.useMemo( () => extractTableName(targetComp || undefined), [targetComp] @@ -324,7 +450,6 @@ function ConnectionForm({ return () => { cancelled = true; }; }, [tableName]); - // 표시 컬럼과 데이터 전용 컬럼 분리 const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); const dataOnlyColumns = React.useMemo( () => allDbColumns.filter((c) => !displaySet.has(c)), @@ -388,7 +513,6 @@ function ConnectionForm({

새 연결 추가

)} - {/* 보내는 값 */}
보내는 값
- {/* 받는 컴포넌트 */}
받는 컴포넌트 setFilterMode(v)}> @@ -540,7 +658,6 @@ function ConnectionForm({
)} - {/* 제출 버튼 */} +
+ ); +} + +function SingleRuleEditor({ + rule, + idx, + tables, + columns, + onLoadColumns, + onUpdate, + onRemove, +}: { + rule: StatusChangeRule; + idx: number; + tables: TableInfo[]; + columns: ColumnInfo[]; + onLoadColumns: (tableName: string) => void; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + useEffect(() => { + if (rule.targetTable) onLoadColumns(rule.targetTable); + }, [rule.targetTable]); // eslint-disable-line react-hooks/exhaustive-deps + + const conditions = rule.conditionalValue?.conditions ?? []; + const defaultValue = rule.conditionalValue?.defaultValue ?? ""; + + const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => { + const next = [...conditions]; + next[cIdx] = { ...next[cIdx], ...partial }; + onUpdate({ + conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue }, + }); + }; + + const removeCondition = (cIdx: number) => { + const next = [...conditions]; + next.splice(cIdx, 1); + onUpdate({ + conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue }, + }); + }; + + const addCondition = () => { + onUpdate({ + conditionalValue: { + ...rule.conditionalValue, + conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }], + defaultValue, + }, + }); + }; + + return ( +
+
+ 규칙 {idx + 1} + +
+ + {/* 대상 테이블 */} +
+ + onUpdate({ targetTable: v, targetColumn: "" })} + /> +
+ + {/* 변경 컬럼 */} + {rule.targetTable && ( +
+ + onUpdate({ targetColumn: v })} + /> +
+ )} + + {/* 조회 키 */} + {rule.targetColumn && ( +
+
+ + +
+ {(rule.lookupMode ?? "auto") === "auto" ? ( +

+ {rule.targetTable === "cart_items" + ? `카드 항목.__cart_id → ${rule.targetTable}.id` + : `카드 항목.row_key → ${rule.targetTable}.${columns.find(c => c.isPrimaryKey)?.name ?? "PK(조회중)"}`} +

+ ) : ( +
+ + + onUpdate({ manualPkColumn: v })} + placeholder="대상 PK 컬럼" + /> +
+ )} +
+ )} + + {/* 변경 값 타입 */} + {rule.targetColumn && ( + <> +
+ +
+ + +
+
+ + {/* 고정값 */} + {rule.valueType === "fixed" && ( +
+ onUpdate({ fixedValue: e.target.value })} + className="h-7 text-xs" + placeholder="변경할 값 입력" + /> +
+ )} + + {/* 조건부 */} + {rule.valueType === "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: { ...rule.conditionalValue, conditions, defaultValue: e.target.value }, + }) + } + className="h-7 text-[10px]" + placeholder="기본값" + /> +
+
+ )} + + + )} +
+ ); +} + // 레지스트리 등록 PopComponentRegistry.registerComponent({ id: "pop-button", @@ -1486,11 +1964,14 @@ PopComponentRegistry.registerComponent({ } as PopButtonConfig, connectionMeta: { sendable: [ - { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" }, + { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" }, + { key: "collect_data", label: "데이터 수집 요청", type: "event", category: "event", description: "연결된 컴포넌트에 데이터+매핑 수집 요청" }, + { key: "action_completed", label: "액션 완료", type: "event", category: "event", description: "확정/저장 완료 후 결과 전달" }, ], receivable: [ - { key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" }, - { key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" }, + { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" }, + { key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" }, ], }, touchOptimized: true, 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 585bea94..4ba1abc1 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -29,7 +29,8 @@ import type { CardPresetSpec, CartItem, PackageEntry, - CartListModeConfig, + CollectDataRequest, + CollectedDataResponse, } from "../types"; import { DEFAULT_CARD_IMAGE, @@ -183,27 +184,34 @@ export function PopCardListComponent({ currentColSpan, onRequestResize, }: PopCardListComponentProps) { - const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal"; - const maxGridColumns = config?.gridColumns || 2; - const configGridRows = config?.gridRows || 3; - const dataSource = config?.dataSource; - const template = config?.cardTemplate; - const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); - // 장바구니 DB 동기화 - const sourceTableName = dataSource?.tableName || ""; - const cartType = config?.cartAction?.cartType; - const cart = useCartSync(screenId || "", sourceTableName, cartType); - // 장바구니 목록 모드 플래그 및 상태 const isCartListMode = config?.cartListMode?.enabled === true; - const [inheritedTemplate, setInheritedTemplate] = useState(null); + const [inheritedConfig, setInheritedConfig] = useState | null>(null); const [selectedKeys, setSelectedKeys] = useState>(new Set()); - // 장바구니 목록 모드에서는 원본 화면의 템플릿을 사용, 폴백으로 자체 설정 - const effectiveTemplate = (isCartListMode && inheritedTemplate) ? inheritedTemplate : template; + // 장바구니 목록 모드: 원본 화면의 설정 전체를 병합 (cardTemplate, inputField, packageConfig, cardSize 등) + const effectiveConfig = useMemo(() => { + if (!isCartListMode || !inheritedConfig) return config; + return { + ...config, + ...inheritedConfig, + cartListMode: config?.cartListMode, + dataSource: config?.dataSource, + } as PopCardListConfig; + }, [config, inheritedConfig, isCartListMode]); + + const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal"; + const maxGridColumns = effectiveConfig?.gridColumns || 2; + const configGridRows = effectiveConfig?.gridRows || 3; + const dataSource = effectiveConfig?.dataSource; + const effectiveTemplate = effectiveConfig?.cardTemplate; + + // 장바구니 DB 동기화 (장바구니 목록 모드에서는 비활성화) + const sourceTableName = (!isCartListMode && dataSource?.tableName) || ""; + const cart = useCartSync(screenId || "", sourceTableName); // 데이터 상태 const [rows, setRows] = useState([]); @@ -311,7 +319,7 @@ export function PopCardListComponent({ const missingImageCountRef = useRef(0); - const cardSizeKey = config?.cardSize || "large"; + const cardSizeKey = effectiveConfig?.cardSize || "large"; const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열 @@ -509,36 +517,26 @@ export function PopCardListComponent({ setLoading(true); setError(null); try { - // 원본 화면 레이아웃에서 cardTemplate 상속 - if (cartListMode.sourceScreenId) { - try { - const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); - const componentsMap = layoutJson?.components || {}; - const componentList = Object.values(componentsMap) as any[]; - // sourceComponentId > cartType > 첫 번째 pop-card-list 순으로 매칭 - const matched = cartListMode.sourceComponentId - ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) - : cartListMode.cartType - ? componentList.find( - (c: any) => - c.type === "pop-card-list" && - c.config?.cartAction?.cartType === cartListMode.cartType - ) - : componentList.find((c: any) => c.type === "pop-card-list"); - if (matched?.config?.cardTemplate) { - setInheritedTemplate(matched.config.cardTemplate); - } - } catch { - // 레이아웃 로드 실패 시 config.cardTemplate 폴백 + // 원본 화면 레이아웃에서 설정 전체 상속 (cardTemplate, inputField, packageConfig, cardSize 등) + try { + const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + const matched = cartListMode.sourceComponentId + ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) + : componentList.find((c: any) => c.type === "pop-card-list"); + if (matched?.config) { + setInheritedConfig(matched.config); } + } catch { + // 레이아웃 로드 실패 시 자체 config 폴백 } - // cart_items 조회 (cartType이 있으면 필터, 없으면 전체) const cartFilters: Record = { status: cartListMode.statusFilter || "in_cart", }; - if (cartListMode.cartType) { - cartFilters.cart_type = cartListMode.cartType; + if (cartListMode.sourceScreenId) { + cartFilters.screen_id = String(cartListMode.sourceScreenId); } const result = await dataApi.getTableData("cart_items", { size: 500, @@ -572,10 +570,11 @@ export function PopCardListComponent({ missingImageCountRef.current = 0; try { + // 서버에는 = 연산자 필터만 전달, 나머지는 클라이언트 후처리 const filters: Record = {}; if (dataSource.filters && dataSource.filters.length > 0) { dataSource.filters.forEach((f) => { - if (f.column && f.value) { + if (f.column && f.value && (!f.operator || f.operator === "=")) { filters[f.column] = f.value; } }); @@ -604,7 +603,31 @@ export function PopCardListComponent({ filters: Object.keys(filters).length > 0 ? filters : undefined, }); - setRows(result.data || []); + let fetchedRows = result.data || []; + + // 서버에서 처리하지 못한 연산자 필터 클라이언트 후처리 + const clientFilters = (dataSource.filters || []).filter( + (f) => f.column && f.value && f.operator && f.operator !== "=" + ); + if (clientFilters.length > 0) { + fetchedRows = fetchedRows.filter((row) => + clientFilters.every((f) => { + const cellVal = row[f.column]; + const filterVal = f.value; + switch (f.operator) { + case "!=": return String(cellVal ?? "") !== filterVal; + case ">": return Number(cellVal) > Number(filterVal); + case ">=": return Number(cellVal) >= Number(filterVal); + case "<": return Number(cellVal) < Number(filterVal); + case "<=": return Number(cellVal) <= Number(filterVal); + case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase()); + default: return true; + } + }) + ); + } + + setRows(fetchedRows); } catch (err) { const message = err instanceof Error ? err.message : "데이터 조회 실패"; setError(message); @@ -654,10 +677,49 @@ export function PopCardListComponent({ })); }, []); + // 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → 선택 항목 + 매핑 응답 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__collect_data`, + (payload: unknown) => { + const request = (payload as Record)?.value as CollectDataRequest | undefined; + + const selectedItems = isCartListMode + ? filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? ""))) + : rows; + + // CardListSaveMapping → SaveMapping 변환 + const sm = config?.saveMapping; + const mapping = sm?.targetTable && sm.mappings.length > 0 + ? { + targetTable: sm.targetTable, + columnMapping: Object.fromEntries( + sm.mappings + .filter(m => m.sourceField && m.targetColumn) + .map(m => [m.sourceField, m.targetColumn]) + ), + } + : null; + + const response: CollectedDataResponse = { + requestId: request?.requestId ?? "", + componentId: componentId, + componentType: "pop-card-list", + data: { items: selectedItems }, + mapping, + }; + + publish(`__comp_output__${componentId}__collected_data`, response); + } + ); + return unsub; + }, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]); + // 장바구니 목록 모드: 선택 항목 이벤트 발행 useEffect(() => { if (!componentId || !isCartListMode) return; - const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id))); + const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? ""))); publish(`__comp_output__${componentId}__selected_items`, selectedItems); }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); @@ -720,15 +782,15 @@ export function PopCardListComponent({
0} + checked={selectedKeys.size === filteredRows.length && filteredRows.length > 0} onChange={(e) => { if (e.target.checked) { - setSelectedKeys(new Set(displayCards.map(r => String(r.__cart_id)))); + setSelectedKeys(new Set(filteredRows.map(r => String(r.__cart_id ?? "")))); } else { setSelectedKeys(new Set()); } }} - className="h-4 w-4 rounded border-gray-300" + className="h-4 w-4 rounded border-input" /> {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"} @@ -757,19 +819,20 @@ export function PopCardListComponent({ row={row} template={effectiveTemplate} scaled={scaled} - inputField={config?.inputField} - packageConfig={config?.packageConfig} - cartAction={config?.cartAction} + inputField={effectiveConfig?.inputField} + packageConfig={effectiveConfig?.packageConfig} + cartAction={effectiveConfig?.cartAction} publish={publish} router={router} onSelect={handleCardSelect} cart={cart} - codeFieldName={effectiveTemplate?.header?.codeField} + keyColumnName={effectiveConfig?.cartAction?.keyColumn || "id"} parentComponentId={componentId} isCartListMode={isCartListMode} - isSelected={selectedKeys.has(String(row.__cart_id))} + isSelected={selectedKeys.has(String(row.__cart_id ?? ""))} onToggleSelect={() => { - const cartId = String(row.__cart_id); + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; setSelectedKeys(prev => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); @@ -859,7 +922,7 @@ function Card({ router, onSelect, cart, - codeFieldName, + keyColumnName, parentComponentId, isCartListMode, isSelected, @@ -877,7 +940,7 @@ function Card({ router: ReturnType; onSelect?: (row: RowData) => void; cart: ReturnType; - codeFieldName?: string; + keyColumnName?: string; parentComponentId?: string; isCartListMode?: boolean; isSelected?: boolean; @@ -897,8 +960,7 @@ function Card({ const codeValue = header?.codeField ? row[header.codeField] : null; const titleValue = header?.titleField ? row[header.titleField] : null; - // 장바구니 상태: codeField 값을 rowKey로 사용 - const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : ""; + const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); @@ -1012,14 +1074,14 @@ function Card({ // 장바구니 목록 모드: 개별 삭제 const handleCartDelete = async (e: React.MouseEvent) => { e.stopPropagation(); - const cartId = String(row.__cart_id); + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; if (!cartId) return; const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?"); if (!ok) return; try { - await dataApi.deleteRecord("cart_items", cartId); + await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" }); onDeleteItem?.(cartId); } catch { toast.error("삭제에 실패했습니다."); @@ -1058,21 +1120,19 @@ function Card({ tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > - {/* 장바구니 목록 모드: 체크박스 */} - {isCartListMode && ( - { e.stopPropagation(); onToggleSelect?.(); }} - onClick={(e) => e.stopPropagation()} - className="absolute left-2 top-2 z-10 h-4 w-4 rounded border-gray-300" - /> - )} - {/* 헤더 영역 */} - {(codeValue !== null || titleValue !== null) && ( + {(codeValue !== null || titleValue !== null || isCartListMode) && (
+ {isCartListMode && ( + { e.stopPropagation(); onToggleSelect?.(); }} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 shrink-0 rounded border-input" + /> + )} {codeValue !== null && ( {inputValue.toLocaleString()} 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 696f4821..0e868711 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -9,7 +9,7 @@ */ import React, { useState, useEffect, useMemo } from "react"; -import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react"; 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"; @@ -25,8 +25,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; import type { PopCardListConfig, @@ -50,6 +48,8 @@ import type { CardResponsiveConfig, ResponsiveDisplayMode, CartListModeConfig, + CardListSaveMapping, + CardListSaveMappingEntry, } from "../types"; import { screenApi } from "@/lib/api/screen"; import { @@ -63,6 +63,7 @@ import { type TableInfo, type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; +import { TableCombobox } from "../pop-shared/TableCombobox"; // ===== 테이블별 그룹화된 컬럼 ===== @@ -399,6 +400,42 @@ function BasicSettingsTab({ )} + {/* 필터 기준 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( + 0 + ? `${dataSource.filters.length}개` + : undefined + } + > + + + )} + + {/* 저장 매핑 (장바구니 모드일 때만) */} + {isCartListMode && ( + 0 + ? `${config.saveMapping.mappings.length}개` + : undefined + } + > + onUpdate({ saveMapping })} + cartListMode={config.cartListMode} + /> + + )} + {/* 레이아웃 설정 */}
@@ -667,99 +704,7 @@ function CardTemplateTab({ ); } -// ===== 테이블 검색 Combobox ===== - -function TableCombobox({ - tables, - value, - onSelect, -}: { - tables: TableInfo[]; - value: string; - onSelect: (tableName: string) => void; -}) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - - const selectedLabel = useMemo(() => { - const found = tables.find((t) => t.tableName === value); - return found ? (found.displayName || found.tableName) : ""; - }, [tables, value]); - - const filtered = useMemo(() => { - if (!search) return tables; - const q = search.toLowerCase(); - return tables.filter( - (t) => - t.tableName.toLowerCase().includes(q) || - (t.displayName && t.displayName.toLowerCase().includes(q)) - ); - }, [tables, search]); - - return ( - - - - - - - - - - 검색 결과가 없습니다. - - - {filtered.map((table) => ( - { - onSelect(table.tableName); - setOpen(false); - setSearch(""); - }} - className="text-xs" - > - -
- {table.displayName || table.tableName} - {table.displayName && ( - - {table.tableName} - - )} -
-
- ))} -
-
-
-
-
- ); -} +// TableCombobox: pop-shared/TableCombobox.tsx에서 import // ===== 테이블별 그룹화된 컬럼 셀렉트 ===== @@ -867,7 +812,6 @@ function CollapsibleSection({ interface SourceCardListInfo { componentId: string; label: string; - cartType: string; } function CartListModeSection({ @@ -915,8 +859,7 @@ function CartListModeSection({ .filter((c: any) => c.type === "pop-card-list") .map((c: any) => ({ componentId: c.id || "", - label: c.label || c.config?.cartAction?.cartType || "카드 목록", - cartType: c.config?.cartAction?.cartType || "", + label: c.label || "카드 목록", })); setSourceCardLists(cardLists); }) @@ -928,23 +871,18 @@ function CartListModeSection({ const handleScreenChange = (val: string) => { const screenId = val === "__none__" ? undefined : Number(val); - onUpdate({ ...mode, sourceScreenId: screenId, cartType: undefined }); + onUpdate({ ...mode, sourceScreenId: screenId }); }; const handleComponentSelect = (val: string) => { if (val === "__none__") { - onUpdate({ ...mode, cartType: undefined, sourceComponentId: undefined }); + onUpdate({ ...mode, sourceComponentId: undefined }); return; } - const found = val.startsWith("__comp_") - ? sourceCardLists.find((c) => c.componentId === val.replace("__comp_", "")) - : sourceCardLists.find((c) => c.cartType === val); + const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val; + const found = sourceCardLists.find((c) => c.componentId === compId); if (found) { - onUpdate({ - ...mode, - sourceComponentId: found.componentId, - cartType: found.cartType || undefined, - }); + onUpdate({ ...mode, sourceComponentId: found.componentId }); } }; @@ -1000,11 +938,7 @@ function CartListModeSection({
) : ( )}

- 원본 화면의 카드 디자인과 장바구니 구분값이 자동으로 적용됩니다. + 원본 화면의 카드 디자인이 자동으로 적용됩니다.

)} @@ -2329,6 +2260,60 @@ function LimitSettingsSection({ ); } +// ===== 행 식별 키 컬럼 선택 ===== + +function KeyColumnSelect({ + tableName, + value, + onValueChange, +}: { + tableName?: string; + value: string; + onValueChange: (v: string) => void; +}) { + const [columns, setColumns] = useState([]); + + useEffect(() => { + if (tableName) { + fetchTableColumns(tableName).then(setColumns); + } else { + setColumns([]); + } + }, [tableName]); + + const options = useMemo(() => { + const seen = new Set(); + const unique: ColumnInfo[] = []; + const hasId = columns.some((c) => c.name === "id"); + if (!hasId) { + unique.push({ name: "id", type: "uuid", udtName: "uuid" }); + seen.add("id"); + } + for (const c of columns) { + if (!seen.has(c.name)) { + seen.add(c.name); + unique.push(c); + } + } + return unique; + }, [columns]); + + return ( + + ); +} + // ===== 담기 버튼 설정 섹션 ===== function CartActionSettingsSection({ @@ -2393,18 +2378,17 @@ function CartActionSettingsSection({
- {/* 장바구니 구분값 */} + {/* 행 식별 키 컬럼 */} {saveMode === "cart" && (
- - update({ cartType: e.target.value })} - placeholder="예: purchase_inbound" - className="mt-1 h-7 text-xs" + + update({ keyColumn: v })} />

- 장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다. + 각 행을 고유하게 식별하는 컬럼입니다. 기본값: id (UUID)

)} @@ -2606,3 +2590,517 @@ function ResponsiveDisplayRow({
); } + +// ===== 필터 기준 섹션 (columnGroups 기반) ===== + +const FILTER_OPERATORS: { value: FilterOperator; label: string }[] = [ + { value: "=", label: "=" }, + { value: "!=", label: "!=" }, + { value: ">", label: ">" }, + { value: "<", label: "<" }, + { value: ">=", label: ">=" }, + { value: "<=", label: "<=" }, + { value: "like", label: "포함" }, +]; + +function FilterCriteriaSection({ + dataSource, + columnGroups, + onUpdate, +}: { + dataSource: CardListDataSource; + columnGroups: ColumnGroup[]; + onUpdate: (partial: Partial) => void; +}) { + const filters = dataSource.filters || []; + + const addFilter = () => { + const newFilter: CardColumnFilter = { column: "", operator: "=", value: "" }; + onUpdate({ filters: [...filters, newFilter] }); + }; + + const updateFilter = (index: number, updated: CardColumnFilter) => { + const next = [...filters]; + next[index] = updated; + onUpdate({ filters: next }); + }; + + const deleteFilter = (index: number) => { + const next = filters.filter((_, i) => i !== index); + onUpdate({ filters: next.length > 0 ? next : undefined }); + }; + + return ( +
+

+ 데이터 조회 시 적용할 필터 조건입니다. +

+ + {filters.length === 0 ? ( +
+

필터 조건이 없습니다

+
+ ) : ( +
+ {filters.map((filter, index) => ( +
+
+ updateFilter(index, { ...filter, column: val || "" })} + placeholder="컬럼 선택" + /> +
+ + updateFilter(index, { ...filter, value: e.target.value })} + placeholder="값" + className="h-7 flex-1 text-xs" + /> + +
+ ))} +
+ )} + + +
+ ); +} + +// ===== 저장 매핑 섹션 (장바구니 -> 대상 테이블) ===== + +const CART_META_FIELDS = [ + { value: "__cart_quantity", label: "입력 수량" }, + { value: "__cart_package_unit", label: "포장 단위" }, + { value: "__cart_package_entries", label: "포장 내역" }, + { value: "__cart_memo", label: "메모" }, + { value: "__cart_row_key", label: "원본 키" }, +]; + +interface CardDisplayedField { + sourceField: string; + label: string; + badge: string; +} + +function SaveMappingSection({ + saveMapping, + onUpdate, + cartListMode, +}: { + saveMapping?: CardListSaveMapping; + onUpdate: (mapping: CardListSaveMapping) => void; + cartListMode?: CartListModeConfig; +}) { + const mapping: CardListSaveMapping = saveMapping || { targetTable: "", mappings: [] }; + const [tables, setTables] = useState([]); + const [targetColumns, setTargetColumns] = useState([]); + const [sourceColumns, setSourceColumns] = useState([]); + const [sourceTableName, setSourceTableName] = useState(""); + const [cardDisplayedFields, setCardDisplayedFields] = useState([]); + + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + // 원본 화면에서 테이블 컬럼 + 카드 템플릿 필드 추출 + useEffect(() => { + if (!cartListMode?.sourceScreenId) { + setSourceColumns([]); + setSourceTableName(""); + setCardDisplayedFields([]); + return; + } + + screenApi + .getLayoutPop(cartListMode.sourceScreenId) + .then((layoutJson: any) => { + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + + const matched = cartListMode.sourceComponentId + ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) + : componentList.find((c: any) => c.type === "pop-card-list"); + + const tableName = matched?.config?.dataSource?.tableName; + if (tableName) { + setSourceTableName(tableName); + fetchTableColumns(tableName).then(setSourceColumns); + } + + // 카드 템플릿에서 표시 중인 필드 추출 + const cardTemplate = matched?.config?.cardTemplate; + const inputFieldConfig = matched?.config?.inputField; + const packageConfig = matched?.config?.packageConfig; + const displayed: CardDisplayedField[] = []; + + if (cardTemplate?.header?.codeField) { + displayed.push({ + sourceField: cardTemplate.header.codeField, + label: cardTemplate.header.codeField, + badge: "헤더", + }); + } + if (cardTemplate?.header?.titleField) { + displayed.push({ + sourceField: cardTemplate.header.titleField, + label: cardTemplate.header.titleField, + badge: "헤더", + }); + } + for (const f of cardTemplate?.body?.fields || []) { + if (f.valueType === "column" && f.columnName) { + displayed.push({ + sourceField: f.columnName, + label: f.label || f.columnName, + badge: "본문", + }); + } + } + if (inputFieldConfig?.enabled) { + displayed.push({ + sourceField: "__cart_quantity", + label: "입력 수량", + badge: "입력", + }); + } + if (packageConfig?.enabled) { + displayed.push({ + sourceField: "__cart_package_unit", + label: "포장 단위", + badge: "포장", + }); + displayed.push({ + sourceField: "__cart_package_entries", + label: "포장 내역", + badge: "포장", + }); + } + + setCardDisplayedFields(displayed); + }) + .catch(() => { + setSourceColumns([]); + setSourceTableName(""); + setCardDisplayedFields([]); + }); + }, [cartListMode?.sourceScreenId, cartListMode?.sourceComponentId]); + + useEffect(() => { + if (mapping.targetTable) { + fetchTableColumns(mapping.targetTable).then(setTargetColumns); + } else { + setTargetColumns([]); + } + }, [mapping.targetTable]); + + // 카드에 표시된 필드 set (빠른 조회용) + const cardFieldSet = useMemo( + () => new Set(cardDisplayedFields.map((f) => f.sourceField)), + [cardDisplayedFields] + ); + + const getSourceFieldLabel = (field: string) => { + const cardField = cardDisplayedFields.find((f) => f.sourceField === field); + if (cardField) return cardField.label; + const meta = CART_META_FIELDS.find((f) => f.value === field); + if (meta) return meta.label; + return field; + }; + + const getFieldBadge = (field: string) => { + const cardField = cardDisplayedFields.find((f) => f.sourceField === field); + return cardField?.badge || null; + }; + + const isCartMeta = (field: string) => field.startsWith("__cart_"); + + const getSourceTableDisplayName = () => { + if (!sourceTableName) return "원본 데이터"; + const found = tables.find((t) => t.tableName === sourceTableName); + return found?.displayName || sourceTableName; + }; + + const mappedSourceFields = useMemo( + () => new Set(mapping.mappings.map((m) => m.sourceField)), + [mapping.mappings] + ); + + // 카드에 표시된 필드 중 아직 매핑되지 않은 것 + const unmappedCardFields = useMemo( + () => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)), + [cardDisplayedFields, mappedSourceFields] + ); + + // 카드에 없고 매핑도 안 된 원본 컬럼 + const availableExtraSourceFields = useMemo( + () => sourceColumns.filter((col) => !cardFieldSet.has(col.name) && !mappedSourceFields.has(col.name)), + [sourceColumns, cardFieldSet, mappedSourceFields] + ); + + // 카드에 없고 매핑도 안 된 장바구니 메타 + const availableExtraCartFields = useMemo( + () => CART_META_FIELDS.filter((f) => !cardFieldSet.has(f.value) && !mappedSourceFields.has(f.value)), + [cardFieldSet, mappedSourceFields] + ); + + // 대상 테이블 선택 -> 카드 표시 필드 전체 자동 매핑 + const updateTargetTable = (targetTable: string) => { + fetchTableColumns(targetTable).then((targetCols) => { + setTargetColumns(targetCols); + + const targetNameSet = new Set(targetCols.map((c) => c.name)); + const autoMappings: CardListSaveMappingEntry[] = []; + + for (const field of cardDisplayedFields) { + autoMappings.push({ + sourceField: field.sourceField, + targetColumn: targetNameSet.has(field.sourceField) ? field.sourceField : "", + }); + } + + onUpdate({ targetTable, mappings: autoMappings }); + }); + }; + + const addFieldMapping = (sourceField: string) => { + const matched = targetColumns.find((tc) => tc.name === sourceField); + onUpdate({ + ...mapping, + mappings: [ + ...mapping.mappings, + { sourceField, targetColumn: matched?.name || "" }, + ], + }); + }; + + const updateEntry = (index: number, updated: CardListSaveMappingEntry) => { + const next = [...mapping.mappings]; + next[index] = updated; + onUpdate({ ...mapping, mappings: next }); + }; + + const deleteEntry = (index: number) => { + const next = mapping.mappings.filter((_, i) => i !== index); + onUpdate({ ...mapping, mappings: next }); + }; + + const autoMatchedCount = mapping.mappings.filter((m) => m.targetColumn).length; + + // 매핑 행 렌더링 (공용) + const renderMappingRow = (entry: CardListSaveMappingEntry, index: number) => { + const badge = getFieldBadge(entry.sourceField); + return ( +
+
+
+ + {getSourceFieldLabel(entry.sourceField)} + + {badge && ( + + {badge} + + )} +
+ {isCartMeta(entry.sourceField) ? ( + !badge && 장바구니 + ) : ( + + {entry.sourceField} + + )} +
+ + + +
+ +
+ + +
+ ); + }; + + // 매핑 목록을 카드필드 / 추가필드로 분리 + const cardMappings: { entry: CardListSaveMappingEntry; index: number }[] = []; + const extraMappings: { entry: CardListSaveMappingEntry; index: number }[] = []; + mapping.mappings.forEach((entry, index) => { + if (cardFieldSet.has(entry.sourceField)) { + cardMappings.push({ entry, index }); + } else { + extraMappings.push({ entry, index }); + } + }); + + return ( +
+

+ 대상 테이블을 선택하면 카드에 배치된 필드가 자동으로 매핑됩니다. +

+ +
+ + +
+ + {!mapping.targetTable ? ( +
+

대상 테이블을 먼저 선택하세요

+
+ ) : ( + <> + {/* 자동 매핑 안내 */} + {autoMatchedCount > 0 && ( +
+ + + 이름 일치 {autoMatchedCount}개 필드 자동 매핑 + +
+ )} + + {/* --- 카드에 표시된 필드 --- */} + {(cardMappings.length > 0 || unmappedCardFields.length > 0) && ( +
+
+
+ + 카드에 표시된 필드 + +
+
+ + {cardMappings.map(({ entry, index }) => renderMappingRow(entry, index))} + + {/* 카드 필드 중 매핑 안 된 것 -> 칩으로 추가 */} + {unmappedCardFields.length > 0 && ( +
+ {unmappedCardFields.map((f) => ( + + ))} +
+ )} +
+ )} + + {/* --- 추가로 저장할 필드 --- */} + {(extraMappings.length > 0 || availableExtraSourceFields.length > 0 || availableExtraCartFields.length > 0) && ( +
+
+
+ + 추가 저장 필드 + +
+
+ + {extraMappings.map(({ entry, index }) => renderMappingRow(entry, index))} + + {/* 추가 가능한 필드 칩 */} + {(availableExtraSourceFields.length > 0 || availableExtraCartFields.length > 0) && ( +
+ {availableExtraSourceFields.map((col) => ( + + ))} + {availableExtraCartFields.map((f) => ( + + ))} +
+ )} +
+ )} + + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index e78782e2..01b9bf64 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -60,15 +60,17 @@ PopComponentRegistry.registerComponent({ defaultProps: defaultConfig, connectionMeta: { sendable: [ - { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, - { key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" }, - { key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, - { key: "selected_items", label: "선택된 항목", type: "value", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, + { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, + { key: "selected_items", label: "선택된 항목", type: "value", category: "data", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, + { key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" }, ], receivable: [ - { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, - { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, - { key: "confirm_trigger", label: "확정 트리거", type: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, + { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, + { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, + { key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, + { key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index c2baaa55..0f6adda6 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -33,6 +33,7 @@ export interface ColumnInfo { name: string; type: string; udtName: string; + isPrimaryKey?: boolean; } // ===== SQL 값 이스케이프 ===== @@ -328,6 +329,7 @@ export async function fetchTableColumns( name: col.columnName || col.column_name || col.name, type: col.dataType || col.data_type || col.type || "unknown", udtName: col.dbType || col.udt_name || col.udtName || "unknown", + isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true", })); } } diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx index 5fe10ea6..c646dfd6 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -21,6 +21,7 @@ import type { PopFieldReadSource, PopFieldAutoGenMapping, } from "./types"; +import type { CollectDataRequest, CollectedDataResponse } from "../types"; import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types"; // ======================================== @@ -191,6 +192,35 @@ export function PopFieldComponent({ return unsub; }, [componentId, subscribe, cfg.readSource, fetchReadSourceData]); + // 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__collect_data`, + (payload: unknown) => { + const request = (payload as Record)?.value as CollectDataRequest | undefined; + + const response: CollectedDataResponse = { + requestId: request?.requestId ?? "", + componentId: componentId, + componentType: "pop-field", + data: { values: allValues }, + mapping: cfg.saveConfig?.tableName + ? { + targetTable: cfg.saveConfig.tableName, + columnMapping: Object.fromEntries( + (cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn]) + ), + } + : null, + }; + + publish(`__comp_output__${componentId}__collected_data`, response); + } + ); + return unsub; + }, [componentId, subscribe, publish, allValues, cfg.saveConfig]); + // 필드 값 변경 핸들러 const handleFieldChange = useCallback( (fieldName: string, value: unknown) => { diff --git a/frontend/lib/registry/pop-components/pop-field/index.tsx b/frontend/lib/registry/pop-components/pop-field/index.tsx index 1c436301..60ed1ba7 100644 --- a/frontend/lib/registry/pop-components/pop-field/index.tsx +++ b/frontend/lib/registry/pop-components/pop-field/index.tsx @@ -66,16 +66,32 @@ PopComponentRegistry.registerComponent({ key: "value_changed", label: "값 변경", type: "value", + category: "data", description: "필드값 변경 시 fieldName + value + allValues 전달", }, + { + key: "collected_data", + label: "수집 응답", + type: "event", + category: "event", + description: "데이터 수집 요청에 대한 응답 (입력값 + 매핑)", + }, ], receivable: [ { key: "set_value", label: "값 설정", type: "value", + category: "data", description: "외부에서 특정 필드 또는 일괄로 값 세팅", }, + { + key: "collect_data", + label: "수집 요청", + type: "event", + category: "event", + description: "버튼에서 데이터+매핑 수집 요청 수신", + }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx index 87069f38..e78dd11c 100644 --- a/frontend/lib/registry/pop-components/pop-search/index.tsx +++ b/frontend/lib/registry/pop-components/pop-search/index.tsx @@ -36,10 +36,10 @@ PopComponentRegistry.registerComponent({ defaultProps: DEFAULT_SEARCH_CONFIG, connectionMeta: { sendable: [ - { key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" }, + { key: "filter_value", label: "필터 값", type: "filter_value", category: "filter", description: "입력한 검색 조건을 다른 컴포넌트에 전달" }, ], receivable: [ - { key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, + { key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx new file mode 100644 index 00000000..62d63f02 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-shared/ColumnCombobox.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import type { ColumnInfo } from "../pop-dashboard/utils/dataFetcher"; + +interface ColumnComboboxProps { + columns: ColumnInfo[]; + value: string; + onSelect: (columnName: string) => void; + placeholder?: string; +} + +export function ColumnCombobox({ + columns, + value, + onSelect, + placeholder = "컬럼을 선택하세요", +}: ColumnComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search) return columns; + const q = search.toLowerCase(); + return columns.filter((c) => c.name.toLowerCase().includes(q)); + }, [columns, search]); + + return ( + + + + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((col) => ( + { + onSelect(col.name); + setOpen(false); + setSearch(""); + }} + className="text-xs" + > + +
+ {col.name} + + {col.type} + +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-shared/TableCombobox.tsx b/frontend/lib/registry/pop-components/pop-shared/TableCombobox.tsx new file mode 100644 index 00000000..69b1469e --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-shared/TableCombobox.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import type { TableInfo } from "../pop-dashboard/utils/dataFetcher"; + +interface TableComboboxProps { + tables: TableInfo[]; + value: string; + onSelect: (tableName: string) => void; + placeholder?: string; +} + +export function TableCombobox({ + tables, + value, + onSelect, + placeholder = "테이블을 선택하세요", +}: TableComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const selectedLabel = useMemo(() => { + const found = tables.find((t) => t.tableName === value); + return found ? (found.displayName || found.tableName) : ""; + }, [tables, value]); + + const filtered = useMemo(() => { + if (!search) return tables; + const q = search.toLowerCase(); + return tables.filter( + (t) => + t.tableName.toLowerCase().includes(q) || + (t.displayName && t.displayName.toLowerCase().includes(q)) + ); + }, [tables, search]); + + return ( + + + + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((table) => ( + { + onSelect(table.tableName); + setOpen(false); + setSearch(""); + }} + className="text-xs" + > + +
+ {table.displayName || table.tableName} + {table.displayName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/index.tsx b/frontend/lib/registry/pop-components/pop-string-list/index.tsx index 4bf6c638..96a6ae97 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/index.tsx @@ -35,10 +35,10 @@ PopComponentRegistry.registerComponent({ defaultProps: defaultConfig, connectionMeta: { sendable: [ - { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" }, + { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 행 데이터를 전달" }, ], receivable: [ - { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" }, + { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 6aff5126..9dc54978 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -509,7 +509,7 @@ export type CartItemStatus = "in_cart" | "confirmed" | "cancelled"; export interface CartItemWithId extends CartItem { cartId?: string; // DB id (UUID, 저장 후 할당) sourceTable: string; // 원본 테이블명 - rowKey: string; // 원본 행 식별키 (codeField 값) + rowKey: string; // 원본 행 식별키 (keyColumn 값, 기본 id) status: CartItemStatus; _origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가 memo?: string; @@ -523,7 +523,7 @@ export type CartSaveMode = "cart" | "direct"; export interface CardCartActionConfig { saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장 - cartType?: string; // 장바구니 구분값 (예: "purchase_inbound") + keyColumn?: string; // 행 식별 키 컬럼 (기본: "id") label?: string; // 담기 라벨 (기본: "담기") cancelLabel?: string; // 취소 라벨 (기본: "취소") // 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호) @@ -614,10 +614,80 @@ export interface CartListModeConfig { enabled: boolean; sourceScreenId?: number; sourceComponentId?: string; - cartType?: string; statusFilter?: string; } +// ----- 데이터 수집 패턴 (pop-button ↔ 컴포넌트 간 요청-응답) ----- + +export interface CollectDataRequest { + requestId: string; + action: string; +} + +export interface CollectedDataResponse { + requestId: string; + componentId: string; + componentType: string; + data: { + items?: Record[]; + values?: Record; + }; + mapping?: SaveMapping | null; +} + +export interface SaveMapping { + targetTable: string; + columnMapping: Record; +} + +export interface StatusChangeRule { + targetTable: string; + targetColumn: string; + lookupMode?: "auto" | "manual"; + manualItemField?: string; + manualPkColumn?: string; + valueType: "fixed" | "conditional"; + fixedValue?: string; + conditionalValue?: ConditionalValue; +} + +export interface ConditionalValue { + conditions: StatusCondition[]; + defaultValue?: string; +} + +export interface StatusCondition { + whenColumn: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<="; + whenValue: string; + thenValue: string; +} + +export interface ExecuteActionPayload { + inserts: { + table: string; + records: Record[]; + }[]; + statusChanges: { + table: string; + column: string; + value: string; + where: Record; + }[]; +} + +// ----- 저장 매핑 (장바구니 -> 대상 테이블) ----- + +export interface CardListSaveMappingEntry { + sourceField: string; + targetColumn: string; +} + +export interface CardListSaveMapping { + targetTable: string; + mappings: CardListSaveMappingEntry[]; +} + // ----- pop-card-list 전체 설정 ----- export interface PopCardListConfig { @@ -637,4 +707,5 @@ export interface PopCardListConfig { cartAction?: CardCartActionConfig; cartListMode?: CartListModeConfig; + saveMapping?: CardListSaveMapping; }