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 32ab7332..c7d93570 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -90,6 +90,7 @@ import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; import dashboardRoutes from "./routes/dashboardRoutes"; import reportRoutes from "./routes/reportRoutes"; +import barcodeLabelRoutes from "./routes/barcodeLabelRoutes"; import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리 import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리 @@ -114,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"; // 세금계산서 관리 @@ -244,6 +246,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); @@ -279,6 +282,7 @@ app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/dataflow", dataflowExecutionRoutes); app.use("/api/dashboards", dashboardRoutes); app.use("/api/admin/reports", reportRoutes); +app.use("/api/admin/barcode-labels", barcodeLabelRoutes); app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리 app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 diff --git a/backend-node/src/controllers/barcodeLabelController.ts b/backend-node/src/controllers/barcodeLabelController.ts new file mode 100644 index 00000000..3159c4e0 --- /dev/null +++ b/backend-node/src/controllers/barcodeLabelController.ts @@ -0,0 +1,218 @@ +/** + * 바코드 라벨 관리 컨트롤러 + * ZD421 등 바코드 프린터용 라벨 CRUD 및 레이아웃/템플릿 + */ + +import { Request, Response, NextFunction } from "express"; +import barcodeLabelService from "../services/barcodeLabelService"; + +function getUserId(req: Request): string { + return (req as any).user?.userId || "SYSTEM"; +} + +export class BarcodeLabelController { + async getLabels(req: Request, res: Response, next: NextFunction) { + try { + const page = Math.max(1, parseInt((req.query.page as string) || "1", 10)); + const limit = Math.min(100, Math.max(1, parseInt((req.query.limit as string) || "20", 10))); + const searchText = (req.query.searchText as string) || ""; + const useYn = (req.query.useYn as string) || "Y"; + const sortBy = (req.query.sortBy as string) || "created_at"; + const sortOrder = (req.query.sortOrder as "ASC" | "DESC") || "DESC"; + + const data = await barcodeLabelService.getLabels({ + page, + limit, + searchText, + useYn, + sortBy, + sortOrder, + }); + + return res.json({ success: true, data }); + } catch (error) { + return next(error); + } + } + + async getLabelById(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const label = await barcodeLabelService.getLabelById(labelId); + if (!label) { + return res.status(404).json({ + success: false, + message: "바코드 라벨을 찾을 수 없습니다.", + }); + } + return res.json({ success: true, data: label }); + } catch (error) { + return next(error); + } + } + + async getLayout(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const layout = await barcodeLabelService.getLayout(labelId); + if (!layout) { + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + return res.json({ success: true, data: layout }); + } catch (error) { + return next(error); + } + } + + async createLabel(req: Request, res: Response, next: NextFunction) { + try { + const body = req.body as { + labelNameKor?: string; + labelNameEng?: string; + description?: string; + templateId?: string; + }; + if (!body?.labelNameKor?.trim()) { + return res.status(400).json({ + success: false, + message: "라벨명(한글)은 필수입니다.", + }); + } + const labelId = await barcodeLabelService.createLabel( + { + labelNameKor: body.labelNameKor.trim(), + labelNameEng: body.labelNameEng?.trim(), + description: body.description?.trim(), + templateId: body.templateId?.trim(), + }, + getUserId(req) + ); + return res.status(201).json({ + success: true, + data: { labelId }, + message: "바코드 라벨이 생성되었습니다.", + }); + } catch (error) { + return next(error); + } + } + + async updateLabel(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const body = req.body as { + labelNameKor?: string; + labelNameEng?: string; + description?: string; + useYn?: string; + }; + const success = await barcodeLabelService.updateLabel( + labelId, + { + labelNameKor: body.labelNameKor?.trim(), + labelNameEng: body.labelNameEng?.trim(), + description: body.description !== undefined ? body.description : undefined, + useYn: body.useYn, + }, + getUserId(req) + ); + if (!success) { + return res.status(404).json({ + success: false, + message: "바코드 라벨을 찾을 수 없습니다.", + }); + } + return res.json({ success: true, message: "수정되었습니다." }); + } catch (error) { + return next(error); + } + } + + async saveLayout(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const layout = req.body as { width_mm: number; height_mm: number; components: any[] }; + if (!layout || typeof layout.width_mm !== "number" || typeof layout.height_mm !== "number" || !Array.isArray(layout.components)) { + return res.status(400).json({ + success: false, + message: "width_mm, height_mm, components 배열이 필요합니다.", + }); + } + await barcodeLabelService.saveLayout( + labelId, + { width_mm: layout.width_mm, height_mm: layout.height_mm, components: layout.components }, + getUserId(req) + ); + return res.json({ success: true, message: "레이아웃이 저장되었습니다." }); + } catch (error) { + return next(error); + } + } + + async deleteLabel(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const success = await barcodeLabelService.deleteLabel(labelId); + if (!success) { + return res.status(404).json({ + success: false, + message: "바코드 라벨을 찾을 수 없습니다.", + }); + } + return res.json({ success: true, message: "삭제되었습니다." }); + } catch (error) { + return next(error); + } + } + + async copyLabel(req: Request, res: Response, next: NextFunction) { + try { + const { labelId } = req.params; + const newId = await barcodeLabelService.copyLabel(labelId, getUserId(req)); + if (!newId) { + return res.status(404).json({ + success: false, + message: "바코드 라벨을 찾을 수 없습니다.", + }); + } + return res.json({ + success: true, + data: { labelId: newId }, + message: "복사되었습니다.", + }); + } catch (error) { + return next(error); + } + } + + async getTemplates(req: Request, res: Response, next: NextFunction) { + try { + const templates = await barcodeLabelService.getTemplates(); + return res.json({ success: true, data: templates }); + } catch (error) { + return next(error); + } + } + + async getTemplateById(req: Request, res: Response, next: NextFunction) { + try { + const { templateId } = req.params; + const template = await barcodeLabelService.getTemplateById(templateId); + if (!template) { + return res.status(404).json({ + success: false, + message: "템플릿을 찾을 수 없습니다.", + }); + } + const layout = JSON.parse(template.layout_json); + return res.json({ success: true, data: { ...template, layout } }); + } catch (error) { + return next(error); + } + } +} + +export default new BarcodeLabelController(); diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 51d903af..f14f6532 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})`; @@ -2573,11 +2578,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); @@ -2591,11 +2596,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, @@ -2608,7 +2615,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} @@ -2767,6 +2775,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: "그룹을 찾을 수 없습니다." }); } @@ -2781,7 +2797,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}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.` + }); } // 연결된 화면 확인 @@ -2790,7 +2809,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}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.` + }); } // 삭제 @@ -2805,33 +2827,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 1827640c..2c1f2d9c 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -7,7 +7,7 @@ import { auditLogService, getClientIp } from "../services/auditLogService"; 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 사용 @@ -25,7 +25,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({ @@ -1537,3 +1538,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/barcodeLabelRoutes.ts b/backend-node/src/routes/barcodeLabelRoutes.ts new file mode 100644 index 00000000..ba580e7e --- /dev/null +++ b/backend-node/src/routes/barcodeLabelRoutes.ts @@ -0,0 +1,41 @@ +import { Router } from "express"; +import barcodeLabelController from "../controllers/barcodeLabelController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +router.use(authenticateToken); + +router.get("/", (req, res, next) => + barcodeLabelController.getLabels(req, res, next) +); +router.get("/templates", (req, res, next) => + barcodeLabelController.getTemplates(req, res, next) +); +router.get("/templates/:templateId", (req, res, next) => + barcodeLabelController.getTemplateById(req, res, next) +); +router.post("/", (req, res, next) => + barcodeLabelController.createLabel(req, res, next) +); + +router.get("/:labelId", (req, res, next) => + barcodeLabelController.getLabelById(req, res, next) +); +router.get("/:labelId/layout", (req, res, next) => + barcodeLabelController.getLayout(req, res, next) +); +router.put("/:labelId", (req, res, next) => + barcodeLabelController.updateLabel(req, res, next) +); +router.put("/:labelId/layout", (req, res, next) => + barcodeLabelController.saveLayout(req, res, next) +); +router.delete("/:labelId", (req, res, next) => + barcodeLabelController.deleteLabel(req, res, next) +); +router.post("/:labelId/copy", (req, res, next) => + barcodeLabelController.copyLabel(req, res, next) +); + +export default router; 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 3bbded6f..27ef1370 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -52,6 +52,8 @@ import { updateZone, deleteZone, addLayerToZone, + analyzePopScreenLinks, + deployPopScreens, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -147,4 +149,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/barcodeLabelService.ts b/backend-node/src/services/barcodeLabelService.ts new file mode 100644 index 00000000..c5399cf9 --- /dev/null +++ b/backend-node/src/services/barcodeLabelService.ts @@ -0,0 +1,247 @@ +/** + * 바코드 라벨 관리 서비스 + * ZD421 등 라벨 디자인 CRUD 및 기본 템플릿 제공 + */ + +import { v4 as uuidv4 } from "uuid"; +import { query, queryOne, transaction } from "../database/db"; +import { BarcodeLabelLayout } from "../types/barcode"; + +export interface BarcodeLabelMaster { + label_id: string; + label_name_kor: string; + label_name_eng: string | null; + description: string | null; + width_mm: number; + height_mm: number; + layout_json: string | null; + use_yn: string; + created_at: string; + created_by: string | null; + updated_at: string | null; + updated_by: string | null; +} + +export interface BarcodeLabelTemplate { + template_id: string; + template_name_kor: string; + template_name_eng: string | null; + width_mm: number; + height_mm: number; + layout_json: string; + sort_order: number; +} + +export interface GetBarcodeLabelsParams { + page?: number; + limit?: number; + searchText?: string; + useYn?: string; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +} + +export interface GetBarcodeLabelsResult { + items: BarcodeLabelMaster[]; + total: number; + page: number; + limit: number; +} + +export class BarcodeLabelService { + async getLabels(params: GetBarcodeLabelsParams): Promise { + const { + page = 1, + limit = 20, + searchText = "", + useYn = "Y", + sortBy = "created_at", + sortOrder = "DESC", + } = params; + + const offset = (page - 1) * limit; + const conditions: string[] = []; + const values: any[] = []; + let idx = 1; + + if (useYn) { + conditions.push(`use_yn = $${idx++}`); + values.push(useYn); + } + if (searchText) { + conditions.push(`(label_name_kor LIKE $${idx} OR label_name_eng LIKE $${idx})`); + values.push(`%${searchText}%`); + idx++; + } + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + + const countSql = `SELECT COUNT(*) as total FROM barcode_labels ${where}`; + const countRow = await queryOne<{ total: string }>(countSql, values); + const total = parseInt(countRow?.total || "0", 10); + + const listSql = ` + SELECT label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, + layout_json, use_yn, created_at, created_by, updated_at, updated_by + FROM barcode_labels ${where} + ORDER BY ${sortBy} ${sortOrder} + LIMIT $${idx++} OFFSET $${idx} + `; + const items = await query(listSql, [...values, limit, offset]); + + return { items, total, page, limit }; + } + + async getLabelById(labelId: string): Promise { + const sql = ` + SELECT label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, + layout_json, use_yn, created_at, created_by, updated_at, updated_by + FROM barcode_labels WHERE label_id = $1 + `; + return queryOne(sql, [labelId]); + } + + async getLayout(labelId: string): Promise { + const row = await this.getLabelById(labelId); + if (!row?.layout_json) return null; + try { + return JSON.parse(row.layout_json) as BarcodeLabelLayout; + } catch { + return null; + } + } + + async createLabel( + data: { labelNameKor: string; labelNameEng?: string; description?: string; templateId?: string }, + userId: string + ): Promise { + const labelId = `LBL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + let widthMm = 50; + let heightMm = 30; + let layoutJson: string | null = null; + + if (data.templateId) { + const t = await this.getTemplateById(data.templateId); + if (t) { + widthMm = t.width_mm; + heightMm = t.height_mm; + layoutJson = t.layout_json; + } + } + if (!layoutJson) { + const defaultLayout: BarcodeLabelLayout = { + width_mm: widthMm, + height_mm: heightMm, + components: [], + }; + layoutJson = JSON.stringify(defaultLayout); + } + + await query( + `INSERT INTO barcode_labels (label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, layout_json, use_yn, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', $8)`, + [ + labelId, + data.labelNameKor, + data.labelNameEng || null, + data.description || null, + widthMm, + heightMm, + layoutJson, + userId, + ] + ); + return labelId; + } + + async updateLabel( + labelId: string, + data: { labelNameKor?: string; labelNameEng?: string; description?: string; useYn?: string }, + userId: string + ): Promise { + const setClauses: string[] = []; + const values: any[] = []; + let idx = 1; + if (data.labelNameKor !== undefined) { + setClauses.push(`label_name_kor = $${idx++}`); + values.push(data.labelNameKor); + } + if (data.labelNameEng !== undefined) { + setClauses.push(`label_name_eng = $${idx++}`); + values.push(data.labelNameEng); + } + if (data.description !== undefined) { + setClauses.push(`description = $${idx++}`); + values.push(data.description); + } + if (data.useYn !== undefined) { + setClauses.push(`use_yn = $${idx++}`); + values.push(data.useYn); + } + if (setClauses.length === 0) return false; + setClauses.push(`updated_at = CURRENT_TIMESTAMP`); + setClauses.push(`updated_by = $${idx++}`); + values.push(userId); + values.push(labelId); + + const updated = await query<{ label_id: string }>( + `UPDATE barcode_labels SET ${setClauses.join(", ")} WHERE label_id = $${idx} RETURNING label_id`, + values + ); + return updated.length > 0; + } + + async saveLayout(labelId: string, layout: BarcodeLabelLayout, userId: string): Promise { + const layoutJson = JSON.stringify(layout); + await query( + `UPDATE barcode_labels SET width_mm = $1, height_mm = $2, layout_json = $3, updated_at = CURRENT_TIMESTAMP, updated_by = $4 WHERE label_id = $5`, + [layout.width_mm, layout.height_mm, layoutJson, userId, labelId] + ); + return true; + } + + async deleteLabel(labelId: string): Promise { + const deleted = await query<{ label_id: string }>( + `DELETE FROM barcode_labels WHERE label_id = $1 RETURNING label_id`, + [labelId] + ); + return deleted.length > 0; + } + + async copyLabel(labelId: string, userId: string): Promise { + const row = await this.getLabelById(labelId); + if (!row) return null; + const newId = `LBL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + await query( + `INSERT INTO barcode_labels (label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, layout_json, use_yn, created_by) + VALUES ($1, $2 || ' (복사)', $3, $4, $5, $6, $7, 'Y', $8)`, + [ + newId, + row.label_name_kor, + row.label_name_eng, + row.description, + row.width_mm, + row.height_mm, + row.layout_json, + userId, + ] + ); + return newId; + } + + async getTemplates(): Promise { + const sql = ` + SELECT template_id, template_name_kor, template_name_eng, width_mm, height_mm, layout_json, sort_order + FROM barcode_label_templates ORDER BY sort_order, template_id + `; + const rows = await query(sql); + return rows || []; + } + + async getTemplateById(templateId: string): Promise { + const sql = `SELECT template_id, template_name_kor, template_name_eng, width_mm, height_mm, layout_json, sort_order + FROM barcode_label_templates WHERE template_id = $1`; + return queryOne(sql, [templateId]); + } +} + +export default new BarcodeLabelService(); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 3ced9ca8..e6ee6b0f 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, ), @@ -5846,28 +5853,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; } @@ -5905,6 +5908,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/backend-node/src/types/barcode.ts b/backend-node/src/types/barcode.ts new file mode 100644 index 00000000..19f3b31d --- /dev/null +++ b/backend-node/src/types/barcode.ts @@ -0,0 +1,61 @@ +/** + * 바코드 라벨 백엔드 타입 + */ + +export interface BarcodeLabelComponent { + id: string; + type: "text" | "barcode" | "image" | "line" | "rectangle"; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + // text + content?: string; + fontSize?: number; + fontColor?: string; + fontWeight?: string; + // barcode + barcodeType?: string; + barcodeValue?: string; + showBarcodeText?: boolean; + // image + imageUrl?: string; + objectFit?: string; + // line/rectangle + lineColor?: string; + lineWidth?: number; + backgroundColor?: string; +} + +export interface BarcodeLabelLayout { + width_mm: number; + height_mm: number; + components: BarcodeLabelComponent[]; +} + +export interface BarcodeLabelRow { + label_id: string; + label_name_kor: string; + label_name_eng: string | null; + description: string | null; + width_mm: number; + height_mm: number; + layout_json: string | null; + use_yn: string; + created_at: string; + created_by: string | null; + updated_at: string | null; + updated_by: string | null; +} + +export interface BarcodeLabelTemplateRow { + template_id: string; + template_name_kor: string; + template_name_eng: string | null; + width_mm: number; + height_mm: number; + layout_json: string; + sort_order: number; + created_at: string; +} diff --git a/frontend/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page.tsx b/frontend/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page.tsx new file mode 100644 index 00000000..8d55ca5c --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { BarcodeDesignerToolbar } from "@/components/barcode/designer/BarcodeDesignerToolbar"; +import { BarcodeDesignerLeftPanel } from "@/components/barcode/designer/BarcodeDesignerLeftPanel"; +import { BarcodeDesignerCanvas } from "@/components/barcode/designer/BarcodeDesignerCanvas"; +import { BarcodeDesignerRightPanel } from "@/components/barcode/designer/BarcodeDesignerRightPanel"; +import { BarcodeDesignerProvider } from "@/contexts/BarcodeDesignerContext"; + +export default function BarcodeLabelDesignerPage() { + const params = useParams(); + const labelId = (params.labelId as string) || "new"; + + return ( + + +
+ +
+ + + +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/barcodeList/page.tsx b/frontend/app/(main)/admin/screenMng/barcodeList/page.tsx new file mode 100644 index 00000000..914e111a --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/barcodeList/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { BarcodeListTable } from "@/components/barcode/BarcodeListTable"; +import { Plus, Search, RotateCcw } from "lucide-react"; +import { useBarcodeList } from "@/hooks/useBarcodeList"; + +export default function BarcodeLabelManagementPage() { + const router = useRouter(); + const [searchText, setSearchText] = useState(""); + + const { labels, total, page, limit, isLoading, refetch, setPage, handleSearch } = useBarcodeList(); + + const handleSearchClick = () => { + handleSearch(searchText); + }; + + const handleReset = () => { + setSearchText(""); + handleSearch(""); + }; + + const handleCreateNew = () => { + router.push("/admin/screenMng/barcodeList/designer/new"); + }; + + return ( +
+
+
+
+

바코드 라벨 관리

+

ZD421 등 바코드 프린터용 라벨을 작성하고 출력합니다

+
+ +
+ + + + + + 검색 + + + +
+ setSearchText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSearchClick(); + } + }} + className="flex-1" + /> + + +
+
+
+ + + + + + 바코드 라벨 목록 + (총 {total}건) + + + + + + + +
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx index fa47a353..b1e09621 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"); @@ -242,6 +248,21 @@ export default function PopScreenManagementPage() { + {selectedScreen && ( + + )} + + {page} / {totalPages} + + + + )} + + !open && setDeleteTarget(null)}> + + + 바코드 라벨 삭제 + + 이 바코드 라벨을 삭제하시겠습니까? +
+ 삭제된 라벨은 복구할 수 없습니다. +
+
+ + 취소 + + {isDeleting ? ( + <> + + 삭제 중... + + ) : ( + "삭제" + )} + + +
+
+ + ); +} diff --git a/frontend/components/barcode/designer/BarcodeComponentPalette.tsx b/frontend/components/barcode/designer/BarcodeComponentPalette.tsx new file mode 100644 index 00000000..e28ff12d --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeComponentPalette.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useDrag } from "react-dnd"; +import { Type, Barcode, Image, Minus, Square } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { BarcodeLabelComponent } from "@/types/barcode"; +import { v4 as uuidv4 } from "uuid"; + +const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode }[] = [ + { type: "text", label: "텍스트", icon: }, + { type: "barcode", label: "바코드", icon: }, + { type: "image", label: "이미지", icon: }, + { type: "line", label: "선", icon: }, + { type: "rectangle", label: "사각형", icon: }, +]; + +const MM_TO_PX = 4; + +function defaultComponent(type: BarcodeLabelComponent["type"]): BarcodeLabelComponent { + const id = `comp_${uuidv4()}`; + const base = { id, type, x: 10 * MM_TO_PX, y: 10 * MM_TO_PX, width: 80, height: 24, zIndex: 0 }; + + switch (type) { + case "text": + return { ...base, content: "텍스트", fontSize: 10, fontColor: "#000000" }; + case "barcode": + return { + ...base, + width: 120, + height: 40, + barcodeType: "CODE128", + barcodeValue: "123456789", + showBarcodeText: true, + }; + case "image": + return { ...base, width: 60, height: 60, imageUrl: "", objectFit: "contain" }; + case "line": + return { ...base, width: 100, height: 2, lineColor: "#000", lineWidth: 1 }; + case "rectangle": + return { ...base, width: 80, height: 40, backgroundColor: "transparent", lineColor: "#000", lineWidth: 1 }; + default: + return base; + } +} + +function DraggableItem({ + type, + label, + icon, +}: { + type: BarcodeLabelComponent["type"]; + label: string; + icon: React.ReactNode; +}) { + const [{ isDragging }, drag] = useDrag(() => ({ + type: "barcode-component", + item: { component: defaultComponent(type) }, + collect: (m) => ({ isDragging: m.isDragging() }), + })); + + return ( +
+ {icon} + {label} +
+ ); +} + +export function BarcodeComponentPalette() { + return ( + + + 요소 추가 + + + {ITEMS.map((item) => ( + + ))} + + + ); +} diff --git a/frontend/components/barcode/designer/BarcodeDesignerCanvas.tsx b/frontend/components/barcode/designer/BarcodeDesignerCanvas.tsx new file mode 100644 index 00000000..15136668 --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeDesignerCanvas.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useRef } from "react"; +import { useDrop } from "react-dnd"; +import { useBarcodeDesigner, MM_TO_PX } from "@/contexts/BarcodeDesignerContext"; +import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent"; +import { BarcodeLabelComponent } from "@/types/barcode"; +import { v4 as uuidv4 } from "uuid"; + +export function BarcodeDesignerCanvas() { + const canvasRef = useRef(null); + const { + widthMm, + heightMm, + components, + addComponent, + selectComponent, + showGrid, + snapValueToGrid, + } = useBarcodeDesigner(); + + const widthPx = widthMm * MM_TO_PX; + const heightPx = heightMm * MM_TO_PX; + + const [{ isOver }, drop] = useDrop(() => ({ + accept: "barcode-component", + drop: (item: { component: BarcodeLabelComponent }, monitor) => { + if (!canvasRef.current) return; + const offset = monitor.getClientOffset(); + const rect = canvasRef.current.getBoundingClientRect(); + if (!offset) return; + + let x = offset.x - rect.left; + let y = offset.y - rect.top; + // 드롭 시 요소 중앙이 커서에 오도록 보정 + x -= item.component.width / 2; + y -= item.component.height / 2; + x = Math.max(0, Math.min(x, widthPx - item.component.width)); + y = Math.max(0, Math.min(y, heightPx - item.component.height)); + + const newComp: BarcodeLabelComponent = { + ...item.component, + id: `comp_${uuidv4()}`, + x: snapValueToGrid(x), + y: snapValueToGrid(y), + zIndex: components.length, + }; + addComponent(newComp); + }, + collect: (m) => ({ isOver: m.isOver() }), + }), [widthPx, heightPx, components.length, addComponent, snapValueToGrid]); + + return ( +
+
{ + (canvasRef as any).current = r; + drop(r); + }} + className="relative bg-white shadow-lg" + style={{ + width: widthPx, + height: heightPx, + minWidth: widthPx, + minHeight: heightPx, + backgroundImage: showGrid + ? `linear-gradient(to right, #e5e7eb 1px, transparent 1px), + linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)` + : undefined, + backgroundSize: showGrid ? `${MM_TO_PX * 5}px ${MM_TO_PX * 5}px` : undefined, + outline: isOver ? "2px dashed #2563eb" : "1px solid #d1d5db", + }} + onClick={(e) => { + if (e.target === e.currentTarget) selectComponent(null); + }} + > + {components.map((c) => ( + + ))} +
+
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodeDesignerLeftPanel.tsx b/frontend/components/barcode/designer/BarcodeDesignerLeftPanel.tsx new file mode 100644 index 00000000..9062041e --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeDesignerLeftPanel.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { BarcodeTemplatePalette } from "./BarcodeTemplatePalette"; +import { BarcodeComponentPalette } from "./BarcodeComponentPalette"; + +export function BarcodeDesignerLeftPanel() { + return ( +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodeDesignerRightPanel.tsx b/frontend/components/barcode/designer/BarcodeDesignerRightPanel.tsx new file mode 100644 index 00000000..aaa92d2c --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeDesignerRightPanel.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Trash2 } from "lucide-react"; +import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext"; +import { BarcodeLabelComponent } from "@/types/barcode"; + +export function BarcodeDesignerRightPanel() { + const { + components, + selectedComponentId, + updateComponent, + removeComponent, + selectComponent, + widthMm, + heightMm, + setWidthMm, + setHeightMm, + } = useBarcodeDesigner(); + + const selected = components.find((c) => c.id === selectedComponentId); + + if (!selected) { + return ( +
+

요소를 선택하면 속성을 편집할 수 있습니다.

+
+ +
+ setWidthMm(Number(e.target.value) || 50)} + /> + × + setHeightMm(Number(e.target.value) || 30)} + /> +
+
+
+ ); + } + + const update = (updates: Partial) => + updateComponent(selected.id, updates); + + return ( +
+
+ 속성 + +
+
+
+
+ + update({ x: Number(e.target.value) || 0 })} + /> +
+
+ + update({ y: Number(e.target.value) || 0 })} + /> +
+
+ + update({ width: Number(e.target.value) || 10 })} + /> +
+
+ + update({ height: Number(e.target.value) || 10 })} + /> +
+
+ + {selected.type === "text" && ( + <> +
+ + update({ content: e.target.value })} + placeholder="텍스트" + /> +
+
+ + update({ fontSize: Number(e.target.value) || 10 })} + /> +
+
+ + update({ fontColor: e.target.value })} + className="h-9 w-20 p-1" + /> +
+ + )} + + {selected.type === "barcode" && ( + <> +
+ + +
+
+ + update({ barcodeValue: e.target.value })} + placeholder="123456789" + /> +
+
+ update({ showBarcodeText: v })} + /> + +
+ + )} + + {selected.type === "line" && ( + <> +
+ + update({ lineWidth: Number(e.target.value) || 1 })} + /> +
+
+ + update({ lineColor: e.target.value })} + className="h-9 w-20 p-1" + /> +
+ + )} + + {selected.type === "rectangle" && ( + <> +
+ + update({ lineWidth: Number(e.target.value) || 0 })} + /> +
+
+ + update({ lineColor: e.target.value })} + className="h-9 w-20 p-1" + /> +
+
+ + update({ backgroundColor: e.target.value })} + className="h-9 w-20 p-1" + /> +
+ + )} + + {selected.type === "image" && ( +
+ + update({ imageUrl: e.target.value })} + placeholder="https://..." + /> +

또는 나중에 업로드 기능 연동

+
+ )} +
+
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodeDesignerToolbar.tsx b/frontend/components/barcode/designer/BarcodeDesignerToolbar.tsx new file mode 100644 index 00000000..a7b0f598 --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeDesignerToolbar.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { ArrowLeft, Save, Loader2, Download, Printer } from "lucide-react"; +import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext"; +import { barcodeApi } from "@/lib/api/barcodeApi"; +import { useToast } from "@/hooks/use-toast"; +import { generateZPL } from "@/lib/zplGenerator"; +import { BarcodePrintPreviewModal } from "./BarcodePrintPreviewModal"; + +export function BarcodeDesignerToolbar() { + const router = useRouter(); + const { toast } = useToast(); + const { + labelId, + labelMaster, + widthMm, + heightMm, + components, + saveLayout, + isSaving, + } = useBarcodeDesigner(); + + const handleDownloadZPL = () => { + const layout = { width_mm: widthMm, height_mm: heightMm, components }; + const zpl = generateZPL(layout); + const blob = new Blob([zpl], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = (labelMaster?.label_name_kor || "label") + ".zpl"; + a.click(); + URL.revokeObjectURL(url); + toast({ title: "다운로드", description: "ZPL 파일이 다운로드되었습니다. Zebra 프린터/유틸에서 사용하세요." }); + }; + + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [printPreviewOpen, setPrintPreviewOpen] = useState(false); + const [newLabelName, setNewLabelName] = useState(""); + const [creating, setCreating] = useState(false); + + const handleSave = async () => { + if (labelId !== "new") { + await saveLayout(); + return; + } + setSaveDialogOpen(true); + }; + + const handleCreateAndSave = async () => { + const name = newLabelName.trim(); + if (!name) { + toast({ + title: "입력 필요", + description: "라벨명을 입력하세요.", + variant: "destructive", + }); + return; + } + setCreating(true); + try { + const createRes = await barcodeApi.createLabel({ + labelNameKor: name, + }); + if (!createRes.success || !createRes.data?.labelId) throw new Error(createRes.message || "생성 실패"); + const newId = createRes.data.labelId; + + await barcodeApi.saveLayout(newId, { + width_mm: widthMm, + height_mm: heightMm, + components: components.map((c, i) => ({ ...c, zIndex: i })), + }); + + toast({ title: "저장됨", description: "라벨이 생성되었습니다." }); + setSaveDialogOpen(false); + setNewLabelName(""); + router.push(`/admin/screenMng/barcodeList/designer/${newId}`); + } catch (e: any) { + toast({ + title: "저장 실패", + description: e.message || "라벨 생성에 실패했습니다.", + variant: "destructive", + }); + } finally { + setCreating(false); + } + }; + + return ( + <> +
+
+ + + {labelId === "new" ? "새 라벨" : labelMaster?.label_name_kor || "바코드 라벨 디자이너"} + +
+
+ + + +
+
+ + ({ ...c, zIndex: i })), + }} + labelName={labelMaster?.label_name_kor || "라벨"} + /> + + + + + 새 라벨 저장 + +
+ + setNewLabelName(e.target.value)} + placeholder="예: 품목 바코드 라벨" + /> +
+ + + + +
+
+ + ); +} diff --git a/frontend/components/barcode/designer/BarcodeLabelCanvasComponent.tsx b/frontend/components/barcode/designer/BarcodeLabelCanvasComponent.tsx new file mode 100644 index 00000000..3552f5fe --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeLabelCanvasComponent.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import { BarcodeLabelComponent } from "@/types/barcode"; +import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext"; +import JsBarcode from "jsbarcode"; +import QRCode from "qrcode"; +import { getFullImageUrl } from "@/lib/api/client"; +import { MM_TO_PX } from "@/contexts/BarcodeDesignerContext"; + +interface Props { + component: BarcodeLabelComponent; +} + +// 1D 바코드 렌더 +function Barcode1DRender({ + value, + format, + width, + height, + showText, +}: { + value: string; + format: string; + width: number; + height: number; + showText: boolean; +}) { + const svgRef = useRef(null); + useEffect(() => { + if (!svgRef.current || !value.trim()) return; + try { + JsBarcode(svgRef.current, value.trim(), { + format: format.toLowerCase(), + width: 2, + height: Math.max(20, height - (showText ? 14 : 4)), + displayValue: showText, + margin: 2, + }); + } catch { + // ignore + } + }, [value, format, height, showText]); + + return ( +
+ +
+ ); +} + +// QR 렌더 +function QRRender({ value, size }: { value: string; size: number }) { + const canvasRef = useRef(null); + useEffect(() => { + if (!canvasRef.current || !value.trim()) return; + QRCode.toCanvas(canvasRef.current, value.trim(), { + width: Math.max(40, size), + margin: 1, + }); + }, [value, size]); + + return ( +
+ +
+ ); +} + +export function BarcodeLabelCanvasComponent({ component }: Props) { + const { + updateComponent, + removeComponent, + selectComponent, + selectedComponentId, + snapValueToGrid, + } = useBarcodeDesigner(); + + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0, compX: 0, compY: 0 }); + const [isResizing, setIsResizing] = useState(false); + const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, w: 0, h: 0 }); + const ref = useRef(null); + const selected = selectedComponentId === component.id; + + const handleMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + selectComponent(component.id); + if ((e.target as HTMLElement).closest("[data-resize-handle]")) { + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + w: component.width, + h: component.height, + }); + } else { + setIsDragging(true); + setDragStart({ x: e.clientX, y: e.clientY, compX: component.x, compY: component.y }); + } + }; + + useEffect(() => { + if (!isDragging && !isResizing) return; + + const onMove = (e: MouseEvent) => { + if (isDragging) { + const dx = e.clientX - dragStart.x; + const dy = e.clientY - dragStart.y; + updateComponent(component.id, { + x: Math.max(0, snapValueToGrid(dragStart.compX + dx)), + y: Math.max(0, snapValueToGrid(dragStart.compY + dy)), + }); + } else if (isResizing) { + const dx = e.clientX - resizeStart.x; + const dy = e.clientY - resizeStart.y; + updateComponent(component.id, { + width: Math.max(20, resizeStart.w + dx), + height: Math.max(10, resizeStart.h + dy), + }); + } + }; + const onUp = () => { + setIsDragging(false); + setIsResizing(false); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + }, [ + isDragging, + isResizing, + dragStart, + resizeStart, + component.id, + updateComponent, + snapValueToGrid, + ]); + + const style: React.CSSProperties = { + position: "absolute", + left: component.x, + top: component.y, + width: component.width, + height: component.height, + zIndex: component.zIndex, + }; + + const border = selected ? "2px solid #2563eb" : "1px solid transparent"; + const isBarcode = component.type === "barcode"; + const isQR = component.barcodeType === "QR"; + + const content = () => { + switch (component.type) { + case "text": + return ( +
+ {component.content || "텍스트"} +
+ ); + case "barcode": + if (isQR) { + return ( + + ); + } + return ( + + ); + case "image": + return component.imageUrl ? ( + + ) : ( +
+ 이미지 +
+ ); + case "line": + return ( +
+ ); + case "rectangle": + return ( +
+ ); + default: + return null; + } + }; + + return ( +
+ {content()} + {selected && component.type !== "line" && ( +
{ + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + w: component.width, + h: component.height, + }); + }} + /> + )} +
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodePrintPreviewModal.tsx b/frontend/components/barcode/designer/BarcodePrintPreviewModal.tsx new file mode 100644 index 00000000..0db9b65e --- /dev/null +++ b/frontend/components/barcode/designer/BarcodePrintPreviewModal.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Download, Printer, Loader2, AlertCircle } from "lucide-react"; +import { BarcodeLabelLayout } from "@/types/barcode"; +import { generateZPL } from "@/lib/zplGenerator"; +import { + printZPLToZebraBLE, + isWebBluetoothSupported, + getUnsupportedMessage, +} from "@/lib/zebraBluetooth"; +import { + printZPLToBrowserPrint, + getBrowserPrintHelpMessage, +} from "@/lib/zebraBrowserPrint"; +import { useToast } from "@/hooks/use-toast"; +import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent"; +import { MM_TO_PX } from "@/contexts/BarcodeDesignerContext"; + +const PREVIEW_MAX_PX = 320; + +interface BarcodePrintPreviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + layout: BarcodeLabelLayout; + labelName?: string; +} + +export function BarcodePrintPreviewModal({ + open, + onOpenChange, + layout, + labelName = "라벨", +}: BarcodePrintPreviewModalProps) { + const { toast } = useToast(); + const [printing, setPrinting] = useState(false); + + const { width_mm, height_mm, components } = layout; + const widthPx = width_mm * MM_TO_PX; + const heightPx = height_mm * MM_TO_PX; + const scale = + widthPx > PREVIEW_MAX_PX || heightPx > PREVIEW_MAX_PX + ? Math.min(PREVIEW_MAX_PX / widthPx, PREVIEW_MAX_PX / heightPx) + : 1; + const previewW = Math.round(widthPx * scale); + const previewH = Math.round(heightPx * scale); + + const zpl = generateZPL(layout); + const bleSupported = isWebBluetoothSupported(); + const unsupportedMsg = getUnsupportedMessage(); + + const handleDownloadZPL = () => { + const blob = new Blob([zpl], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${labelName}.zpl`; + a.click(); + URL.revokeObjectURL(url); + toast({ title: "다운로드", description: "ZPL 파일이 저장되었습니다." }); + }; + + const handlePrintToZebra = async () => { + const canUseBle = bleSupported; + if (!canUseBle) { + // Browser Print만 시도 (스크립트 로드 후 기본 프린터로 전송) + setPrinting(true); + try { + const result = await printZPLToBrowserPrint(zpl); + if (result.success) { + toast({ title: "전송 완료", description: result.message }); + onOpenChange(false); + } else { + toast({ + title: "출력 실패", + description: result.message, + variant: "destructive", + }); + toast({ + title: "안내", + description: getBrowserPrintHelpMessage(), + variant: "default", + }); + } + } catch (e: unknown) { + toast({ + title: "오류", + description: (e as Error).message || "Zebra 출력 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setPrinting(false); + } + return; + } + + // Web Bluetooth 지원 시: Browser Print 먼저 시도, 실패하면 BLE로 폴백 + setPrinting(true); + try { + const bpResult = await printZPLToBrowserPrint(zpl); + if (bpResult.success) { + toast({ title: "전송 완료", description: bpResult.message }); + onOpenChange(false); + return; + } + const bleResult = await printZPLToZebraBLE(zpl); + if (bleResult.success) { + toast({ title: "전송 완료", description: bleResult.message }); + onOpenChange(false); + } else { + toast({ + title: "출력 실패", + description: bleResult.message, + variant: "destructive", + }); + toast({ + title: "안내", + description: getBrowserPrintHelpMessage(), + variant: "default", + }); + } + } catch (e: unknown) { + toast({ + title: "오류", + description: (e as Error).message || "Zebra 출력 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setPrinting(false); + } + }; + + return ( + + + + 인쇄 미리보기 + + +
+

