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 e30a9c34..0bf35e75 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -115,6 +115,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"; // 세금계산서 관리 @@ -242,6 +243,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/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 0e97e2e2..260efc3e 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -20,7 +20,7 @@ const pool = getPool(); export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; - const { page = 1, size = 20, searchTerm } = req.query; + const { page = 1, size = 20, searchTerm, excludePop } = req.query; const offset = (parseInt(page as string) - 1) * parseInt(size as string); let whereClause = "WHERE 1=1"; @@ -34,6 +34,11 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) paramIndex++; } + // POP 그룹 제외 (PC 화면관리용) + if (excludePop === "true") { + whereClause += ` AND (hierarchy_path IS NULL OR (hierarchy_path NOT LIKE 'POP/%' AND hierarchy_path != 'POP'))`; + } + // 검색어 필터링 if (searchTerm) { whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; @@ -2574,11 +2579,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons const companyCode = req.user?.companyCode || "*"; const { searchTerm } = req.query; - let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'"; + let whereClause = "WHERE (hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP')"; const params: any[] = []; let paramIndex = 1; - // 회사 코드 필터링 (멀티테넌시) + // 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만 if (companyCode !== "*") { whereClause += ` AND company_code = $${paramIndex}`; params.push(companyCode); @@ -2592,11 +2597,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons paramIndex++; } - // POP 그룹 조회 (계층 구조를 위해 전체 조회) + // POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함) const dataQuery = ` SELECT sg.*, - (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT COUNT(*) FROM screen_group_screens sgs + INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code + WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code) as screen_count, (SELECT json_agg( json_build_object( 'id', sgs.id, @@ -2609,7 +2616,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons ) ORDER BY sgs.display_order ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id - WHERE sgs.group_id = sg.id + INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code + WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code ) as screens FROM screen_groups sg ${whereClause} @@ -2768,6 +2776,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo const existing = await pool.query(checkQuery, checkParams); if (existing.rows.length === 0) { + // 그룹이 다른 회사 소속인지 확인하여 구체적 메시지 제공 + const anyGroup = await pool.query(`SELECT company_code FROM screen_groups WHERE id = $1`, [id]); + if (anyGroup.rows.length > 0) { + return res.status(403).json({ + success: false, + message: `이 그룹은 ${anyGroup.rows[0].company_code === "*" ? "최고관리자" : anyGroup.rows[0].company_code} 소속이라 삭제할 수 없습니다.` + }); + } return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); } @@ -2782,7 +2798,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo [id] ); if (parseInt(childCheck.rows[0].count) > 0) { - return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." }); + return res.status(400).json({ + success: false, + message: `하위 그룹이 ${childCheck.rows[0].count}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.` + }); } // 연결된 화면 확인 @@ -2791,7 +2810,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo [id] ); if (parseInt(screenCheck.rows[0].count) > 0) { - return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." }); + return res.status(400).json({ + success: false, + message: `그룹에 연결된 화면이 ${screenCheck.rows[0].count}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.` + }); } // 삭제 @@ -2806,33 +2828,44 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo } }; -// POP 루트 그룹 확보 (없으면 자동 생성) +// POP 루트 그룹 확보 (최고관리자 전용 - 일반 회사는 복사 기능으로 배포) export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; - // POP 루트 그룹 확인 - const checkQuery = ` - SELECT * FROM screen_groups - WHERE hierarchy_path = 'POP' AND company_code = $1 - `; - const existing = await pool.query(checkQuery, [companyCode]); - - if (existing.rows.length > 0) { - return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." }); + // 최고관리자만 자동 생성 + if (companyCode !== "*") { + const existing = await pool.query( + `SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`, + [companyCode] + ); + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0] }); + } + return res.json({ success: true, data: null, message: "POP 루트 그룹이 없습니다." }); + } + + // 최고관리자(*): 루트 그룹 확인 후 없으면 생성 + const checkQuery = ` + SELECT * FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = '*' + `; + const existing = await pool.query(checkQuery, []); + + if (existing.rows.length > 0) { + return res.json({ success: true, data: existing.rows[0] }); } - // 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤) const insertQuery = ` INSERT INTO screen_groups ( group_name, group_code, hierarchy_path, company_code, description, display_order, is_active, writer - ) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2) + ) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1) RETURNING * `; - const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]); + const result = await pool.query(insertQuery, [req.user?.userId || ""]); - logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode }); + logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id }); res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." }); } catch (error: any) { diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 3e624c40..0b994f01 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -6,7 +6,7 @@ import { AuthenticatedRequest } from "../types/auth"; export const getScreens = async (req: AuthenticatedRequest, res: Response) => { try { const userCompanyCode = (req.user as any).companyCode; - const { page = 1, size = 20, searchTerm, companyCode } = req.query; + const { page = 1, size = 20, searchTerm, companyCode, excludePop } = req.query; // 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용) // 아니면 현재 사용자의 companyCode 사용 @@ -24,7 +24,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => { targetCompanyCode, parseInt(page as string), parseInt(size as string), - searchTerm as string // 검색어 전달 + searchTerm as string, + { excludePop: excludePop === "true" }, ); res.json({ @@ -1364,3 +1365,82 @@ export const copyCascadingRelation = async ( }); } }; + +// POP 화면 연결 분석 +export const analyzePopScreenLinks = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + + const result = await screenManagementService.analyzePopScreenLinks( + parseInt(screenId), + companyCode, + ); + + res.json({ success: true, data: result }); + } catch (error: any) { + console.error("POP 화면 연결 분석 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "POP 화면 연결 분석에 실패했습니다.", + }); + } +}; + +// POP 화면 배포 (다른 회사로 복사) +export const deployPopScreens = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { screens, targetCompanyCode, groupStructure } = req.body; + const { companyCode, userId } = req.user as any; + + if (!screens || !Array.isArray(screens) || screens.length === 0) { + res.status(400).json({ + success: false, + message: "배포할 화면 목록이 필요합니다.", + }); + return; + } + + if (!targetCompanyCode) { + res.status(400).json({ + success: false, + message: "대상 회사 코드가 필요합니다.", + }); + return; + } + + if (companyCode !== "*") { + res.status(403).json({ + success: false, + message: "최고 관리자만 POP 화면을 배포할 수 있습니다.", + }); + return; + } + + const result = await screenManagementService.deployPopScreens({ + screens, + groupStructure: groupStructure || undefined, + targetCompanyCode, + companyCode, + userId, + }); + + res.json({ + success: true, + data: result, + message: `POP 화면 ${result.deployedScreens.length}개가 ${targetCompanyCode}에 배포되었습니다.`, + }); + } catch (error: any) { + console.error("POP 화면 배포 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "POP 화면 배포에 실패했습니다.", + }); + } +}; 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/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 824bee71..29b415d0 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -51,6 +51,8 @@ import { updateZone, deleteZone, addLayerToZone, + analyzePopScreenLinks, + deployPopScreens, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -145,4 +147,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns); // 연쇄관계 설정 복제 router.post("/copy-cascading-relation", copyCascadingRelation); +// POP 화면 배포 (다른 회사로 복사) +router.get("/screens/:screenId/pop-links", analyzePopScreenLinks); +router.post("/deploy-pop-screens", deployPopScreens); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 74506a39..73d01149 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -108,42 +108,49 @@ export class ScreenManagementService { companyCode: string, page: number = 1, size: number = 20, - searchTerm?: string, // 검색어 추가 + searchTerm?: string, + options?: { excludePop?: boolean }, ): Promise> { const offset = (page - 1) * size; // WHERE 절 동적 생성 - const whereConditions: string[] = ["is_active != 'D'"]; + const whereConditions: string[] = ["sd.is_active != 'D'"]; const params: any[] = []; if (companyCode !== "*") { - whereConditions.push(`company_code = $${params.length + 1}`); + whereConditions.push(`sd.company_code = $${params.length + 1}`); params.push(companyCode); } - // 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색) if (searchTerm && searchTerm.trim() !== "") { whereConditions.push(`( - screen_name ILIKE $${params.length + 1} OR - screen_code ILIKE $${params.length + 1} OR - table_name ILIKE $${params.length + 1} + sd.screen_name ILIKE $${params.length + 1} OR + sd.screen_code ILIKE $${params.length + 1} OR + sd.table_name ILIKE $${params.length + 1} )`); params.push(`%${searchTerm.trim()}%`); } + // POP 화면 제외 필터: screen_layouts_pop에 레이아웃이 있는 화면 제외 + if (options?.excludePop) { + whereConditions.push( + `NOT EXISTS (SELECT 1 FROM screen_layouts_pop slp WHERE slp.screen_id = sd.screen_id)` + ); + } + const whereSQL = whereConditions.join(" AND "); // 페이징 쿼리 (Raw Query) const [screens, totalResult] = await Promise.all([ query( - `SELECT * FROM screen_definitions + `SELECT sd.* FROM screen_definitions sd WHERE ${whereSQL} - ORDER BY created_date DESC + ORDER BY sd.created_date DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, size, offset], ), query<{ count: string }>( - `SELECT COUNT(*)::text as count FROM screen_definitions + `SELECT COUNT(*)::text as count FROM screen_definitions sd WHERE ${whereSQL}`, params, ), @@ -5814,28 +5821,24 @@ export class ScreenManagementService { async getScreenIdsWithPopLayout( companyCode: string, ): Promise { - console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`); - console.log(`회사 코드: ${companyCode}`); - let result: { screen_id: number }[]; if (companyCode === "*") { - // 최고 관리자: 모든 POP 레이아웃 조회 result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop`, [], ); } else { - // 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회 + // 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용) result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop - WHERE company_code = $1 OR company_code = '*'`, + WHERE company_code = $1`, [companyCode], ); } const screenIds = result.map((r) => r.screen_id); - console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`); + logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length }); return screenIds; } @@ -5873,6 +5876,512 @@ export class ScreenManagementService { console.log(`POP 레이아웃 삭제 완료`); return true; } + + // ============================================================ + // POP 화면 배포 (다른 회사로 복사) + // ============================================================ + + /** + * POP layout_data 내 다른 화면 참조를 스캔하여 연결 관계 분석 + */ + async analyzePopScreenLinks( + screenId: number, + companyCode: string, + ): Promise<{ + linkedScreenIds: number[]; + references: Array<{ + componentId: string; + referenceType: string; + targetScreenId: number; + }>; + }> { + const layoutResult = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + + if (!layoutResult?.layout_data) { + return { linkedScreenIds: [], references: [] }; + } + + const layoutData = layoutResult.layout_data; + const references: Array<{ + componentId: string; + referenceType: string; + targetScreenId: number; + }> = []; + + const scanComponents = (components: Record) => { + for (const [compId, comp] of Object.entries(components)) { + const config = (comp as any).config || {}; + + if (config.cart?.cartScreenId) { + const sid = parseInt(config.cart.cartScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "cartScreenId", + targetScreenId: sid, + }); + } + } + + if (config.cartListMode?.sourceScreenId) { + const sid = + typeof config.cartListMode.sourceScreenId === "number" + ? config.cartListMode.sourceScreenId + : parseInt(config.cartListMode.sourceScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "sourceScreenId", + targetScreenId: sid, + }); + } + } + + if (Array.isArray(config.followUpActions)) { + for (const action of config.followUpActions) { + if (action.targetScreenId) { + const sid = parseInt(action.targetScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "targetScreenId", + targetScreenId: sid, + }); + } + } + } + } + + if (config.action?.modalScreenId) { + const sid = parseInt(config.action.modalScreenId); + if (!isNaN(sid) && sid !== screenId) { + references.push({ + componentId: compId, + referenceType: "modalScreenId", + targetScreenId: sid, + }); + } + } + } + }; + + if (layoutData.components) { + scanComponents(layoutData.components); + } + + if (Array.isArray(layoutData.modals)) { + for (const modal of layoutData.modals) { + if (modal.components) { + scanComponents(modal.components); + } + } + } + + const linkedScreenIds = [ + ...new Set(references.map((r) => r.targetScreenId)), + ]; + + return { linkedScreenIds, references }; + } + + /** + * POP 화면 배포 (최고관리자 화면을 특정 회사로 복사) + * - screen_definitions + screen_layouts_pop 복사 + * - 화면 간 참조(cartScreenId, sourceScreenId 등) 자동 치환 + * - numberingRuleId 초기화 + */ + async deployPopScreens(data: { + screens: Array<{ + sourceScreenId: number; + screenName: string; + screenCode: string; + }>; + groupStructure?: { + sourceGroupId: number; + groupName: string; + groupCode: string; + children?: Array<{ + sourceGroupId: number; + groupName: string; + groupCode: string; + screenIds: number[]; + }>; + screenIds: number[]; + }; + targetCompanyCode: string; + companyCode: string; + userId: string; + }): Promise<{ + deployedScreens: Array<{ + sourceScreenId: number; + newScreenId: number; + screenName: string; + screenCode: string; + }>; + createdGroups?: number; + }> { + if (data.companyCode !== "*") { + throw new Error("최고 관리자만 POP 화면을 배포할 수 있습니다."); + } + + return await transaction(async (client) => { + const screenIdMap = new Map(); + const deployedScreens: Array<{ + sourceScreenId: number; + newScreenId: number; + screenName: string; + screenCode: string; + }> = []; + + // 1단계: screen_definitions 복사 + for (const screen of data.screens) { + const sourceResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screen.sourceScreenId], + ); + + if (sourceResult.rows.length === 0) { + throw new Error( + `원본 화면(ID: ${screen.sourceScreenId})을 찾을 수 없습니다.`, + ); + } + + const sourceScreen = sourceResult.rows[0]; + + const existingResult = await client.query( + `SELECT screen_id FROM screen_definitions + WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL + LIMIT 1`, + [screen.screenCode, data.targetCompanyCode], + ); + + if (existingResult.rows.length > 0) { + throw new Error( + `화면 코드 "${screen.screenCode}"가 대상 회사에 이미 존재합니다.`, + ); + } + + const newScreenResult = await client.query( + `INSERT INTO screen_definitions ( + screen_code, screen_name, description, company_code, table_name, + is_active, created_by, created_date, updated_by, updated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW()) + RETURNING *`, + [ + screen.screenCode, + screen.screenName, + sourceScreen.description, + data.targetCompanyCode, + sourceScreen.table_name, + "Y", + data.userId, + ], + ); + + const newScreen = newScreenResult.rows[0]; + screenIdMap.set(screen.sourceScreenId, newScreen.screen_id); + + deployedScreens.push({ + sourceScreenId: screen.sourceScreenId, + newScreenId: newScreen.screen_id, + screenName: screen.screenName, + screenCode: screen.screenCode, + }); + + logger.info("POP 화면 배포 - screen_definitions 생성", { + sourceScreenId: screen.sourceScreenId, + newScreenId: newScreen.screen_id, + targetCompanyCode: data.targetCompanyCode, + }); + } + + // 2단계: screen_layouts_pop 복사 + 참조 치환 + for (const screen of data.screens) { + const newScreenId = screenIdMap.get(screen.sourceScreenId); + if (!newScreenId) continue; + + // 원본 POP 레이아웃 조회 (company_code = '*' 우선, fallback) + let layoutResult = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screen.sourceScreenId], + ); + + let layoutData = layoutResult.rows[0]?.layout_data; + if (!layoutData) { + const fallbackResult = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 LIMIT 1`, + [screen.sourceScreenId], + ); + layoutData = fallbackResult.rows[0]?.layout_data; + } + + if (!layoutData) { + logger.warn("POP 레이아웃 없음, 건너뜀", { + sourceScreenId: screen.sourceScreenId, + }); + continue; + } + + const updatedLayoutData = this.updatePopLayoutScreenReferences( + JSON.parse(JSON.stringify(layoutData)), + screenIdMap, + ); + + await client.query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, NOW(), NOW(), $4, $4) + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`, + [ + newScreenId, + data.targetCompanyCode, + JSON.stringify(updatedLayoutData), + data.userId, + ], + ); + + logger.info("POP 레이아웃 복사 완료", { + sourceScreenId: screen.sourceScreenId, + newScreenId, + componentCount: Object.keys(updatedLayoutData.components || {}) + .length, + }); + } + + // 3단계: 그룹 구조 복사 (groupStructure가 있는 경우) + let createdGroups = 0; + if (data.groupStructure) { + const gs = data.groupStructure; + + // 대상 회사의 POP 루트 그룹 찾기/생성 + let popRootResult = await client.query( + `SELECT id FROM screen_groups + WHERE hierarchy_path = 'POP' AND company_code = $1 LIMIT 1`, + [data.targetCompanyCode], + ); + + let popRootId: number; + if (popRootResult.rows.length > 0) { + popRootId = popRootResult.rows[0].id; + } else { + const createRootResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, writer, is_active, display_order) + VALUES ('POP 화면', 'POP_ROOT', 'POP', $1, $2, 'Y', 0) RETURNING id`, + [data.targetCompanyCode, data.userId], + ); + popRootId = createRootResult.rows[0].id; + } + + // 메인 그룹 생성 (중복 코드 방지: _COPY 접미사 추가) + const mainGroupCode = gs.groupCode + "_COPY"; + const dupCheck = await client.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [mainGroupCode, data.targetCompanyCode], + ); + + let mainGroupId: number; + if (dupCheck.rows.length > 0) { + mainGroupId = dupCheck.rows[0].id; + } else { + const mainGroupResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', 0) RETURNING id`, + [ + gs.groupName, + mainGroupCode, + `POP/${mainGroupCode}`, + data.targetCompanyCode, + popRootId, + data.userId, + ], + ); + mainGroupId = mainGroupResult.rows[0].id; + createdGroups++; + } + + // 메인 그룹에 화면 연결 + for (const oldScreenId of gs.screenIds) { + const newScreenId = screenIdMap.get(oldScreenId); + if (!newScreenId) continue; + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code) + VALUES ($1, $2, 'main', 0, 'N', $3) + ON CONFLICT DO NOTHING`, + [mainGroupId, newScreenId, data.targetCompanyCode], + ); + } + + // 하위 그룹 생성 + 화면 연결 + if (gs.children) { + for (let i = 0; i < gs.children.length; i++) { + const child = gs.children[i]; + const childGroupCode = child.groupCode + "_COPY"; + + const childDupCheck = await client.query( + `SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`, + [childGroupCode, data.targetCompanyCode], + ); + + let childGroupId: number; + if (childDupCheck.rows.length > 0) { + childGroupId = childDupCheck.rows[0].id; + } else { + const childResult = await client.query( + `INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7) RETURNING id`, + [ + child.groupName, + childGroupCode, + `POP/${mainGroupCode}/${childGroupCode}`, + data.targetCompanyCode, + mainGroupId, + data.userId, + i, + ], + ); + childGroupId = childResult.rows[0].id; + createdGroups++; + } + + for (const oldScreenId of child.screenIds) { + const newScreenId = screenIdMap.get(oldScreenId); + if (!newScreenId) continue; + await client.query( + `INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code) + VALUES ($1, $2, 'main', 0, 'N', $3) + ON CONFLICT DO NOTHING`, + [childGroupId, newScreenId, data.targetCompanyCode], + ); + } + } + } + + logger.info("POP 그룹 구조 복사 완료", { + targetCompanyCode: data.targetCompanyCode, + createdGroups, + mainGroupName: gs.groupName, + }); + } + + return { deployedScreens, createdGroups }; + }); + } + + /** + * POP layout_data 내 screen_id 참조 치환 + * componentId, connectionId는 레이아웃 내부 식별자이므로 변경 불필요 + */ + private updatePopLayoutScreenReferences( + layoutData: any, + screenIdMap: Map, + ): any { + if (!layoutData?.components) return layoutData; + + const updateComponents = ( + components: Record, + ): Record => { + const updated: Record = {}; + + for (const [compId, comp] of Object.entries(components)) { + const updatedComp = JSON.parse(JSON.stringify(comp)); + const config = updatedComp.config || {}; + + // cart.cartScreenId (string) + if (config.cart?.cartScreenId) { + const oldId = parseInt(config.cart.cartScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + config.cart.cartScreenId = String(newId); + logger.info(`POP 참조 치환: cartScreenId ${oldId} -> ${newId}`); + } + } + + // cartListMode.sourceScreenId (number) + if (config.cartListMode?.sourceScreenId) { + const oldId = + typeof config.cartListMode.sourceScreenId === "number" + ? config.cartListMode.sourceScreenId + : parseInt(config.cartListMode.sourceScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + config.cartListMode.sourceScreenId = newId; + logger.info( + `POP 참조 치환: sourceScreenId ${oldId} -> ${newId}`, + ); + } + } + + // followUpActions[].targetScreenId (string) + if (Array.isArray(config.followUpActions)) { + for (const action of config.followUpActions) { + if (action.targetScreenId) { + const oldId = parseInt(action.targetScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + action.targetScreenId = String(newId); + logger.info( + `POP 참조 치환: targetScreenId ${oldId} -> ${newId}`, + ); + } + } + } + } + + // action.modalScreenId (숫자형이면 화면 참조로 간주) + if (config.action?.modalScreenId) { + const oldId = parseInt(config.action.modalScreenId); + if (!isNaN(oldId)) { + const newId = screenIdMap.get(oldId); + if (newId) { + config.action.modalScreenId = String(newId); + logger.info( + `POP 참조 치환: modalScreenId ${oldId} -> ${newId}`, + ); + } + } + } + + // numberingRuleId 초기화 (배포 후 대상 회사에서 재설정 필요) + if (config.numberingRuleId) { + logger.info(`POP 채번규칙 초기화: ${config.numberingRuleId}`); + config.numberingRuleId = ""; + } + if (config.autoGenMappings) { + for (const mapping of Object.values(config.autoGenMappings) as any[]) { + if (mapping?.numberingRuleId) { + logger.info( + `POP 채번규칙 초기화: ${mapping.numberingRuleId}`, + ); + mapping.numberingRuleId = ""; + } + } + } + + updatedComp.config = config; + updated[compId] = updatedComp; + } + + return updated; + }; + + layoutData.components = updateComponents(layoutData.components); + + if (Array.isArray(layoutData.modals)) { + for (const modal of layoutData.modals) { + if (modal.components) { + modal.components = updateComponents(modal.components); + } + } + } + + return layoutData; + } } // 서비스 인스턴스 export diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx index d9e289ca..aec81bb4 100644 --- a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx @@ -13,6 +13,7 @@ import { Settings, LayoutGrid, GitBranch, + Upload, } from "lucide-react"; import { PopDesigner } from "@/components/pop/designer"; import { ScrollToTop } from "@/components/common/ScrollToTop"; @@ -27,6 +28,7 @@ import { PopScreenPreview, PopScreenFlowView, PopScreenSettingModal, + PopDeployModal, } from "@/components/pop/management"; import { PopScreenGroup } from "@/lib/api/popScreenGroup"; @@ -62,6 +64,10 @@ export default function PopScreenManagementPage() { // UI 상태 const [isCreateOpen, setIsCreateOpen] = useState(false); const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [isDeployModalOpen, setIsDeployModalOpen] = useState(false); + const [deployGroupScreens, setDeployGroupScreens] = useState([]); + const [deployGroupName, setDeployGroupName] = useState(""); + const [deployGroupInfo, setDeployGroupInfo] = useState(undefined); const [devicePreview, setDevicePreview] = useState("tablet"); const [rightPanelView, setRightPanelView] = useState("preview"); @@ -235,6 +241,21 @@ export default function PopScreenManagementPage() { + {selectedScreen && ( + + )} + + )} + {!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({
)} - {/* 제출 버튼 */} - - - onScreenDesign(screen)}> - - 설계 - - - openMoveModal(screen, null)}> - - 카테고리로 이동 - - - handleDeleteScreen(screen)} - > - - 화면 삭제 - - - - - ))} + + {screen.screenName} + #{screen.screenId} + + + + + + + onScreenDesign(screen)}> + + 설계 + + onScreenSettings?.(screen)}> + + 설정 (이름 변경) + + onScreenCopy?.(screen)}> + + 복사 + + + openMoveModal(screen, null)}> + + 카테고리로 이동 + + + handleDeleteScreen(screen)} + > + + 화면 삭제 + + + + + ))} + + ); + }); + })()} )} diff --git a/frontend/components/pop/management/PopDeployModal.tsx b/frontend/components/pop/management/PopDeployModal.tsx new file mode 100644 index 00000000..46f8cc68 --- /dev/null +++ b/frontend/components/pop/management/PopDeployModal.tsx @@ -0,0 +1,560 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Loader2, Link2, Monitor, Folder, ChevronRight } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { GroupCopyInfo } from "./PopCategoryTree"; +import { getCompanyList } from "@/lib/api/company"; +import { ScreenDefinition } from "@/types/screen"; +import { Company } from "@/types/company"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +interface LinkedScreenInfo { + screenId: number; + screenName: string; + screenCode: string; + references: Array<{ + componentId: string; + referenceType: string; + }>; + deploy: boolean; + newScreenName: string; + newScreenCode: string; +} + +interface ScreenEntry { + screenId: number; + screenName: string; + newScreenName: string; + newScreenCode: string; + included: boolean; +} + +interface PopDeployModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + screen: ScreenDefinition | null; + groupScreens?: ScreenDefinition[]; + groupName?: string; + groupInfo?: GroupCopyInfo; + allScreens: ScreenDefinition[]; + onDeployed?: () => void; +} + +export function PopDeployModal({ + open, + onOpenChange, + screen, + groupScreens, + groupName, + groupInfo, + allScreens, + onDeployed, +}: PopDeployModalProps) { + const isGroupMode = !!(groupScreens && groupScreens.length > 0); + + const [companies, setCompanies] = useState([]); + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + + // 단일 화면 모드 + const [screenName, setScreenName] = useState(""); + const [screenCode, setScreenCode] = useState(""); + const [linkedScreens, setLinkedScreens] = useState([]); + + // 그룹 모드 + const [groupEntries, setGroupEntries] = useState([]); + + const [analyzing, setAnalyzing] = useState(false); + const [deploying, setDeploying] = useState(false); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + getCompanyList({ status: "active" }) + .then((list) => { + setCompanies(list.filter((c) => c.company_code !== "*")); + }) + .catch(console.error); + } + }, [open]); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (!open) return; + + setTargetCompanyCode(""); + setLinkedScreens([]); + + if (isGroupMode && groupScreens) { + setGroupEntries( + groupScreens.map((s) => ({ + screenId: s.screenId, + screenName: s.screenName, + newScreenName: s.screenName, + newScreenCode: "", + included: true, + })), + ); + setScreenName(""); + setScreenCode(""); + } else if (screen) { + setScreenName(screen.screenName); + setScreenCode(""); + setGroupEntries([]); + analyzeLinks(screen.screenId); + } + }, [open, screen, groupScreens, isGroupMode]); + + // 회사 선택 시 화면 코드 자동 생성 + useEffect(() => { + if (!targetCompanyCode) return; + + if (isGroupMode) { + const count = groupEntries.filter((e) => e.included).length; + if (count > 0) { + screenApi + .generateMultipleScreenCodes(targetCompanyCode, count) + .then((codes) => { + let codeIdx = 0; + setGroupEntries((prev) => + prev.map((e) => + e.included + ? { ...e, newScreenCode: codes[codeIdx++] || "" } + : e, + ), + ); + }) + .catch(console.error); + } + } else { + const count = 1 + linkedScreens.filter((ls) => ls.deploy).length; + screenApi + .generateMultipleScreenCodes(targetCompanyCode, count) + .then((codes) => { + setScreenCode(codes[0] || ""); + setLinkedScreens((prev) => + prev.map((ls, idx) => ({ + ...ls, + newScreenCode: codes[idx + 1] || "", + })), + ); + }) + .catch(console.error); + } + }, [targetCompanyCode]); + + const analyzeLinks = async (screenId: number) => { + setAnalyzing(true); + try { + const result = await screenApi.analyzePopScreenLinks(screenId); + const linked: LinkedScreenInfo[] = result.linkedScreenIds.map( + (linkedId) => { + const linkedScreen = allScreens.find( + (s) => s.screenId === linkedId, + ); + const refs = result.references.filter( + (r) => r.targetScreenId === linkedId, + ); + return { + screenId: linkedId, + screenName: linkedScreen?.screenName || `화면 ${linkedId}`, + screenCode: linkedScreen?.screenCode || "", + references: refs.map((r) => ({ + componentId: r.componentId, + referenceType: r.referenceType, + })), + deploy: true, + newScreenName: linkedScreen?.screenName || `화면 ${linkedId}`, + newScreenCode: "", + }; + }, + ); + setLinkedScreens(linked); + } catch (error) { + console.error("연결 분석 실패:", error); + } finally { + setAnalyzing(false); + } + }; + + const handleDeploy = async () => { + if (!targetCompanyCode) return; + + setDeploying(true); + try { + let screensToSend: Array<{ + sourceScreenId: number; + screenName: string; + screenCode: string; + }>; + + if (isGroupMode) { + screensToSend = groupEntries + .filter((e) => e.included && e.newScreenCode) + .map((e) => ({ + sourceScreenId: e.screenId, + screenName: e.newScreenName, + screenCode: e.newScreenCode, + })); + } else { + if (!screen || !screenName || !screenCode) return; + screensToSend = [ + { + sourceScreenId: screen.screenId, + screenName, + screenCode, + }, + ...linkedScreens + .filter((ls) => ls.deploy) + .map((ls) => ({ + sourceScreenId: ls.screenId, + screenName: ls.newScreenName, + screenCode: ls.newScreenCode, + })), + ]; + } + + if (screensToSend.length === 0) { + toast.error("복사할 화면이 없습니다."); + return; + } + + const deployPayload: Parameters[0] = { + screens: screensToSend, + targetCompanyCode, + }; + + if (isGroupMode && groupInfo) { + deployPayload.groupStructure = groupInfo; + } + + const result = await screenApi.deployPopScreens(deployPayload); + + const groupMsg = result.createdGroups + ? ` (카테고리 ${result.createdGroups}개 생성)` + : ""; + toast.success( + `POP 화면 ${result.deployedScreens.length}개가 복사되었습니다.${groupMsg}`, + ); + onOpenChange(false); + onDeployed?.(); + } catch (error: any) { + toast.error(error?.response?.data?.message || "복사에 실패했습니다."); + } finally { + setDeploying(false); + } + }; + + const totalCount = isGroupMode + ? groupEntries.filter((e) => e.included).length + : 1 + linkedScreens.filter((ls) => ls.deploy).length; + + const canDeploy = isGroupMode + ? !deploying && targetCompanyCode && groupEntries.some((e) => e.included) + : !deploying && targetCompanyCode && screenName && screenCode; + + return ( + + + + + POP 화면 복사 + + + {isGroupMode + ? `"${groupName}" 카테고리의 화면 ${groupScreens!.length}개를 다른 회사로 복사합니다.` + : screen + ? `"${screen.screenName}" (ID: ${screen.screenId}) 화면을 다른 회사로 복사합니다.` + : "화면을 선택해주세요."} + + + +
+ {/* 대상 회사 선택 */} +
+ + +
+ + {/* ===== 그룹 모드: 카테고리 구조 + 화면 목록 ===== */} + {isGroupMode ? ( +
+ +
+ {groupInfo ? ( +
+ {/* 메인 카테고리 */} +
+ + {groupInfo.groupName} + + + 새 카테고리 생성 + +
+ {/* 메인 카테고리의 직접 화면 */} + {groupEntries + .filter((e) => groupInfo.screenIds.includes(e.screenId)) + .map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} + {/* 하위 카테고리들 */} + {groupInfo.children?.map((child) => ( +
+
+ + {child.groupName} +
+ {groupEntries + .filter((e) => + child.screenIds.includes(e.screenId), + ) + .map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} +
+ ))} +
+ ) : ( +
+ {groupEntries.map((entry) => ( +
+ { + setGroupEntries((prev) => + prev.map((e) => + e.screenId === entry.screenId + ? { ...e, included: !!checked } + : e, + ), + ); + }} + /> + + + {entry.screenName} + + + #{entry.screenId} + +
+ ))} +
+ )} +
+

+ 카테고리 구조와 화면 간 연결(cartScreenId 등)이 자동으로 + 복사됩니다. +

+
+ ) : ( + <> + {/* ===== 단일 모드: 화면명 + 코드 ===== */} +
+ + setScreenName(e.target.value)} + placeholder="화면 이름" + /> +
+ +
+ + +
+ + {/* 연결 화면 감지 */} + {analyzing ? ( +
+ + 연결된 화면을 분석 중입니다... +
+ ) : linkedScreens.length > 0 ? ( +
+
+ + 연결된 POP 화면 {linkedScreens.length}개 감지됨 +
+
+ {linkedScreens.map((ls) => ( +
+
+
{ls.screenName}
+
+ ID: {ls.screenId} |{" "} + {ls.references + .map((r) => r.referenceType) + .join(", ")} +
+
+
+ { + setLinkedScreens((prev) => + prev.map((item) => + item.screenId === ls.screenId + ? { ...item, deploy: !!checked } + : item, + ), + ); + }} + /> + 함께 복사 +
+
+ ))} +
+

+ 함께 복사하면 화면 간 연결(cartScreenId 등)이 새 ID로 자동 + 치환됩니다. +

+
+ ) : ( + !analyzing && ( +
+ 연결된 POP 화면이 없습니다. 이 화면만 복사됩니다. +
+ ) + )} + + )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/components/pop/management/PopScreenSettingModal.tsx b/frontend/components/pop/management/PopScreenSettingModal.tsx index 7dd7a11e..3c260423 100644 --- a/frontend/components/pop/management/PopScreenSettingModal.tsx +++ b/frontend/components/pop/management/PopScreenSettingModal.tsx @@ -165,19 +165,26 @@ export function PopScreenSettingModal({ try { setSaving(true); - // 화면 기본 정보 업데이트 const screenUpdate: Partial = { screenName, description: screenDescription, }; + // screen_definitions 테이블에 화면명/설명 업데이트 + if (screenName !== screen.screenName || screenDescription !== (screen.description || "")) { + await screenApi.updateScreenInfo(screen.screenId, { + screenName, + description: screenDescription, + isActive: "Y", + }); + } + // 레이아웃에 하위 화면 정보 저장 const currentLayout = await screenApi.getLayoutPop(screen.screenId); const updatedLayout = { ...currentLayout, version: "pop-1.0", subScreens: subScreens, - // flow 배열 자동 생성 (메인 → 각 서브) flow: subScreens.map((sub) => ({ from: sub.triggerFrom || "main", to: sub.id, @@ -201,11 +208,11 @@ export function PopScreenSettingModal({ return ( - - + + POP 화면 설정 - {screen.screenName} ({screen.screenCode}) + {screen.screenName} [{screen.screenCode}] @@ -214,57 +221,57 @@ export function PopScreenSettingModal({ onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0" > - + - - 개요 + + 기본 정보 - + 하위 화면 {subScreens.length > 0 && ( - + {subScreens.length} )} - + 화면 흐름 - {/* 개요 탭 */} - + {/* 기본 정보 탭 */} + {loading ? ( -
+
) : ( -
-
+
+
setScreenName(e.target.value)} - placeholder="화면 이름" + placeholder="화면 이름을 입력하세요" className="h-8 text-xs sm:h-10 sm:text-sm" />
-
+
@@ -282,7 +289,7 @@ export function PopScreenSettingModal({
-
+
@@ -290,13 +297,13 @@ export function PopScreenSettingModal({ id="description" value={screenDescription} onChange={(e) => setScreenDescription(e.target.value)} - placeholder="화면에 대한 설명" + placeholder="화면에 대한 설명을 입력하세요" rows={3} className="text-xs sm:text-sm resize-none" />
-
+
@@ -307,7 +314,7 @@ export function PopScreenSettingModal({ placeholder="lucide 아이콘 이름 (예: Package)" className="h-8 text-xs sm:h-10 sm:text-sm" /> -

+

lucide-react 아이콘 이름을 입력하세요.

@@ -316,19 +323,19 @@ export function PopScreenSettingModal({ {/* 하위 화면 탭 */} - -
+ +
-

- 이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다. +

+ 모달, 드로어 등 하위 화면을 관리합니다.

-
- + {subScreens.length === 0 ? (
@@ -339,12 +346,12 @@ export function PopScreenSettingModal({
) : (
- {subScreens.map((subScreen, index) => ( + {subScreens.map((subScreen) => (
- +
@@ -362,7 +369,7 @@ export function PopScreenSettingModal({ updateSubScreen(subScreen.id, "type", v) } > - + @@ -374,7 +381,7 @@ export function PopScreenSettingModal({
- + 트리거: onUpdate({ lookupMode: v as "auto" | "manual" })} + > + + + + + 자동 + 수동 + + +
+ {(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 +2022,15 @@ 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: "컴포넌트에서 수집한 데이터+매핑 응답" }, + { key: "selected_items", 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 e3e7dc4c..4ba1abc1 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -14,6 +14,7 @@ import { useRouter } from "next/navigation"; import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, + Trash2, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -28,12 +29,15 @@ import type { CardPresetSpec, CartItem, PackageEntry, + CollectDataRequest, + CollectedDataResponse, } from "../types"; import { DEFAULT_CARD_IMAGE, CARD_PRESET_SPECS, } from "../types"; import { dataApi } from "@/lib/api/data"; +import { screenApi } from "@/lib/api/screen"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "./NumberInputModal"; @@ -121,6 +125,28 @@ function MarqueeText({ ); } +// cart_items 행의 row_data JSON을 풀어서 __cart_ 접두사 메타데이터와 병합 +function parseCartRow(dbRow: Record): Record { + let rowData: Record = {}; + try { + const raw = dbRow.row_data; + if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw); + else if (typeof raw === "object" && raw !== null) rowData = raw as Record; + } catch { rowData = {}; } + + return { + ...rowData, + __cart_id: dbRow.id, + __cart_quantity: Number(dbRow.quantity) || 0, + __cart_package_unit: dbRow.package_unit || "", + __cart_package_entries: dbRow.package_entries, + __cart_status: dbRow.status || "in_cart", + __cart_memo: dbRow.memo || "", + __cart_row_key: dbRow.row_key || "", + __cart_modified: false, + }; +} + interface PopCardListComponentProps { config?: PopCardListConfig; className?: string; @@ -158,19 +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 [inheritedConfig, setInheritedConfig] = useState | null>(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + + // 장바구니 목록 모드: 원본 화면의 설정 전체를 병합 (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([]); @@ -219,9 +260,9 @@ export function PopCardListComponent({ const cartRef = useRef(cart); cartRef.current = cart; - // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 + // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 (장바구니 목록 모드 제외) useEffect(() => { - if (!componentId) return; + if (!componentId || isCartListMode) return; const unsub = subscribe( `__comp_input__${componentId}__cart_save_trigger`, async (payload: unknown) => { @@ -233,16 +274,16 @@ export function PopCardListComponent({ } ); return unsub; - }, [componentId, subscribe, publish]); + }, [componentId, subscribe, publish, isCartListMode]); - // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 + // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 (장바구니 목록 모드 제외) useEffect(() => { - if (!componentId || cart.loading) return; + if (!componentId || cart.loading || isCartListMode) return; publish(`__comp_output__${componentId}__cart_updated`, { count: cart.cartCount, isDirty: cart.isDirty, }); - }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]); + }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]); // 카드 선택 시 selected_row 이벤트 발행 const handleCardSelect = useCallback((row: RowData) => { @@ -278,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열 @@ -454,7 +495,69 @@ export function PopCardListComponent({ [dataSource] ); + // 장바구니 목록 모드 설정을 직렬화 (의존성 안정화) + const cartListModeKey = useMemo( + () => JSON.stringify(config?.cartListMode || null), + [config?.cartListMode] + ); + useEffect(() => { + // 장바구니 목록 모드: cart_items에서 직접 조회 + if (isCartListMode) { + const cartListMode = config!.cartListMode!; + + // 원본 화면 미선택 시 데이터 조회하지 않음 + if (!cartListMode.sourceScreenId) { + setLoading(false); + setRows([]); + return; + } + + const fetchCartData = async () => { + setLoading(true); + setError(null); + try { + // 원본 화면 레이아웃에서 설정 전체 상속 (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 폴백 + } + + const cartFilters: Record = { + status: cartListMode.statusFilter || "in_cart", + }; + if (cartListMode.sourceScreenId) { + cartFilters.screen_id = String(cartListMode.sourceScreenId); + } + const result = await dataApi.getTableData("cart_items", { + size: 500, + filters: cartFilters, + }); + + const parsed = (result.data || []).map(parseCartRow); + setRows(parsed); + } catch (err) { + const message = err instanceof Error ? err.message : "장바구니 데이터 조회 실패"; + setError(message); + setRows([]); + } finally { + setLoading(false); + } + }; + fetchCartData(); + return; + } + + // 기본 모드: 데이터 소스에서 조회 if (!dataSource?.tableName) { setLoading(false); setRows([]); @@ -467,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; } }); @@ -499,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); @@ -510,16 +638,90 @@ export function PopCardListComponent({ }; fetchData(); - }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps + }, [dataSourceKey, isCartListMode, cartListModeKey]); // eslint-disable-line react-hooks/exhaustive-deps // 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시) useEffect(() => { - if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) { - const imageColumn = template.image.imageColumn; + if (!loading && rows.length > 0 && effectiveTemplate?.image?.enabled && effectiveTemplate?.image?.imageColumn) { + const imageColumn = effectiveTemplate.image.imageColumn; missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length; } - }, [loading, rows, template?.image]); + }, [loading, rows, effectiveTemplate?.image]); + // 장바구니 목록 모드: 항목 삭제 콜백 + const handleDeleteItem = useCallback((cartId: string) => { + setRows(prev => prev.filter(r => String(r.__cart_id) !== cartId)); + setSelectedKeys(prev => { + const next = new Set(prev); + next.delete(cartId); + return next; + }); + }, []); + + // 장바구니 목록 모드: 수량 수정 콜백 (로컬만 업데이트, DB 미반영) + const handleUpdateQuantity = useCallback(( + cartId: string, + quantity: number, + unit?: string, + entries?: PackageEntry[], + ) => { + setRows(prev => prev.map(r => { + if (String(r.__cart_id) !== cartId) return r; + return { + ...r, + __cart_quantity: quantity, + __cart_package_unit: unit || r.__cart_package_unit, + __cart_package_entries: entries || r.__cart_package_entries, + __cart_modified: true, + }; + })); + }, []); + + // 데이터 수집 요청 수신: 버튼에서 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 ?? ""))); + publish(`__comp_output__${componentId}__selected_items`, selectedItems); + }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); // 카드 영역 스타일 const cardAreaStyle: React.CSSProperties = { @@ -549,7 +751,13 @@ export function PopCardListComponent({ ref={containerRef} className={`flex h-full w-full flex-col ${className || ""}`} > - {!dataSource?.tableName ? ( + {isCartListMode && !config?.cartListMode?.sourceScreenId ? ( +
+

+ 원본 화면을 선택해주세요. +

+
+ ) : !isCartListMode && !dataSource?.tableName ? (

데이터 소스를 설정해주세요. @@ -569,6 +777,27 @@ export function PopCardListComponent({

) : ( <> + {/* 장바구니 목록 모드: 선택 바 */} + {isCartListMode && ( +
+ 0} + onChange={(e) => { + if (e.target.checked) { + setSelectedKeys(new Set(filteredRows.map(r => String(r.__cart_id ?? "")))); + } else { + setSelectedKeys(new Set()); + } + }} + className="h-4 w-4 rounded border-input" + /> + + {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"} + +
+ )} + {/* 카드 영역 (스크롤 가능) */}
{displayCards.map((row, index) => { - const codeValue = template?.header?.codeField && row[template.header.codeField] - ? String(row[template.header.codeField]) + const codeValue = effectiveTemplate?.header?.codeField && row[effectiveTemplate.header.codeField] + ? String(row[effectiveTemplate.header.codeField]) : null; const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`; return ( { + 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); + else next.add(cartId); + return next; + }); + }} + onDeleteItem={handleDeleteItem} + onUpdateQuantity={handleUpdateQuantity} /> ); })} @@ -679,8 +922,13 @@ function Card({ router, onSelect, cart, - codeFieldName, + keyColumnName, parentComponentId, + isCartListMode, + isSelected, + onToggleSelect, + onDeleteItem, + onUpdateQuantity, }: { row: RowData; template?: CardTemplateConfig; @@ -692,8 +940,13 @@ function Card({ router: ReturnType; onSelect?: (row: RowData) => void; cart: ReturnType; - codeFieldName?: string; + keyColumnName?: string; parentComponentId?: string; + isCartListMode?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + onDeleteItem?: (cartId: string) => void; + onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void; }) { const header = template?.header; const image = template?.image; @@ -707,19 +960,28 @@ 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); - // DB에서 로드된 장바구니 품목이면 입력값 복원 + // DB에서 로드된 장바구니 품목이면 입력값 복원 (기본 모드) useEffect(() => { + if (isCartListMode) return; if (existingCartItem && existingCartItem._origin === "db") { setInputValue(existingCartItem.quantity); setPackageUnit(existingCartItem.packageUnit); setPackageEntries(existingCartItem.packageEntries || []); } - }, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + }, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + + // 장바구니 목록 모드: __cart_quantity에서 초기값 복원 + useEffect(() => { + if (!isCartListMode) return; + const cartQty = Number(row.__cart_quantity) || 0; + setInputValue(cartQty); + const cartUnit = row.__cart_package_unit ? String(row.__cart_package_unit) : undefined; + setPackageUnit(cartUnit); + }, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]); const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] @@ -771,6 +1033,9 @@ function Card({ setInputValue(value); setPackageUnit(unit); setPackageEntries(entries || []); + if (isCartListMode) { + onUpdateQuantity?.(String(row.__cart_id), value, unit, entries); + } }; // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달 @@ -806,6 +1071,23 @@ function Card({ } }; + // 장바구니 목록 모드: 개별 삭제 + const handleCartDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; + if (!cartId) return; + + const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?"); + if (!ok) return; + + try { + await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" }); + onDeleteItem?.(cartId); + } catch { + toast.error("삭제에 실패했습니다."); + } + }; + // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11))); const cartLabel = cartAction?.label || "담기"; @@ -815,13 +1097,23 @@ function Card({ onSelect?.(row); }; + // 카드 테두리: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준 + const borderClass = isCartListMode + ? isSelected + ? "border-primary border-2 hover:border-primary/80" + : "hover:border-2 hover:border-blue-500" + : isCarted + ? "border-emerald-500 border-2 hover:border-emerald-600" + : "hover:border-2 hover:border-blue-500"; + + // 카드 헤더 배경: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준 + const headerBgClass = isCartListMode + ? isSelected ? "bg-primary/10 dark:bg-primary/20" : "bg-muted/30" + : isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"; + return (
{ if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > {/* 헤더 영역 */} - {(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 && (
- {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */} - {(inputField?.enabled || cartAction) && ( + {/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */} + {(inputField?.enabled || cartAction || isCartListMode) && (
{inputValue.toLocaleString()} @@ -914,8 +1215,22 @@ function Card({ )} - {/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */} - {cartAction && ( + {/* 장바구니 목록 모드: 삭제 버튼 */} + {isCartListMode && ( + + )} + + {/* 기본 모드: 담기/취소 버튼 (cartAction 존재 시) */} + {!isCartListMode && cartAction && ( <> {isCarted ? ( @@ -183,7 +187,16 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC /> )} {activeTab === "template" && ( - + isCartListMode ? ( +
+
+

원본 화면의 카드 설정을 자동으로 사용합니다

+

카드 디자인을 변경하려면 원본 화면에서 수정하세요

+
+
+ ) : ( + + ) )}
@@ -299,46 +312,58 @@ function BasicSettingsTab({ } }, [currentMode]); // eslint-disable-line react-hooks/exhaustive-deps + const isCartListMode = !!config.cartListMode?.enabled; + const updateDataSource = (partial: Partial) => { onUpdate({ dataSource: { ...dataSource, ...partial } }); }; return (
- {/* 테이블 선택 */} - -
-
- - { - onUpdate({ - dataSource: { - tableName: val, - joins: undefined, - filters: undefined, - sort: undefined, - limit: undefined, - }, - cardTemplate: DEFAULT_TEMPLATE, - }); - }} - /> -
- - {dataSource.tableName && ( -
- - {dataSource.tableName} -
- )} -
+ {/* 장바구니 목록 모드 */} + + onUpdate({ cartListMode })} + /> - {/* 조인 설정 (테이블 선택 시만 표시) */} - {dataSource.tableName && ( + {/* 테이블 선택 (장바구니 모드 시 숨김) */} + {!isCartListMode && ( + +
+
+ + { + onUpdate({ + dataSource: { + tableName: val, + joins: undefined, + filters: undefined, + sort: undefined, + limit: undefined, + }, + cardTemplate: DEFAULT_TEMPLATE, + }); + }} + /> +
+ + {dataSource.tableName && ( +
+ + {dataSource.tableName} +
+ )} +
+
+ )} + + {/* 조인 설정 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( )} - {/* 정렬 기준 (테이블 선택 시만 표시) */} - {dataSource.tableName && ( + {/* 정렬 기준 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( )} + {/* 필터 기준 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( + 0 + ? `${dataSource.filters.length}개` + : undefined + } + > + + + )} + + {/* 저장 매핑 (장바구니 모드일 때만) */} + {isCartListMode && ( + 0 + ? `${config.saveMapping.mappings.length}개` + : undefined + } + > + onUpdate({ saveMapping })} + cartListMode={config.cartListMode} + /> + + )} + {/* 레이아웃 설정 */}
@@ -643,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 // ===== 테이블별 그룹화된 컬럼 셀렉트 ===== @@ -838,6 +807,164 @@ function CollapsibleSection({ ); } +// ===== 장바구니 목록 모드 설정 ===== + +interface SourceCardListInfo { + componentId: string; + label: string; +} + +function CartListModeSection({ + cartListMode, + onUpdate, +}: { + cartListMode?: CartListModeConfig; + onUpdate: (config: CartListModeConfig) => void; +}) { + const mode: CartListModeConfig = cartListMode || { enabled: false }; + const [screens, setScreens] = useState<{ id: number; name: string }[]>([]); + const [sourceCardLists, setSourceCardLists] = useState([]); + const [loadingComponents, setLoadingComponents] = useState(false); + + // 화면 목록 로드 + useEffect(() => { + screenApi + .getScreens({ size: 500 }) + .then((res) => { + if (res?.data) { + setScreens( + res.data.map((s) => ({ + id: s.screenId, + name: s.screenName || `화면 ${s.screenId}`, + })) + ); + } + }) + .catch(() => {}); + }, []); + + // 원본 화면 선택 시 -> 해당 화면의 pop-card-list 컴포넌트 목록 로드 + useEffect(() => { + if (!mode.sourceScreenId) { + setSourceCardLists([]); + return; + } + setLoadingComponents(true); + screenApi + .getLayoutPop(mode.sourceScreenId) + .then((layoutJson: any) => { + const componentsMap = layoutJson?.components || {}; + const componentList = Object.values(componentsMap) as any[]; + const cardLists: SourceCardListInfo[] = componentList + .filter((c: any) => c.type === "pop-card-list") + .map((c: any) => ({ + componentId: c.id || "", + label: c.label || "카드 목록", + })); + setSourceCardLists(cardLists); + }) + .catch(() => { + setSourceCardLists([]); + }) + .finally(() => setLoadingComponents(false)); + }, [mode.sourceScreenId]); + + const handleScreenChange = (val: string) => { + const screenId = val === "__none__" ? undefined : Number(val); + onUpdate({ ...mode, sourceScreenId: screenId }); + }; + + const handleComponentSelect = (val: string) => { + if (val === "__none__") { + onUpdate({ ...mode, sourceComponentId: undefined }); + return; + } + const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val; + const found = sourceCardLists.find((c) => c.componentId === compId); + if (found) { + onUpdate({ ...mode, sourceComponentId: found.componentId }); + } + }; + + return ( +
+
+ + onUpdate({ ...mode, enabled })} + /> +
+ +

+ 활성화하면 cart_items 테이블에서 데이터를 조회하고, + 원본 화면의 카드 설정을 자동으로 상속합니다. +

+ + {mode.enabled && ( + <> + {/* 원본 화면 선택 */} +
+ + +
+ + {/* 원본 컴포넌트 선택 (원본 화면에서 자동 로드) */} + {mode.sourceScreenId && ( +
+ + {loadingComponents ? ( +
+ 로딩 중... +
+ ) : sourceCardLists.length === 0 ? ( +
+ 원본 화면에 담기 설정이 있는 카드 목록이 없습니다. +
+ ) : ( + + )} +

+ 원본 화면의 카드 디자인이 자동으로 적용됩니다. +

+
+ )} + + )} +
+ ); +} + // ===== 헤더 설정 섹션 ===== function HeaderSettingsSection({ @@ -2133,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({ @@ -2197,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)

)} @@ -2410,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 738dfa4c..b9b769af 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -60,13 +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_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: "event", category: "event", 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: "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 new file mode 100644 index 00000000..c646dfd6 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx @@ -0,0 +1,762 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Loader2 } from "lucide-react"; +import { usePopEvent } from "@/hooks/pop"; +import { dataApi } from "@/lib/api/data"; +import type { + PopFieldConfig, + PopFieldItem, + PopFieldSection, + FieldSectionStyle, + PopFieldReadSource, + PopFieldAutoGenMapping, +} from "./types"; +import type { CollectDataRequest, CollectedDataResponse } from "../types"; +import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types"; + +// ======================================== +// Props +// ======================================== + +interface PopFieldComponentProps { + config?: PopFieldConfig; + screenId?: string; + componentId?: string; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +export function PopFieldComponent({ + config, + screenId, + componentId, +}: PopFieldComponentProps) { + const cfg: PopFieldConfig = { + ...DEFAULT_FIELD_CONFIG, + ...config, + sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections, + }; + const { publish, subscribe } = usePopEvent(screenId || "default"); + const containerRef = useRef(null); + + const [allValues, setAllValues] = useState>({}); + const [hiddenValues, setHiddenValues] = useState>({}); + const [errors, setErrors] = useState>({}); + const [containerWidth, setContainerWidth] = useState(0); + + const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? []; + const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; + const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm); + + // ResizeObserver로 컨테이너 너비 감시 + useEffect(() => { + if (typeof window === "undefined" || !containerRef.current) return; + const ro = new ResizeObserver(([entry]) => { + setContainerWidth(entry.contentRect.width); + }); + ro.observe(containerRef.current); + return () => ro.disconnect(); + }, []); + + // readSource 기반 DB 조회 + JSON 파싱 + const fetchReadSourceData = useCallback( + async (pkValue: unknown, readSource: PopFieldReadSource) => { + if (!readSource.tableName || !readSource.pkColumn || !pkValue) return; + try { + const res = await dataApi.getTableData(readSource.tableName, { + page: 1, + size: 1, + filters: { [readSource.pkColumn]: String(pkValue) }, + }); + if (!Array.isArray(res.data) || res.data.length === 0) return; + const row = res.data[0] as Record; + + const extracted: Record = {}; + for (const mapping of readSource.fieldMappings || []) { + if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) { + const raw = row[mapping.columnName]; + let parsed: Record = {}; + if (typeof raw === "string") { + try { parsed = JSON.parse(raw); } catch { /* ignore */ } + } else if (typeof raw === "object" && raw !== null) { + parsed = raw as Record; + } + extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? ""; + } else if (mapping.valueSource === "db_column" && mapping.columnName) { + extracted[mapping.fieldId] = row[mapping.columnName] ?? ""; + } + } + + const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []); + const valuesUpdate: Record = {}; + for (const [fieldId, val] of Object.entries(extracted)) { + const f = allFieldsInConfig.find((fi) => fi.id === fieldId); + const key = f?.fieldName || f?.id || fieldId; + valuesUpdate[key] = val; + } + if (Object.keys(valuesUpdate).length > 0) { + setAllValues((prev) => ({ ...prev, ...valuesUpdate })); + } + } catch { + // 조회 실패 시 무시 + } + }, + [cfg.sections] + ); + + // set_value 이벤트 수신 (useConnectionResolver의 enrichedPayload도 처리) + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__set_value`, + (payload: unknown) => { + const raw = payload as Record | undefined; + if (!raw) return; + + // useConnectionResolver가 감싼 enrichedPayload인지 확인 + const isConnectionPayload = raw._connectionId !== undefined; + const actual = isConnectionPayload + ? (raw.value as Record | undefined) + : raw; + if (!actual) return; + + const data = actual as { + fieldName?: string; + value?: unknown; + values?: Record; + pkValue?: unknown; + }; + + // row 객체가 통째로 온 경우 (pop-card-list selected_row 등) + if (!data.fieldName && !data.values && !data.pkValue && typeof actual === "object") { + const rowObj = actual as Record; + setAllValues((prev) => ({ ...prev, ...rowObj })); + // 숨은 필드 값 추출 (valueSource 기반) + if (hiddenMappings.length > 0) { + const extracted: Record = {}; + for (const hm of hiddenMappings) { + if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + if (rowObj[hm.sourceDbColumn] !== undefined) { + extracted[hm.targetColumn] = rowObj[hm.sourceDbColumn]; + } + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const raw = rowObj[hm.sourceJsonColumn]; + let parsed: Record = {}; + if (typeof raw === "string") { + try { parsed = JSON.parse(raw); } catch { /* ignore */ } + } else if (typeof raw === "object" && raw !== null) { + parsed = raw as Record; + } + if (parsed[hm.sourceJsonKey] !== undefined) { + extracted[hm.targetColumn] = parsed[hm.sourceJsonKey]; + } + } + } + if (Object.keys(extracted).length > 0) { + setHiddenValues((prev) => ({ ...prev, ...extracted })); + } + } + const pkCol = cfg.readSource?.pkColumn; + const pkVal = pkCol ? rowObj[pkCol] : undefined; + if (pkVal && cfg.readSource) { + fetchReadSourceData(pkVal, cfg.readSource); + } + return; + } + + if (data.values) { + setAllValues((prev) => ({ ...prev, ...data.values })); + } else if (data.fieldName) { + setAllValues((prev) => ({ + ...prev, + [data.fieldName!]: data.value, + })); + } + if (data.pkValue && cfg.readSource) { + fetchReadSourceData(data.pkValue, cfg.readSource); + } + } + ); + 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) => { + setAllValues((prev) => { + const next = { ...prev, [fieldName]: value }; + if (componentId) { + publish(`__comp_output__${componentId}__value_changed`, { + fieldName, + value, + allValues: next, + hiddenValues, + targetTable: cfg.saveConfig?.tableName || cfg.targetTable, + saveConfig: cfg.saveConfig, + readSource: cfg.readSource, + }); + } + return next; + }); + setErrors((prev) => { + if (!prev[fieldName]) return prev; + const next = { ...prev }; + delete next[fieldName]; + return next; + }); + }, + [componentId, publish, cfg.targetTable, cfg.saveConfig, cfg.readSource, hiddenValues] + ); + + // readSource 설정 시 자동 샘플 데이터 조회 (디자인 모드 미리보기) + const readSourceKey = cfg.readSource + ? `${cfg.readSource.tableName}__${cfg.readSource.pkColumn}__${(cfg.readSource.fieldMappings || []).map((m) => `${m.fieldId}:${m.columnName}:${m.jsonKey || ""}`).join(",")}` + : ""; + const previewFetchedRef = useRef(""); + useEffect(() => { + if (!cfg.readSource?.tableName || !cfg.readSource.fieldMappings?.length) return; + if (previewFetchedRef.current === readSourceKey) return; + previewFetchedRef.current = readSourceKey; + + (async () => { + try { + const res = await dataApi.getTableData(cfg.readSource!.tableName, { + page: 1, + size: 1, + }); + if (!Array.isArray(res.data) || res.data.length === 0) return; + const row = res.data[0] as Record; + + const extracted: Record = {}; + for (const mapping of cfg.readSource!.fieldMappings || []) { + if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) { + const rawVal = row[mapping.columnName]; + let parsed: Record = {}; + if (typeof rawVal === "string") { + try { parsed = JSON.parse(rawVal); } catch { /* ignore */ } + } else if (typeof rawVal === "object" && rawVal !== null) { + parsed = rawVal as Record; + } + extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? ""; + } else if (mapping.valueSource === "db_column" && mapping.columnName) { + extracted[mapping.fieldId] = row[mapping.columnName] ?? ""; + } + } + + const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []); + const valuesUpdate: Record = {}; + for (const [fieldId, val] of Object.entries(extracted)) { + const f = allFieldsInConfig.find((fi) => fi.id === fieldId); + const key = f?.fieldName || f?.id || fieldId; + valuesUpdate[key] = val; + } + if (Object.keys(valuesUpdate).length > 0) { + setAllValues((prev) => ({ ...prev, ...valuesUpdate })); + } + } catch { + // 미리보기 조회 실패 시 무시 + } + })(); + }, [readSourceKey, cfg.readSource, cfg.sections]); + + // "auto" 열 수 계산 + function resolveColumns( + columns: "auto" | 1 | 2 | 3 | 4, + fieldCount: number + ): number { + if (columns !== "auto") return columns; + if (containerWidth < 200) return 1; + if (containerWidth < 400) return Math.min(2, fieldCount); + if (containerWidth < 600) return Math.min(3, fieldCount); + return Math.min(4, fieldCount); + } + + function migrateStyle(style: string): FieldSectionStyle { + if (style === "display" || style === "input") return style; + if (style === "summary") return "display"; + if (style === "form") return "input"; + return "input"; + } + + function sectionClassName(section: PopFieldSection): string { + const resolved = migrateStyle(section.style); + const defaults = DEFAULT_SECTION_APPEARANCES[resolved]; + const a = section.appearance || {}; + const bg = a.bgColor || defaults.bgColor; + const border = a.borderColor || defaults.borderColor; + return cn("rounded-lg border px-4", bg, border, resolved === "display" ? "py-2" : "py-3"); + } + + return ( +
+ {cfg.sections.map((section) => { + const fields = section.fields || []; + const fieldCount = fields.length; + if (fieldCount === 0) return null; + const cols = resolveColumns(section.columns, fieldCount); + return ( +
+ {section.label && ( +
+ {section.label} +
+ )} +
+ {fields.map((field) => { + const fKey = field.fieldName || field.id; + return ( + + ); + })} +
+
+ ); + })} + {visibleAutoGens.length > 0 && ( +
+
+ {visibleAutoGens.map((ag) => ( + + ))} +
+
+ )} +
+ ); +} + +// ======================================== +// FieldRenderer: 개별 필드 렌더링 +// ======================================== + +interface FieldRendererProps { + field: PopFieldItem; + value: unknown; + showLabel: boolean; + error?: string; + onChange: (fieldName: string, value: unknown) => void; + sectionStyle: FieldSectionStyle; +} + +function FieldRenderer({ + field, + value, + showLabel, + error, + onChange, + sectionStyle, +}: FieldRendererProps) { + const handleChange = useCallback( + (v: unknown) => onChange(field.fieldName, v), + [onChange, field.fieldName] + ); + + const resolvedStyle = sectionStyle === "summary" ? "display" : sectionStyle === "form" ? "input" : sectionStyle; + const inputClassName = cn( + "h-9 w-full rounded-md border px-3 text-sm", + field.readOnly + ? "cursor-default bg-muted text-muted-foreground" + : "bg-background", + resolvedStyle === "display" && + "border-transparent bg-transparent text-sm font-medium" + ); + + return ( +
+ {showLabel && field.labelText && ( + + )} + {renderByType(field, value, handleChange, inputClassName)} + {error &&

{error}

} +
+ ); +} + +// ======================================== +// 서브타입별 렌더링 분기 +// ======================================== + +function renderByType( + field: PopFieldItem, + value: unknown, + onChange: (v: unknown) => void, + className: string +) { + switch (field.inputType) { + case "text": + return ( + onChange(e.target.value)} + readOnly={field.readOnly} + placeholder={field.placeholder} + className={className} + /> + ); + case "number": + return ( + + ); + case "date": + return ( + onChange(e.target.value)} + readOnly={field.readOnly} + className={className} + /> + ); + case "select": + return ( + + ); + case "auto": + return ; + case "numpad": + return ( + + ); + default: + return ( + + ); + } +} + +// ======================================== +// NumberFieldInput +// ======================================== + +function NumberFieldInput({ + field, + value, + onChange, + className, +}: { + field: PopFieldItem; + value: unknown; + onChange: (v: unknown) => void; + className: string; +}) { + return ( +
+ { + const num = e.target.value === "" ? "" : Number(e.target.value); + onChange(num); + }} + readOnly={field.readOnly} + placeholder={field.placeholder} + min={field.validation?.min} + max={field.validation?.max} + className={cn(className, "flex-1")} + /> + {field.unit && ( + + {field.unit} + + )} +
+ ); +} + +// ======================================== +// SelectFieldInput +// ======================================== + +function SelectFieldInput({ + field, + value, + onChange, + className, +}: { + field: PopFieldItem; + value: unknown; + onChange: (v: unknown) => void; + className: string; +}) { + const [options, setOptions] = useState<{ value: string; label: string }[]>( + [] + ); + const [loading, setLoading] = useState(false); + + const source = field.selectSource; + + useEffect(() => { + if (!source) return; + + if (source.type === "static" && source.staticOptions) { + setOptions(source.staticOptions); + return; + } + + if ( + source.type === "table" && + source.tableName && + source.valueColumn && + source.labelColumn + ) { + setLoading(true); + dataApi + .getTableData(source.tableName, { + page: 1, + pageSize: 500, + sortColumn: source.labelColumn, + sortDirection: "asc", + }) + .then((res) => { + if (res.data?.success && Array.isArray(res.data.data?.data)) { + setOptions( + res.data.data.data.map((row: Record) => ({ + value: String(row[source.valueColumn!] ?? ""), + label: String(row[source.labelColumn!] ?? ""), + })) + ); + } + }) + .catch(() => { + setOptions([]); + }) + .finally(() => setLoading(false)); + } + }, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (field.readOnly) { + const selectedLabel = + options.find((o) => o.value === String(value ?? ""))?.label ?? + String(value ?? "-"); + return ( + + ); + } + + if (!source) { + return ( +
+ 옵션 소스를 설정해주세요 +
+ ); + } + + return ( + + ); +} + +// ======================================== +// AutoFieldInput (자동 채번 - 읽기전용) +// ======================================== + +function AutoFieldInput({ + field, + value, + className, +}: { + field: PopFieldItem; + value: unknown; + className: string; +}) { + const displayValue = useMemo(() => { + if (value) return String(value); + if (!field.autoNumber) return "자동생성"; + + const { prefix, separator, dateFormat, sequenceDigits } = field.autoNumber; + const parts: string[] = []; + if (prefix) parts.push(prefix); + if (dateFormat) { + const now = new Date(); + const dateStr = dateFormat + .replace("YYYY", String(now.getFullYear())) + .replace("MM", String(now.getMonth() + 1).padStart(2, "0")) + .replace("DD", String(now.getDate()).padStart(2, "0")); + parts.push(dateStr); + } + if (sequenceDigits) { + parts.push("0".repeat(sequenceDigits)); + } + return parts.join(separator || "-") || "자동생성"; + }, [value, field.autoNumber]); + + return ( + + ); +} + +// ======================================== +// AutoGenFieldDisplay (자동생성 필드 - showInForm일 때 표시) +// ======================================== + +function AutoGenFieldDisplay({ mapping }: { mapping: PopFieldAutoGenMapping }) { + return ( +
+ {mapping.label && ( + + )} +
+ + 저장 시 자동발급 + +
+
+ ); +} + +// ======================================== +// NumpadFieldInput (클릭 시 숫자 직접 입력) +// ======================================== + +function NumpadFieldInput({ + field, + value, + onChange, + className, +}: { + field: PopFieldItem; + value: unknown; + onChange: (v: unknown) => void; + className: string; +}) { + const displayValue = + value !== undefined && value !== null ? String(value) : ""; + + return ( +
+ { + const num = e.target.value === "" ? "" : Number(e.target.value); + onChange(num); + }} + readOnly={field.readOnly} + placeholder={field.placeholder || "수량 입력"} + className={cn(className, "flex-1")} + /> + {field.unit && ( + + {field.unit} + + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx new file mode 100644 index 00000000..4a285f20 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx @@ -0,0 +1,2220 @@ +"use client"; + +/** + * pop-field 설정 패널 + * + * 구조: + * - [레이아웃 탭] 섹션 목록 (추가/삭제/이동) + 필드 편집 + * - [저장 탭] 저장 테이블 / 필드-컬럼 매핑 / 읽기 데이터 소스 + */ + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + ChevronDown, + ChevronRight, + Plus, + Trash2, + GripVertical, + Check, + ChevronsUpDown, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + 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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import type { + PopFieldConfig, + PopFieldSection, + PopFieldItem, + FieldInputType, + FieldSectionStyle, + FieldSectionAppearance, + FieldSelectSource, + AutoNumberConfig, + FieldValueSource, + PopFieldSaveMapping, + PopFieldSaveConfig, + PopFieldReadMapping, + PopFieldHiddenMapping, + PopFieldAutoGenMapping, + HiddenValueSource, +} from "./types"; +import { + DEFAULT_FIELD_CONFIG, + DEFAULT_SECTION_APPEARANCES, + FIELD_INPUT_TYPE_LABELS, + FIELD_SECTION_STYLE_LABELS, +} from "./types"; +import { + fetchTableList, + fetchTableColumns, + type TableInfo, + type ColumnInfo, +} from "../pop-dashboard/utils/dataFetcher"; +import { dataApi } from "@/lib/api/data"; +import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; + +// ======================================== +// Props +// ======================================== + +interface PopFieldConfigPanelProps { + config: PopFieldConfig | undefined; + onUpdate: (config: PopFieldConfig) => void; +} + +// ======================================== +// 메인 설정 패널 +// ======================================== + +export function PopFieldConfigPanel({ + config, + onUpdate: onConfigChange, +}: PopFieldConfigPanelProps) { + const cfg: PopFieldConfig = { + ...DEFAULT_FIELD_CONFIG, + ...config, + sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections, + }; + const [tables, setTables] = useState([]); + const [saveTableOpen, setSaveTableOpen] = useState(false); + const [readTableOpen, setReadTableOpen] = useState(false); + + const saveTableName = cfg.saveConfig?.tableName ?? cfg.targetTable ?? ""; + + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + const updateConfig = useCallback( + (partial: Partial) => { + onConfigChange({ ...cfg, ...partial }); + }, + [cfg, onConfigChange] + ); + + const updateSection = useCallback( + (sectionId: string, partial: Partial) => { + const sections = cfg.sections.map((s) => + s.id === sectionId ? { ...s, ...partial } : s + ); + updateConfig({ sections }); + }, + [cfg.sections, updateConfig] + ); + + const addSection = useCallback(() => { + const newId = `section_${Date.now()}`; + const newSection: PopFieldSection = { + id: newId, + style: "input", + columns: "auto", + showLabels: true, + fields: [], + }; + updateConfig({ sections: [...cfg.sections, newSection] }); + }, [cfg.sections, updateConfig]); + + const removeSection = useCallback( + (sectionId: string) => { + if (cfg.sections.length <= 1) return; + updateConfig({ sections: cfg.sections.filter((s) => s.id !== sectionId) }); + }, + [cfg.sections, updateConfig] + ); + + const moveSectionUp = useCallback( + (index: number) => { + if (index <= 0) return; + const sections = [...cfg.sections]; + [sections[index - 1], sections[index]] = [ + sections[index], + sections[index - 1], + ]; + updateConfig({ sections }); + }, + [cfg.sections, updateConfig] + ); + + const handleSaveTableChange = useCallback( + (tableName: string) => { + const next = tableName === saveTableName ? "" : tableName; + const saveConfig: PopFieldSaveConfig = { + ...cfg.saveConfig, + tableName: next, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + }; + updateConfig({ saveConfig, targetTable: next }); + }, + [cfg.saveConfig, saveTableName, updateConfig] + ); + + const allFields = useMemo(() => { + return cfg.sections.flatMap((s) => + (s.fields ?? []).map((f) => ({ field: f, section: s })) + ); + }, [cfg.sections]); + + const displayFields = useMemo(() => { + return cfg.sections + .filter((s) => migrateStyle(s.style) === "display") + .flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s }))); + }, [cfg.sections]); + + const inputFields = useMemo(() => { + return cfg.sections + .filter((s) => migrateStyle(s.style) === "input") + .flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s }))); + }, [cfg.sections]); + + const hasDisplayFields = displayFields.length > 0; + const hasInputFields = inputFields.length > 0; + + return ( + + + + 레이아웃 + + + 저장 + + + + + {cfg.sections.map((section, idx) => ( + 1} + onUpdate={(partial) => updateSection(section.id, partial)} + onRemove={() => removeSection(section.id)} + onMoveUp={() => moveSectionUp(idx)} + /> + ))} + + + + + + + + ); +} + +// ======================================== +// SaveTabContent: 저장 탭 (필드 중심 순차 설정) +// 1. 테이블 설정 (읽기/저장 테이블 + PK) +// 2. 읽기 필드 매핑 (display 섹션) +// 3. 입력 필드 매핑 (input 섹션) +// ======================================== + +interface SaveTabContentProps { + cfg: PopFieldConfig; + tables: TableInfo[]; + saveTableName: string; + saveTableOpen: boolean; + setSaveTableOpen: (v: boolean) => void; + readTableOpen: boolean; + setReadTableOpen: (v: boolean) => void; + allFields: { field: PopFieldItem; section: PopFieldSection }[]; + displayFields: { field: PopFieldItem; section: PopFieldSection }[]; + inputFields: { field: PopFieldItem; section: PopFieldSection }[]; + hasDisplayFields: boolean; + hasInputFields: boolean; + onSaveTableChange: (tableName: string) => void; + onUpdateConfig: (partial: Partial) => void; +} + +function SaveTabContent({ + cfg, + tables, + saveTableName, + saveTableOpen, + setSaveTableOpen, + readTableOpen, + setReadTableOpen, + allFields, + displayFields, + inputFields, + hasDisplayFields, + hasInputFields, + onSaveTableChange, + onUpdateConfig, +}: SaveTabContentProps) { + const [saveColumns, setSaveColumns] = useState([]); + const [readColumns, setReadColumns] = useState([]); + const [jsonKeysMap, setJsonKeysMap] = useState>({}); + + useEffect(() => { + if (saveTableName) { + fetchTableColumns(saveTableName).then(setSaveColumns); + } else { + setSaveColumns([]); + } + }, [saveTableName]); + + const readTableName = cfg.readSource?.tableName ?? ""; + const readSameAsSave = readTableName === saveTableName && !!saveTableName; + const readTableForFetch = readSameAsSave ? saveTableName : readTableName; + + useEffect(() => { + if (readTableForFetch) { + fetchTableColumns(readTableForFetch).then(setReadColumns); + } else { + setReadColumns([]); + } + }, [readTableForFetch]); + + const fetchJsonKeysForColumn = useCallback( + async (tableName: string, columnName: string) => { + const cacheKey = `${tableName}__${columnName}`; + if (jsonKeysMap[cacheKey]) return; + try { + const result = await dataApi.getTableData(tableName, { page: 1, size: 1 }); + const row = result.data?.[0]; + if (!row || !row[columnName]) return; + const raw = row[columnName]; + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + setJsonKeysMap((prev) => ({ ...prev, [cacheKey]: Object.keys(parsed).sort() })); + } + } catch { + // JSON 파싱 실패 시 무시 + } + }, + [jsonKeysMap] + ); + + const getJsonKeys = useCallback( + (tableName: string, columnName: string): string[] => { + return jsonKeysMap[`${tableName}__${columnName}`] ?? []; + }, + [jsonKeysMap] + ); + + // --- 저장 매핑 로직 --- + const saveMappings = cfg.saveConfig?.fieldMappings ?? []; + + const getSaveMappingForField = (fieldId: string): PopFieldSaveMapping => { + return ( + saveMappings.find((x) => x.fieldId === fieldId) ?? { + fieldId, + valueSource: "direct", + targetColumn: "", + } + ); + }; + + const syncAndUpdateSaveMappings = useCallback( + (updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[]) => { + const fieldIds = new Set(allFields.map(({ field }) => field.id)); + const prev = saveMappings.filter((m) => fieldIds.has(m.fieldId)); + const next = updater ? updater(prev) : prev; + const added = allFields.filter( + ({ field }) => !next.some((m) => m.fieldId === field.id) + ); + const merged: PopFieldSaveMapping[] = [ + ...next, + ...added.map(({ field }) => ({ + fieldId: field.id, + valueSource: "direct" as FieldValueSource, + targetColumn: "", + })), + ]; + const structureChanged = + merged.length !== saveMappings.length || + merged.some((m, i) => m.fieldId !== saveMappings[i]?.fieldId); + if (updater || structureChanged) { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: merged, + }, + }); + } + }, + [allFields, saveMappings, cfg.saveConfig, saveTableName, onUpdateConfig] + ); + + const fieldIdsKey = allFields.map(({ field }) => field.id).join(","); + useEffect(() => { + syncAndUpdateSaveMappings(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fieldIdsKey]); + + const updateSaveMapping = useCallback( + (fieldId: string, partial: Partial) => { + syncAndUpdateSaveMappings((prev) => + prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)) + ); + }, + [syncAndUpdateSaveMappings] + ); + + // --- 숨은 필드 매핑 로직 --- + const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? []; + + const addHiddenMapping = useCallback(() => { + const newMapping: PopFieldHiddenMapping = { + id: `hidden_${Date.now()}`, + valueSource: "db_column", + targetColumn: "", + label: "", + }; + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + hiddenMappings: [...hiddenMappings, newMapping], + }, + }); + }, [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig]); + + const updateHiddenMapping = useCallback( + (id: string, partial: Partial) => { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + hiddenMappings: hiddenMappings.map((m) => + m.id === id ? { ...m, ...partial } : m + ), + }, + }); + }, + [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig] + ); + + const removeHiddenMapping = useCallback( + (id: string) => { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + hiddenMappings: hiddenMappings.filter((m) => m.id !== id), + }, + }); + }, + [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig] + ); + + // --- 레이아웃 auto 필드 감지 (입력 섹션에서 inputType=auto인 필드) --- + const autoInputFields = useMemo( + () => inputFields.filter(({ field }) => field.inputType === "auto"), + [inputFields] + ); + const regularInputFields = useMemo( + () => inputFields.filter(({ field }) => field.inputType !== "auto"), + [inputFields] + ); + + // --- 자동생성 필드 로직 --- + const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? []; + const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]); + + // 레이아웃 auto 필드 → autoGenMappings 자동 동기화 + const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(","); + useEffect(() => { + if (autoInputFields.length === 0) return; + const current = cfg.saveConfig?.autoGenMappings ?? []; + const linkedIds = new Set(current.filter((m) => m.linkedFieldId).map((m) => m.linkedFieldId)); + const toAdd: PopFieldAutoGenMapping[] = []; + for (const { field } of autoInputFields) { + if (!linkedIds.has(field.id)) { + toAdd.push({ + id: `autogen_linked_${field.id}`, + linkedFieldId: field.id, + label: field.labelText || "", + targetColumn: "", + numberingRuleId: field.autoNumber?.numberingRuleId ?? "", + showInForm: true, + showResultModal: true, + }); + } + } + if (toAdd.length > 0) { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + autoGenMappings: [...current, ...toAdd], + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoFieldIdsKey]); + + useEffect(() => { + if (saveTableName) { + getAvailableNumberingRulesForScreen(saveTableName) + .then((res) => { + if (res.success && Array.isArray(res.data)) { + setNumberingRules( + res.data.map((r: any) => ({ + ruleId: String(r.ruleId ?? r.rule_id ?? ""), + ruleName: String(r.ruleName ?? r.rule_name ?? ""), + })) + ); + } + }) + .catch(() => setNumberingRules([])); + } + }, [saveTableName]); + + const addAutoGenMapping = useCallback(() => { + const newMapping: PopFieldAutoGenMapping = { + id: `autogen_${Date.now()}`, + label: "", + targetColumn: "", + numberingRuleId: "", + showInForm: false, + showResultModal: true, + }; + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + autoGenMappings: [...autoGenMappings, newMapping], + }, + }); + }, [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig]); + + const updateAutoGenMapping = useCallback( + (id: string, partial: Partial) => { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + autoGenMappings: autoGenMappings.map((m) => + m.id === id ? { ...m, ...partial } : m + ), + }, + }); + }, + [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig] + ); + + const removeAutoGenMapping = useCallback( + (id: string) => { + onUpdateConfig({ + saveConfig: { + ...cfg.saveConfig, + tableName: saveTableName, + fieldMappings: cfg.saveConfig?.fieldMappings ?? [], + autoGenMappings: autoGenMappings.filter((m) => m.id !== id), + }, + }); + }, + [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig] + ); + + // --- 읽기 매핑 로직 --- + const getReadMappingForField = (fieldId: string): PopFieldReadMapping => { + return ( + cfg.readSource?.fieldMappings?.find((x) => x.fieldId === fieldId) ?? { + fieldId, + valueSource: "db_column", + columnName: "", + } + ); + }; + + const updateReadMapping = useCallback( + (fieldId: string, partial: Partial) => { + const prev = cfg.readSource?.fieldMappings ?? []; + const next = prev.map((m) => + m.fieldId === fieldId ? { ...m, ...partial } : m + ); + if (!next.some((m) => m.fieldId === fieldId)) { + next.push({ fieldId, valueSource: "db_column", columnName: "", ...partial }); + } + onUpdateConfig({ + readSource: { + ...cfg.readSource, + tableName: readTableName || saveTableName, + pkColumn: cfg.readSource?.pkColumn ?? "", + fieldMappings: next, + }, + }); + }, + [cfg.readSource, readTableName, saveTableName, onUpdateConfig] + ); + + // --- 읽기 테이블 변경 --- + const handleReadSameAsSaveChange = useCallback( + (checked: boolean) => { + onUpdateConfig({ + readSource: { + tableName: checked ? saveTableName : "", + pkColumn: checked ? (cfg.readSource?.pkColumn ?? "") : "", + fieldMappings: cfg.readSource?.fieldMappings ?? [], + }, + }); + }, + [saveTableName, cfg.readSource, onUpdateConfig] + ); + + const handleReadTableChange = useCallback( + (tableName: string) => { + onUpdateConfig({ + readSource: { + ...cfg.readSource, + tableName, + pkColumn: cfg.readSource?.pkColumn ?? "", + fieldMappings: cfg.readSource?.fieldMappings ?? [], + }, + }); + }, + [cfg.readSource, onUpdateConfig] + ); + + const colName = (c: ColumnInfo) => c.name; + + const noFields = allFields.length === 0; + + const [collapsed, setCollapsed] = useState>({}); + const toggleSection = useCallback((key: string) => { + setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + return ( +
+ {noFields && ( +

+ 레이아웃 탭에서 섹션과 필드를 먼저 추가해주세요. +

+ )} + + {/* ── 1. 테이블 설정 ── */} + {!noFields && ( +
+
toggleSection("table")} + > + {collapsed["table"] ? ( + + ) : ( + + )} + 테이블 설정 +
+ {!collapsed["table"] &&
+ {/* 읽기 테이블 (display 섹션이 있을 때만) */} + {hasDisplayFields && ( + <> +
+ +
+
+ + + 저장과 동일 + +
+
+ {!readSameAsSave && ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + handleReadTableChange(v); + setReadTableOpen(false); + }} + className="text-xs" + > + + {t.tableName} + + ))} + + + + + + )} +
+
+ + +
+ + )} + + {/* 저장 테이블 */} + {hasInputFields && ( +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + onSaveTableChange(v === saveTableName ? "" : v); + setSaveTableOpen(false); + }} + className="text-xs" + > + + {t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + + ({t.displayName}) + + )} + + ))} + + + + + +
+ )} +
} +
+ )} + + {/* ── 2. 읽기 필드 매핑 (display) ── */} + {hasDisplayFields && (readTableForFetch || readSameAsSave) && ( +
+
toggleSection("read")} + > + {collapsed["read"] ? ( + + ) : ( + + )} + 읽기 필드 + + (읽기 폼) + +
+ {!collapsed["read"] &&
+ {readColumns.length === 0 ? ( +

+ 읽기 테이블의 컬럼을 불러오는 중... +

+ ) : ( + displayFields.map(({ field }) => { + const m = getReadMappingForField(field.id); + const sm = getSaveMappingForField(field.id); + const isJson = m.valueSource === "json_extract"; + return ( +
+
+ + {field.labelText || "(미설정)"} + + + {!isJson && ( + <> + + + + )} +
+ {isJson && ( +
+ + + . + updateReadMapping(field.id, { jsonKey: v })} + onOpen={() => { + if (readTableForFetch && m.columnName) { + fetchJsonKeysForColumn(readTableForFetch, m.columnName); + } + }} + /> +
+ )} + {saveTableName && saveColumns.length > 0 && ( +
+ + +
+ )} +
+ ); + }) + )} +
} +
+ )} + + {/* ── 3. 입력 필드 매핑 (input, auto 타입 제외) ── */} + {regularInputFields.length > 0 && saveTableName && ( +
+
toggleSection("input")} + > + {collapsed["input"] ? ( + + ) : ( + + )} + 입력 필드 + + (입력 폼 → 저장) + +
+ {!collapsed["input"] &&
+ {saveColumns.length === 0 ? ( +

+ 저장 테이블의 컬럼을 불러오는 중... +

+ ) : ( + regularInputFields.map(({ field }) => { + const m = getSaveMappingForField(field.id); + return ( +
+ + {field.labelText || "(미설정)"} + + + +
+ ); + }) + )} +
} +
+ )} + + {/* ── 4. 숨은 필드 매핑 (읽기 필드와 동일한 소스 구조) ── */} + {saveTableName && ( +
+
toggleSection("hidden")} + > + {collapsed["hidden"] ? ( + + ) : ( + + )} + 숨은 필드 + + (UI 미표시, 전달 데이터에서 추출하여 저장) + +
+ {!collapsed["hidden"] &&
+ {hiddenMappings.map((m) => { + const isJson = m.valueSource === "json_extract"; + return ( +
+
+ updateHiddenMapping(m.id, { label: e.target.value })} + placeholder="라벨 (관리용)" + className="h-7 flex-1 text-xs" + /> + +
+
+ + {!isJson && ( + <> + + + )} +
+ {isJson && ( +
+ + . + updateHiddenMapping(m.id, { sourceJsonKey: v })} + onOpen={() => { + if (readTableForFetch && m.sourceJsonColumn) { + fetchJsonKeysForColumn(readTableForFetch, m.sourceJsonColumn); + } + }} + /> +
+ )} +
+ + +
+
+ ); + })} + +
} +
+ )} + + {/* ── 5. 자동생성 필드 ── */} + {saveTableName && ( +
+
toggleSection("autogen")} + > + {collapsed["autogen"] ? ( + + ) : ( + + )} + 자동생성 필드 + + (저장 시 서버에서 채번) + +
+ {!collapsed["autogen"] &&
+ {autoGenMappings.map((m) => { + const isLinked = !!m.linkedFieldId; + return ( +
+
+ {isLinked && ( + + 레이아웃 + + )} + updateAutoGenMapping(m.id, { label: e.target.value })} + placeholder="라벨 (예: 입고번호)" + className="h-7 flex-1 text-xs" + readOnly={isLinked} + /> + {!isLinked && ( + + )} +
+
+ + +
+
+ + +
+
+ {!isLinked && ( +
+ updateAutoGenMapping(m.id, { showInForm: v })} + /> + +
+ )} +
+ updateAutoGenMapping(m.id, { showResultModal: v })} + /> + +
+
+
+ ); + })} + +
} +
+ )} + + {/* 저장 테이블 미선택 안내 */} + {(regularInputFields.length > 0 || autoInputFields.length > 0) && !saveTableName && !noFields && ( +

+ 저장 테이블을 선택하면 입력 필드 매핑이 표시됩니다. +

+ )} +
+ ); +} + +// ======================================== +// SectionEditor: 섹션 단위 편집 +// ======================================== + +interface SectionEditorProps { + section: PopFieldSection; + index: number; + canDelete: boolean; + onUpdate: (partial: Partial) => void; + onRemove: () => void; + onMoveUp: () => void; +} + +function migrateStyle(style: string): FieldSectionStyle { + if (style === "display" || style === "input") return style; + if (style === "summary") return "display"; + if (style === "form") return "input"; + return "input"; +} + +function SectionEditor({ + section, + index, + canDelete, + onUpdate, + onRemove, + onMoveUp, +}: SectionEditorProps) { + const [collapsed, setCollapsed] = useState(false); + const resolvedStyle = migrateStyle(section.style); + + const sectionFields = section.fields || []; + + const updateField = useCallback( + (fieldId: string, partial: Partial) => { + const fields = sectionFields.map((f) => + f.id === fieldId ? { ...f, ...partial } : f + ); + onUpdate({ fields }); + }, + [sectionFields, onUpdate] + ); + + const addField = useCallback(() => { + const fieldId = `field_${Date.now()}`; + const newField: PopFieldItem = { + id: fieldId, + inputType: "text", + fieldName: fieldId, + labelText: "", + readOnly: false, + }; + onUpdate({ fields: [...sectionFields, newField] }); + }, [sectionFields, onUpdate]); + + const removeField = useCallback( + (fieldId: string) => { + onUpdate({ fields: sectionFields.filter((f) => f.id !== fieldId) }); + }, + [sectionFields, onUpdate] + ); + + return ( +
+ {/* 섹션 헤더 */} +
setCollapsed(!collapsed)} + > + {collapsed ? ( + + ) : ( + + )} + + 섹션 {index + 1} + {section.label && ` - ${section.label}`} + + {index > 0 && ( + + )} + {canDelete && ( + + )} +
+ + {!collapsed && ( +
+ {/* 섹션 라벨 */} +
+ + onUpdate({ label: e.target.value })} + placeholder="선택사항" + className="mt-1 h-7 text-xs" + /> +
+ + {/* 스타일 + 열 수 (가로 배치) */} +
+
+ + +
+
+ + +
+
+ + {/* 라벨 표시 토글 */} +
+ + onUpdate({ showLabels: v })} + /> +
+ + {/* 커스텀 색상 */} + onUpdate({ appearance })} + /> + + {/* 필드 목록 */} +
+ + {sectionFields.map((field) => ( + updateField(field.id, partial)} + onRemove={() => removeField(field.id)} + /> + ))} + +
+
+ )} +
+ ); +} + +// ======================================== +// FieldItemEditor: 필드 단위 편집 +// ======================================== + +interface FieldItemEditorProps { + field: PopFieldItem; + sectionStyle?: FieldSectionStyle; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +} + +function FieldItemEditor({ + field, + sectionStyle, + onUpdate, + onRemove, +}: FieldItemEditorProps) { + const isDisplay = sectionStyle === "display"; + const [expanded, setExpanded] = useState(false); + + return ( +
+ {/* 필드 헤더 */} +
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + + {field.labelText || "(미설정)"} + + [{FIELD_INPUT_TYPE_LABELS[field.inputType]}] + + {field.readOnly && ( + (읽기전용) + )} + + +
+ + {expanded && ( +
+ {/* 라벨 + 타입 */} +
+
+ + onUpdate({ labelText: e.target.value })} + placeholder="표시 라벨" + className="mt-0.5 h-7 text-xs" + /> +
+
+ + +
+
+ + {/* 플레이스홀더 */} +
+ + onUpdate({ placeholder: e.target.value })} + placeholder="힌트 텍스트" + className="mt-0.5 h-7 text-xs" + /> +
+ + {/* 읽기전용 + 필수 (입력 폼에서만 표시) */} + {!isDisplay && ( +
+
+ onUpdate({ readOnly: v })} + /> + +
+
+ + onUpdate({ + validation: { ...field.validation, required: v }, + }) + } + /> + +
+
+ )} + + {/* 단위 (number, numpad) */} + {(field.inputType === "number" || field.inputType === "numpad") && ( +
+ + onUpdate({ unit: e.target.value })} + placeholder="EA, KG 등" + className="mt-0.5 h-7 text-xs" + /> +
+ )} + + {/* select 전용: 옵션 소스 */} + {field.inputType === "select" && ( + onUpdate({ selectSource: source })} + /> + )} + + {/* auto 전용: 채번 설정 */} + {field.inputType === "auto" && ( + onUpdate({ autoNumber })} + /> + )} +
+ )} +
+ ); +} + +// ======================================== +// SelectSourceEditor: select 옵션 소스 편집 +// ======================================== + +function SelectSourceEditor({ + source, + onUpdate, +}: { + source?: FieldSelectSource; + onUpdate: (source: FieldSelectSource) => void; +}) { + const current: FieldSelectSource = source || { + type: "static", + staticOptions: [], + }; + + return ( +
+ + + + + {current.type === "static" && ( + onUpdate({ ...current, staticOptions: opts })} + /> + )} + + {current.type === "table" && ( + onUpdate({ ...current, ...partial })} + /> + )} +
+ ); +} + +// ======================================== +// StaticOptionsEditor: 정적 옵션 CRUD +// ======================================== + +function StaticOptionsEditor({ + options, + onUpdate, +}: { + options: { value: string; label: string }[]; + onUpdate: (options: { value: string; label: string }[]) => void; +}) { + return ( +
+ {options.map((opt, idx) => ( +
+ { + const next = [...options]; + next[idx] = { ...opt, value: e.target.value }; + onUpdate(next); + }} + placeholder="값" + className="h-6 flex-1 text-[10px]" + /> + { + const next = [...options]; + next[idx] = { ...opt, label: e.target.value }; + onUpdate(next); + }} + placeholder="표시" + className="h-6 flex-1 text-[10px]" + /> + +
+ ))} + +
+ ); +} + +// ======================================== +// TableSourceEditor: 테이블 소스 설정 +// ======================================== + +function TableSourceEditor({ + source, + onUpdate, +}: { + source: FieldSelectSource; + onUpdate: (partial: Partial) => void; +}) { + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [tblOpen, setTblOpen] = useState(false); + + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + useEffect(() => { + if (source.tableName) { + fetchTableColumns(source.tableName).then(setColumns); + } else { + setColumns([]); + } + }, [source.tableName]); + + return ( +
+ {/* 테이블 Combobox */} + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + onUpdate({ tableName: v }); + setTblOpen(false); + }} + className="text-xs" + > + + {t.tableName} + + ))} + + + + + + + {/* 값 컬럼 / 라벨 컬럼 */} +
+
+ + +
+
+ + +
+
+
+ ); +} + +// ======================================== +// AutoNumberEditor: 자동 채번 설정 +// ======================================== + +function AutoNumberEditor({ + config, + onUpdate, +}: { + config?: AutoNumberConfig; + onUpdate: (config: AutoNumberConfig) => void; +}) { + const current: AutoNumberConfig = config || { + prefix: "", + dateFormat: "YYYYMMDD", + separator: "-", + sequenceDigits: 3, + }; + + return ( +
+ + +
+
+ + onUpdate({ ...current, prefix: e.target.value })} + placeholder="IN-" + className="mt-0.5 h-7 text-xs" + /> +
+
+ + +
+
+ +
+
+ + onUpdate({ ...current, separator: e.target.value })} + placeholder="-" + className="mt-0.5 h-7 text-xs" + /> +
+
+ + + onUpdate({ + ...current, + sequenceDigits: Number(e.target.value) || 3, + }) + } + min={1} + max={10} + className="mt-0.5 h-7 text-xs" + /> +
+
+ + {/* 미리보기 */} +
+ 미리보기:{" "} + + {current.prefix || ""} + {current.separator || ""} + {current.dateFormat === "YYMM" + ? "2602" + : current.dateFormat === "YYMMDD" + ? "260226" + : "20260226"} + {current.separator || ""} + {"0".repeat(current.sequenceDigits || 3).slice(0, -1)}1 + +
+
+ ); +} + +// ======================================== +// JsonKeySelect: JSON 키 드롭다운 (자동 추출) +// ======================================== + +function JsonKeySelect({ + value, + keys, + onValueChange, + onOpen, +}: { + value: string; + keys: string[]; + onValueChange: (v: string) => void; + onOpen?: () => void; +}) { + const [open, setOpen] = useState(false); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (nextOpen) onOpen?.(); + }; + + if (keys.length === 0 && !value) { + return ( + onValueChange(e.target.value)} + onFocus={() => onOpen?.()} + className="h-7 w-24 text-xs" + /> + ); + } + + return ( + + + + + + + + + + {keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."} + + + {keys.map((k) => ( + { + onValueChange(v === value ? "" : v); + setOpen(false); + }} + className="text-xs" + > + + {k} + + ))} + + + + + + ); +} + +// ======================================== +// AppearanceEditor: 섹션 외관 설정 +// ======================================== + +const BG_COLOR_OPTIONS = [ + { value: "__default__", label: "기본" }, + { value: "bg-emerald-50", label: "초록" }, + { value: "bg-blue-50", label: "파랑" }, + { value: "bg-amber-50", label: "노랑" }, + { value: "bg-rose-50", label: "빨강" }, + { value: "bg-purple-50", label: "보라" }, + { value: "bg-gray-50", label: "회색" }, +] as const; + +const BORDER_COLOR_OPTIONS = [ + { value: "__default__", label: "기본" }, + { value: "border-emerald-200", label: "초록" }, + { value: "border-blue-200", label: "파랑" }, + { value: "border-amber-200", label: "노랑" }, + { value: "border-rose-200", label: "빨강" }, + { value: "border-purple-200", label: "보라" }, + { value: "border-gray-200", label: "회색" }, +] as const; + +function AppearanceEditor({ + style, + appearance, + onUpdate, +}: { + style: FieldSectionStyle; + appearance?: FieldSectionAppearance; + onUpdate: (appearance: FieldSectionAppearance) => void; +}) { + const defaults = DEFAULT_SECTION_APPEARANCES[style]; + const current = appearance || {}; + + return ( +
+ +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-field/index.tsx b/frontend/lib/registry/pop-components/pop-field/index.tsx new file mode 100644 index 00000000..60ed1ba7 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-field/index.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopFieldComponent } from "./PopFieldComponent"; +import { PopFieldConfigPanel } from "./PopFieldConfig"; +import type { PopFieldConfig } from "./types"; +import { DEFAULT_FIELD_CONFIG, FIELD_INPUT_TYPE_LABELS } from "./types"; + +function PopFieldPreviewComponent({ + config, + label, +}: { + config?: PopFieldConfig; + label?: string; +}) { + const cfg: PopFieldConfig = { + ...DEFAULT_FIELD_CONFIG, + ...config, + sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections, + }; + const totalFields = cfg.sections.reduce( + (sum, s) => sum + (s.fields?.length || 0), + 0 + ); + const sectionCount = cfg.sections.length; + + return ( +
+ + {label || "입력 필드"} + +
+ {cfg.sections.map((section) => + (section.fields || []).slice(0, 3).map((field) => ( +
+ + {field.labelText || field.fieldName || FIELD_INPUT_TYPE_LABELS[field.inputType]} + +
+ )) + )} +
+ + {sectionCount}섹션 / {totalFields}필드 + +
+ ); +} + +PopComponentRegistry.registerComponent({ + id: "pop-field", + name: "입력 필드", + description: "저장용 값 입력 (섹션별 멀티필드, 읽기전용/입력 혼합)", + category: "input", + icon: "TextCursorInput", + component: PopFieldComponent, + configPanel: PopFieldConfigPanel, + preview: PopFieldPreviewComponent, + defaultProps: DEFAULT_FIELD_CONFIG, + connectionMeta: { + sendable: [ + { + 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, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-field/types.ts b/frontend/lib/registry/pop-components/pop-field/types.ts new file mode 100644 index 00000000..6d9e1734 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-field/types.ts @@ -0,0 +1,210 @@ +/** + * pop-field 타입 정의 + * 섹션 기반 멀티필드: 하나의 컴포넌트 안에 여러 섹션, 각 섹션에 여러 필드 + * 저장용 값 입력 (text/number/date/select/auto/numpad) + */ + +import type { DataSourceFilter } from "../types"; + +// ===== 서브타입 ===== + +export type FieldInputType = "text" | "number" | "date" | "select" | "auto" | "numpad"; + +export const FIELD_INPUT_TYPE_LABELS: Record = { + text: "텍스트", + number: "숫자", + date: "날짜", + select: "선택", + auto: "자동채번", + numpad: "숫자패드", +}; + +// ===== 섹션 스타일 ===== + +export type FieldSectionStyle = "display" | "input"; + +export const FIELD_SECTION_STYLE_LABELS: Record = { + display: "읽기 폼", + input: "입력 폼", +}; + +// 섹션 커스텀 외관 옵션 +export interface FieldSectionAppearance { + bgColor?: string; + borderColor?: string; + textColor?: string; +} + +export const DEFAULT_SECTION_APPEARANCES: Record = { + display: { bgColor: "bg-emerald-50", borderColor: "border-emerald-200", textColor: "text-foreground" }, + input: { bgColor: "bg-background", borderColor: "border-border", textColor: "text-foreground" }, +}; + +// ===== select 옵션 소스 ===== + +export type FieldSelectSourceType = "static" | "table"; + +export interface FieldSelectSource { + type: FieldSelectSourceType; + staticOptions?: { value: string; label: string }[]; + tableName?: string; + valueColumn?: string; + labelColumn?: string; + filters?: DataSourceFilter[]; +} + +// ===== 자동 채번 설정 ===== + +export interface AutoNumberConfig { + prefix?: string; + dateFormat?: string; + separator?: string; + sequenceDigits?: number; + numberingRuleId?: string; +} + +// ===== 유효성 검증 ===== + +export interface PopFieldValidation { + required?: boolean; + min?: number; + max?: number; + pattern?: string; + customMessage?: string; +} + +// ===== 개별 필드 정의 ===== + +export interface PopFieldItem { + id: string; + inputType: FieldInputType; + fieldName: string; + labelText?: string; + placeholder?: string; + defaultValue?: unknown; + readOnly?: boolean; + unit?: string; + + selectSource?: FieldSelectSource; + autoNumber?: AutoNumberConfig; + validation?: PopFieldValidation; +} + +// ===== 섹션 정의 ===== + +export interface PopFieldSection { + id: string; + label?: string; + style: FieldSectionStyle; + columns: "auto" | 1 | 2 | 3 | 4; + showLabels: boolean; + appearance?: FieldSectionAppearance; + fields: PopFieldItem[]; +} + +// ===== 저장 설정: 값 소스 타입 ===== + +export type FieldValueSource = "direct" | "json_extract" | "db_column"; + +export const FIELD_VALUE_SOURCE_LABELS: Record = { + direct: "직접 입력", + json_extract: "JSON 추출", + db_column: "DB 컬럼", +}; + +// ===== 저장 설정: 필드-컬럼 매핑 ===== + +export interface PopFieldSaveMapping { + fieldId: string; + valueSource: FieldValueSource; + targetColumn: string; + sourceJsonColumn?: string; + sourceJsonKey?: string; +} + +// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) ===== + +export type HiddenValueSource = "json_extract" | "db_column"; + +export interface PopFieldHiddenMapping { + id: string; + label?: string; + valueSource: HiddenValueSource; + sourceJsonColumn?: string; + sourceJsonKey?: string; + sourceDbColumn?: string; + targetColumn: string; +} + +// ===== 자동생성 필드 (서버 채번규칙으로 저장 시점 생성) ===== + +export interface PopFieldAutoGenMapping { + id: string; + linkedFieldId?: string; + label: string; + targetColumn: string; + numberingRuleId?: string; + showInForm: boolean; + showResultModal: boolean; +} + +export interface PopFieldSaveConfig { + tableName: string; + fieldMappings: PopFieldSaveMapping[]; + hiddenMappings?: PopFieldHiddenMapping[]; + autoGenMappings?: PopFieldAutoGenMapping[]; +} + +// ===== 읽기 데이터 소스 ===== + +export interface PopFieldReadMapping { + fieldId: string; + valueSource: FieldValueSource; + columnName: string; + jsonKey?: string; +} + +export interface PopFieldReadSource { + tableName: string; + pkColumn: string; + fieldMappings: PopFieldReadMapping[]; +} + +// ===== pop-field 전체 설정 (루트) ===== + +export interface PopFieldConfig { + targetTable?: string; + sections: PopFieldSection[]; + saveConfig?: PopFieldSaveConfig; + readSource?: PopFieldReadSource; +} + +// ===== 기본값 ===== + +export const DEFAULT_FIELD_CONFIG: PopFieldConfig = { + targetTable: "", + sections: [ + { + id: "section_display", + label: "요약", + style: "display", + columns: "auto", + showLabels: true, + fields: [ + { id: "f_disp_1", inputType: "text", fieldName: "", labelText: "항목1", readOnly: true }, + { id: "f_disp_2", inputType: "text", fieldName: "", labelText: "항목2", readOnly: true }, + ], + }, + { + id: "section_input", + label: "입력", + style: "input", + columns: "auto", + showLabels: true, + fields: [ + { id: "f_input_1", inputType: "text", fieldName: "", labelText: "필드1" }, + { id: "f_input_2", inputType: "number", fieldName: "", labelText: "필드2", unit: "EA" }, + ], + }, + ], +}; 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 d3c77233..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 (사용하지 않지만 기존 데이터 보호) @@ -608,6 +608,86 @@ export interface CardResponsiveConfig { fields?: Record; } +// ----- 장바구니 목록 모드 설정 ----- + +export interface CartListModeConfig { + enabled: boolean; + sourceScreenId?: number; + sourceComponentId?: 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 { @@ -620,10 +700,12 @@ export interface PopCardListConfig { gridColumns?: number; gridRows?: number; - // 반응형 표시 설정 responsiveDisplay?: CardResponsiveConfig; inputField?: CardInputFieldConfig; packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; + + cartListMode?: CartListModeConfig; + saveMapping?: CardListSaveMapping; }