+ {width_mm}×{height_mm}mm · {components.length}개 요소 +

+ + {/* 미리보기 캔버스 (축소) */} +
+
+
+ {components.map((c) => ( + + ))} +
+
+
+ + {!bleSupported && ( +
+ + + Web Bluetooth 미지원 브라우저입니다. Zebra Browser Print 앱을 설치하면 출력할 수 있습니다. + {unsupportedMsg && ` ${unsupportedMsg}`} + +
+ )} + +

+ {bleSupported ? ( + <> + Zebra 프린터를 Bluetooth LE로 켜 두고, 출력 시 기기 선택에서 프린터를 선택하세요. + (Chrome/Edge 권장) + {typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && ( + <> Android에서는 목록에 인근 BLE 기기가 모두 표시되므로, 'ZD421' 등 프린터 이름을 골라 주세요. + )} + + ) : null} + {typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && ( + <> + {" "} + 목록에 프린터가 안 나오면 지브라 공식 'Zebra Browser Print' 앱을 설치한 뒤, 앱에서 프린터 검색·기본 설정 후 이 사이트를 허용하면 출력할 수 있습니다. + + )} +

+
+ + + + + +
+
+ ); +} diff --git a/frontend/components/barcode/designer/BarcodeTemplatePalette.tsx b/frontend/components/barcode/designer/BarcodeTemplatePalette.tsx new file mode 100644 index 00000000..4eb026ba --- /dev/null +++ b/frontend/components/barcode/designer/BarcodeTemplatePalette.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Loader2, Search } from "lucide-react"; +import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext"; +import { barcodeApi, BarcodeLabelTemplate } from "@/lib/api/barcodeApi"; + +type Category = "all" | "basic" | "zebra"; + +export function BarcodeTemplatePalette() { + const { applyTemplate } = useBarcodeDesigner(); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [category, setCategory] = useState("all"); + const [searchText, setSearchText] = useState(""); + + useEffect(() => { + (async () => { + try { + const res = await barcodeApi.getTemplates(); + if (res.success && res.data) setTemplates(res.data); + } catch { + setTemplates([]); + } finally { + setLoading(false); + } + })(); + }, []); + + const filtered = useMemo(() => { + let list = templates; + if (category === "basic") { + list = list.filter((t) => t.template_id.startsWith("TMPL_")); + } else if (category === "zebra") { + list = list.filter((t) => t.template_id.startsWith("ZJ")); + } + const q = searchText.trim().toLowerCase(); + if (q) { + list = list.filter( + (t) => + t.template_id.toLowerCase().includes(q) || + (t.template_name_kor && t.template_name_kor.toLowerCase().includes(q)) || + (t.template_name_eng && t.template_name_eng.toLowerCase().includes(q)) + ); + } + return list; + }, [templates, category, searchText]); + + if (loading) { + return ( + + + 라벨 규격 + + + + + + ); + } + + return ( + + + 라벨 규격 + + +
+ + setSearchText(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+
+ + + +
+ +
+ {filtered.length === 0 ? ( +

검색 결과 없음

+ ) : ( + filtered.map((t) => ( + + )) + )} +
+
+
+
+ ); +} diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 42b1ee06..dcd1d987 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -63,6 +63,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: Search, description: "조건 입력 (텍스트/날짜/선택/모달)", }, + { + type: "pop-field", + label: "입력 필드", + icon: TextCursorInput, + description: "저장용 값 입력 (섹션별 멀티필드)", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 725b4f3f..52c8102f 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -36,6 +36,15 @@ interface ConnectionEditorProps { onRemoveConnection?: (connectionId: string) => void; } +// ======================================== +// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단 +// ======================================== + +function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean { + if (!meta?.sendable) return false; + return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value"); +} + // ======================================== // ConnectionEditor // ======================================== @@ -75,6 +84,8 @@ export default function ConnectionEditor({ ); } + const isFilterSource = hasFilterSendable(meta); + return (
{hasSendable && ( @@ -83,6 +94,7 @@ export default function ConnectionEditor({ meta={meta!} allComponents={allComponents} outgoing={outgoing} + isFilterSource={isFilterSource} onAddConnection={onAddConnection} onUpdateConnection={onUpdateConnection} onRemoveConnection={onRemoveConnection} @@ -92,7 +104,6 @@ export default function ConnectionEditor({ {hasReceivable && ( @@ -105,7 +116,6 @@ export default function ConnectionEditor({ // 대상 컴포넌트에서 정보 추출 // ======================================== -/** 화면에 표시 중인 컬럼만 추출 */ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] { if (!comp?.config) return []; const cfg = comp.config as Record; @@ -126,7 +136,6 @@ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): stri return cols; } -/** 대상 컴포넌트의 데이터소스 테이블명 추출 */ function extractTableName(comp: PopComponentDefinitionV5 | undefined): string { if (!comp?.config) return ""; const cfg = comp.config as Record; @@ -143,6 +152,7 @@ interface SendSectionProps { meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; outgoing: PopDataConnection[]; + isFilterSource: boolean; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; onRemoveConnection?: (connectionId: string) => void; @@ -153,6 +163,7 @@ function SendSection({ meta, allComponents, outgoing, + isFilterSource, onAddConnection, onUpdateConnection, onRemoveConnection, @@ -163,29 +174,42 @@ function SendSection({
- {/* 기존 연결 목록 */} {outgoing.map((conn) => (
{editingId === conn.id ? ( - { - onUpdateConnection?.(conn.id, data); - setEditingId(null); - }} - onCancel={() => setEditingId(null)} - submitLabel="수정" - /> + isFilterSource ? ( + { + onUpdateConnection?.(conn.id, data); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + submitLabel="수정" + /> + ) : ( + { + onUpdateConnection?.(conn.id, data); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + submitLabel="수정" + /> + ) ) : (
- {conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`} + {conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
))} - {/* 새 연결 추가 */} - onAddConnection?.(data)} - submitLabel="연결 추가" - /> + {isFilterSource ? ( + onAddConnection?.(data)} + submitLabel="연결 추가" + /> + ) : ( + onAddConnection?.(data)} + submitLabel="연결 추가" + /> + )}
); } // ======================================== -// 연결 폼 (추가/수정 공용) +// 단순 연결 폼 (이벤트 타입: "어디로" 1개만) // ======================================== -interface ConnectionFormProps { +interface SimpleConnectionFormProps { + component: PopComponentDefinitionV5; + allComponents: PopComponentDefinitionV5[]; + initial?: PopDataConnection; + onSubmit: (data: Omit) => void; + onCancel?: () => void; + submitLabel: string; +} + +function SimpleConnectionForm({ + component, + allComponents, + initial, + onSubmit, + onCancel, + submitLabel, +}: SimpleConnectionFormProps) { + const [selectedTargetId, setSelectedTargetId] = React.useState( + initial?.targetComponent || "" + ); + + const targetCandidates = allComponents.filter((c) => { + if (c.id === component.id) return false; + const reg = PopComponentRegistry.getComponent(c.type); + return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; + }); + + const handleSubmit = () => { + if (!selectedTargetId) return; + + const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const srcLabel = component.label || component.id; + const tgtLabel = targetComp?.label || targetComp?.id || "?"; + + onSubmit({ + sourceComponent: component.id, + sourceField: "", + sourceOutput: "_auto", + targetComponent: selectedTargetId, + targetField: "", + targetInput: "_auto", + label: `${srcLabel} → ${tgtLabel}`, + }); + + if (!initial) { + setSelectedTargetId(""); + } + }; + + return ( +
+ {onCancel && ( +
+

연결 수정

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

새 연결 추가

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

새 연결 추가

)} - {/* 보내는 값 */}
보내는 값
- {/* 받는 컴포넌트 */}
받는 컴포넌트 setFilterMode(v)}> @@ -540,7 +658,6 @@ function ConnectionForm({
)} - {/* 제출 버튼 */} - - - 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 c57d6d52..3e07526b 100644 --- a/frontend/components/pop/management/PopScreenSettingModal.tsx +++ b/frontend/components/pop/management/PopScreenSettingModal.tsx @@ -166,19 +166,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, @@ -202,11 +209,11 @@ export function PopScreenSettingModal({ return ( - - + + POP 화면 설정 - {screen.screenName} ({screen.screenCode}) + {screen.screenName} [{screen.screenCode}] @@ -215,57 +222,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" />
-
+
@@ -283,7 +290,7 @@ export function PopScreenSettingModal({
-
+
@@ -291,13 +298,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" />
-
+
@@ -308,7 +315,7 @@ export function PopScreenSettingModal({ placeholder="lucide 아이콘 이름 (예: Package)" className="h-8 text-xs sm:h-10 sm:text-sm" /> -

+

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

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

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

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

-
- + {subScreens.length === 0 ? (
@@ -340,12 +347,12 @@ export function PopScreenSettingModal({
) : (
- {subScreens.map((subScreen, index) => ( + {subScreens.map((subScreen) => (
- +
@@ -363,7 +370,7 @@ export function PopScreenSettingModal({ updateSubScreen(subScreen.id, "type", v) } > - + @@ -375,7 +382,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; } diff --git a/frontend/lib/zebraBluetooth.ts b/frontend/lib/zebraBluetooth.ts new file mode 100644 index 00000000..14dcd9ff --- /dev/null +++ b/frontend/lib/zebraBluetooth.ts @@ -0,0 +1,118 @@ +/** + * Zebra 프린터 Web Bluetooth LE 연동 + * Chrome/Edge (Chromium) 에서만 지원. BLE로 ZPL 전송 (512바이트 청크) + * 참고: https://developer.zebra.com/content/printing-webapp-using-webbluetooth + */ + +const ZEBRA_BLE_SERVICE_UUID = "38eb4a80-c570-11e3-9507-0002a5d5c51b"; +const ZEBRA_BLE_CHAR_UUID = "38eb4a82-c570-11e3-9507-0002a5d5c51b"; +const CHUNK_SIZE = 512; +const CHUNK_DELAY_MS = 20; + +export function isWebBluetoothSupported(): boolean { + if (typeof window === "undefined") return false; + return !!(navigator.bluetooth && navigator.bluetooth.requestDevice); +} + +/** 지원 브라우저 안내 문구 */ +export function getUnsupportedMessage(): string { + if (!isWebBluetoothSupported()) { + return "이 브라우저는 Web Bluetooth를 지원하지 않습니다. Chrome 또는 Edge(Chromium)에서 열어주세요. HTTPS 또는 localhost 필요."; + } + return ""; +} + +export interface ZebraPrintResult { + success: boolean; + message: string; +} + +/** + * Zebra 프린터를 BLE로 선택·연결 후 ZPL 데이터 전송 + * - 사용자에게 블루투스 기기 선택 창이 뜸 (Zebra 프린터 BLE 선택) + * - ZPL을 512바이트 단위로 나누어 순차 전송 + */ +export async function printZPLToZebraBLE(zpl: string): Promise { + if (!isWebBluetoothSupported()) { + return { + success: false, + message: "Web Bluetooth를 지원하지 않는 브라우저입니다. Chrome 또는 Edge에서 시도해주세요.", + }; + } + + let device: BluetoothDevice | null = null; + let server: BluetoothRemoteGATTServer | null = null; + + try { + // 1) 서비스 UUID로만 필터 시 Android에서 Zebra가 광고하지 않으면 목록에 안 나옴. + // 2) acceptAllDevices + optionalServices 로 모든 BLE 기기 표시 후, 연결해 Zebra 서비스 사용. + const useAcceptAll = + typeof navigator !== "undefined" && + /Android/i.test(navigator.userAgent); + + if (useAcceptAll) { + device = await navigator.bluetooth.requestDevice({ + acceptAllDevices: true, + optionalServices: [ZEBRA_BLE_SERVICE_UUID], + }); + } else { + device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [ZEBRA_BLE_SERVICE_UUID] }], + optionalServices: [ZEBRA_BLE_SERVICE_UUID], + }); + } + + if (!device) { + return { success: false, message: "프린터를 선택하지 않았습니다." }; + } + + server = await device.gatt!.connect(); + let service: BluetoothRemoteGATTService; + try { + service = await server.getPrimaryService(ZEBRA_BLE_SERVICE_UUID); + } catch { + return { + success: false, + message: + "선택한 기기는 Zebra 프린터가 아니거나 BLE 인쇄를 지원하지 않습니다. 'ZD421' 등 Zebra 프린터를 선택해 주세요.", + }; + } + const characteristic = await service.getCharacteristic(ZEBRA_BLE_CHAR_UUID); + + const encoder = new TextEncoder(); + const data = encoder.encode(zpl); + const totalChunks = Math.ceil(data.length / CHUNK_SIZE); + + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, data.length); + const chunk = data.slice(start, end); + await characteristic.writeValue(chunk); + if (i < totalChunks - 1 && CHUNK_DELAY_MS > 0) { + await new Promise((r) => setTimeout(r, CHUNK_DELAY_MS)); + } + } + + return { success: true, message: "Zebra 프린터로 전송했습니다." }; + } catch (err: unknown) { + const e = err as Error & { name?: string }; + if (e.name === "NotFoundError") { + return { success: false, message: "Zebra 프린터(BLE)를 찾을 수 없습니다. 프린터 전원과 블루투스 설정을 확인하세요." }; + } + if (e.name === "NotAllowedError") { + return { success: false, message: "블루투스 연결이 거부되었습니다." }; + } + return { + success: false, + message: e.message || "Zebra BLE 출력 중 오류가 발생했습니다.", + }; + } finally { + if (server && device?.gatt?.connected) { + try { + device.gatt.disconnect(); + } catch { + // ignore + } + } + } +} diff --git a/frontend/lib/zebraBrowserPrint.ts b/frontend/lib/zebraBrowserPrint.ts new file mode 100644 index 00000000..b1ca8ad8 --- /dev/null +++ b/frontend/lib/zebraBrowserPrint.ts @@ -0,0 +1,121 @@ +/** + * Zebra Browser Print 연동 + * - 지브라 공식 "Zebra Browser Print" 앱(Windows/macOS/Android)과 웹 페이지 통신 + * - 앱 설치 시 네트워크·Bluetooth 프린터 발견 후 ZPL 전송 가능 (Chrome 권장) + * - Android: Browser Print APK 설치 시 Chrome에서 목록에 안 나오는 문제 우회 가능 + * 참고: https://developer.zebra.com/products/printers/browser-print + */ + +const BROWSER_PRINT_SCRIPT_URL = + "https://cdn.jsdelivr.net/npm/zebra-browser-print-min@3.0.216/BrowserPrint-3.0.216.min.js"; + +/** ZebraPrintResult와 동일한 형태 (zebraBluetooth와 공유) */ +export interface ZebraPrintResult { + success: boolean; + message: string; +} + +declare global { + interface Window { + BrowserPrint?: { + getDefaultDevice: ( + type: string, + onSuccess: (device: BrowserPrintDevice) => void, + onError: (err: string) => void + ) => void; + }; + } +} + +interface BrowserPrintDevice { + send: ( + data: string, + onSuccess: () => void, + onError: (err: string) => void + ) => void; +} + +let scriptLoadPromise: Promise | null = null; + +/** Browser Print 스크립트를 한 번만 동적 로드 */ +function loadBrowserPrintScript(): Promise { + if (typeof window === "undefined") return Promise.resolve(false); + if (window.BrowserPrint) return Promise.resolve(true); + if (scriptLoadPromise) return scriptLoadPromise; + + scriptLoadPromise = new Promise((resolve) => { + const existing = document.querySelector( + `script[src="${BROWSER_PRINT_SCRIPT_URL}"]` + ); + if (existing) { + resolve(!!window.BrowserPrint); + return; + } + const script = document.createElement("script"); + script.src = BROWSER_PRINT_SCRIPT_URL; + script.async = true; + script.onload = () => resolve(!!window.BrowserPrint); + script.onerror = () => resolve(false); + document.head.appendChild(script); + }); + + return scriptLoadPromise; +} + +/** Browser Print 앱이 설치되어 있고 기본 프린터를 사용할 수 있는지 확인 */ +export function isBrowserPrintAvailable(): boolean { + return typeof window !== "undefined" && !!window.BrowserPrint; +} + +/** + * Zebra Browser Print 앱으로 ZPL 전송 (기본 프린터 사용) + * - 앱 미설치 또는 기본 프린터 없으면 실패 + */ +export function printZPLToBrowserPrint(zpl: string): Promise { + return loadBrowserPrintScript().then((loaded) => { + if (!loaded || !window.BrowserPrint) { + return { + success: false, + message: + "Zebra Browser Print 스크립트를 불러올 수 없습니다. CDN 연결을 확인하세요.", + }; + } + + return new Promise((resolve) => { + window.BrowserPrint!.getDefaultDevice( + "printer", + (device) => { + if (!device) { + resolve({ + success: false, + message: + "기본 Zebra 프린터가 설정되지 않았습니다. Browser Print 앱에서 프린터를 검색해 기본으로 지정해 주세요.", + }); + return; + } + device.send( + zpl, + () => resolve({ success: true, message: "Zebra Browser Print로 전송했습니다." }), + (err) => + resolve({ + success: false, + message: err || "Browser Print 전송 중 오류가 발생했습니다.", + }) + ); + }, + (err) => + resolve({ + success: false, + message: + err || + "Zebra Browser Print 앱이 설치되어 있지 않거나 연결할 수 없습니다. Android에서는 'Zebra Browser Print' 앱을 설치한 뒤 Chrome에서 이 페이지를 허용해 주세요.", + }) + ); + }); + }); +} + +/** Browser Print 앱 설치/다운로드 안내 문구 */ +export function getBrowserPrintHelpMessage(): string { + return "Android에서 Bluetooth 목록에 프린터가 안 나오면, Zebra 공식 'Zebra Browser Print' 앱을 설치한 뒤 앱에서 프린터를 검색·기본 설정하고, 이 사이트를 허용하면 'Zebra 프린터로 출력'으로 인쇄할 수 있습니다."; +} diff --git a/frontend/lib/zplGenerator.ts b/frontend/lib/zplGenerator.ts new file mode 100644 index 00000000..c0b4dd17 --- /dev/null +++ b/frontend/lib/zplGenerator.ts @@ -0,0 +1,67 @@ +/** + * ZPL(Zebra Programming Language) 생성 + * ZD421 등 Zebra 프린터용 라벨 데이터 생성 (200 DPI = 8 dots/mm 기준) + */ + +import { BarcodeLabelLayout } from "@/types/barcode"; + +const MM_TO_PX = 4; +const DOTS_PER_MM = 8; // 200 DPI + +function pxToDots(px: number): number { + const mm = px / MM_TO_PX; + return Math.round(mm * DOTS_PER_MM); +} + +export function generateZPL(layout: BarcodeLabelLayout): string { + const { width_mm, height_mm, components } = layout; + const widthDots = Math.round(width_mm * DOTS_PER_MM); + const heightDots = Math.round(height_mm * DOTS_PER_MM); + + const lines: string[] = [ + "^XA", + "^PW" + widthDots, + "^LL" + heightDots, + "^LH0,0", + ]; + + const sorted = [...components].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)); + + for (const c of sorted) { + const x = pxToDots(c.x); + const y = pxToDots(c.y); + const w = pxToDots(c.width); + const h = pxToDots(c.height); + + if (c.type === "text") { + const fontH = Math.max(10, Math.min(120, (c.fontSize || 10) * 4)); // 대략적 변환 + const fontW = Math.round(fontH * 0.6); + lines.push(`^FO${x},${y}`); + lines.push(`^A0N,${fontH},${fontW}`); + lines.push(`^FD${escapeZPL(c.content || "")}^FS`); + } else if (c.type === "barcode") { + if (c.barcodeType === "QR") { + const size = Math.min(w, h); + const qrSize = Math.max(1, Math.min(10, Math.round(size / 20))); + lines.push(`^FO${x},${y}`); + lines.push(`^BQN,2,${qrSize}`); + lines.push(`^FDQA,${escapeZPL(c.barcodeValue || "")}^FS`); + } else { + // CODE128: ^BC, CODE39: ^B3 + const mod = c.barcodeType === "CODE39" ? "^B3N" : "^BCN"; + const showText = c.showBarcodeText !== false ? "Y" : "N"; + lines.push(`^FO${x},${y}`); + lines.push(`${mod},${Math.max(20, h - 10)},${showText},N,N`); + lines.push(`^FD${escapeZPL(c.barcodeValue || "")}^FS`); + } + } + // 이미지/선/사각형은 ZPL에서 비트맵 또는 ^GB 등으로 확장 가능 (생략) + } + + lines.push("^XZ"); + return lines.join("\n"); +} + +function escapeZPL(s: string): string { + return s.replace(/\^/g, "^^").replace(/~/g, "~~"); +} diff --git a/frontend/types/barcode.ts b/frontend/types/barcode.ts new file mode 100644 index 00000000..4e6e5e41 --- /dev/null +++ b/frontend/types/barcode.ts @@ -0,0 +1,86 @@ +/** + * 바코드 라벨 관리 시스템 타입 정의 + * ZD421 등 바코드 프린터 연동용 라벨 템플릿/출력 관리 + */ + +// 캔버스 요소 (디자이너용) +export interface BarcodeLabelComponent { + id: string; + type: "text" | "barcode" | "image" | "line" | "rectangle"; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + content?: string; + fontSize?: number; + fontColor?: string; + fontWeight?: string; + barcodeType?: string; + barcodeValue?: string; + showBarcodeText?: boolean; + imageUrl?: string; + objectFit?: string; + lineColor?: string; + lineWidth?: number; + backgroundColor?: string; +} + +export interface BarcodeLabelLayout { + width_mm: number; + height_mm: number; + components: BarcodeLabelComponent[]; +} + +// 바코드 라벨 마스터 (목록/카드용) +export interface BarcodeLabelMaster { + label_id: string; + label_name_kor: string; + label_name_eng: string | null; + description: string | null; + width_mm?: number; + height_mm?: number; + layout_json?: string | null; + template_type?: string; + use_yn: string; + created_at: string; + created_by: string | null; + updated_at: string | null; + updated_by: string | null; +} + +// 목록 조회 응답 +export interface GetBarcodeLabelsResponse { + items: BarcodeLabelMaster[]; + total: number; + page: number; + limit: number; +} + +// 목록 조회 파라미터 +export interface GetBarcodeLabelsParams { + page?: number; + limit?: number; + searchText?: string; + useYn?: string; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +} + +// 생성 요청 +export interface CreateBarcodeLabelRequest { + labelNameKor: string; + labelNameEng?: string; + description?: string; + templateType?: string; + templateId?: string; // 선택 시 해당 템플릿 레이아웃 적용 +} + +// 수정 요청 +export interface UpdateBarcodeLabelRequest { + labelNameKor?: string; + labelNameEng?: string; + description?: string; + templateType?: string; + useYn?: string; +}