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/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index 4b57b846..a9bd0755 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -169,9 +169,9 @@ export class CommonCodeController { userId: userId || "", action: "CREATE", resourceType: "CODE_CATEGORY", - resourceId: category?.categoryCode, - resourceName: category?.categoryName || categoryData.categoryName, - summary: `코드 카테고리 "${category?.categoryName || categoryData.categoryName}" 생성`, + resourceId: category?.category_code, + resourceName: category?.category_name || categoryData.categoryName, + summary: `코드 카테고리 "${category?.category_name || categoryData.categoryName}" 생성`, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -227,7 +227,7 @@ export class CommonCodeController { action: "UPDATE", resourceType: "CODE_CATEGORY", resourceId: categoryCode, - resourceName: category?.categoryName, + resourceName: category?.category_name, summary: `코드 카테고리 "${categoryCode}" 수정`, ipAddress: getClientIp(req), requestPath: req.originalUrl, diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 68f12dde..8a9f6b56 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -248,7 +248,7 @@ router.put( resourceId: ruleId, summary: `채번 규칙(ID:${ruleId}) 수정`, changes: { - before: { ruleName: beforeRule?.ruleName, prefix: beforeRule?.prefix }, + before: { ruleName: beforeRule?.ruleName, separator: beforeRule?.separator }, after: updates, }, ipAddress: getClientIp(req), diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 0e97e2e2..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})`; @@ -308,6 +313,7 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response const client = await pool.connect(); try { const { id } = req.params; + const deleteNumberingRules = req.query.deleteNumberingRules === "true"; const companyCode = req.user?.companyCode || "*"; await client.query('BEGIN'); @@ -380,31 +386,29 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response }); } - // 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 시) - // 삭제되는 그룹이 최상위인지 확인 - const isRootGroup = await client.query( - `SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`, - [id] - ); - - if (isRootGroup.rows.length > 0) { - // 최상위 그룹 삭제 시 해당 회사의 채번 규칙도 삭제 - // 먼저 파트 삭제 - await client.query( - `DELETE FROM numbering_rule_parts - WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`, - [targetCompanyCode] + // 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 + 사용자가 명시적으로 요청한 경우에만) + if (deleteNumberingRules) { + const isRootGroup = await client.query( + `SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`, + [id] ); - // 규칙 삭제 - const deletedRules = await client.query( - `DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`, - [targetCompanyCode] - ); - if (deletedRules.rowCount && deletedRules.rowCount > 0) { - logger.info("그룹 삭제 시 채번 규칙 삭제", { - companyCode: targetCompanyCode, - deletedCount: deletedRules.rowCount - }); + + if (isRootGroup.rows.length > 0) { + await client.query( + `DELETE FROM numbering_rule_parts + WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`, + [targetCompanyCode] + ); + const deletedRules = await client.query( + `DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`, + [targetCompanyCode] + ); + if (deletedRules.rowCount && deletedRules.rowCount > 0) { + logger.warn("최상위 그룹 삭제 시 채번 규칙 삭제 (사용자 명시 요청)", { + companyCode: targetCompanyCode, + deletedCount: deletedRules.rowCount + }); + } } } } @@ -2574,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); @@ -2592,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, @@ -2609,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} @@ -2768,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: "그룹을 찾을 수 없습니다." }); } @@ -2782,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}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.` + }); } // 연결된 화면 확인 @@ -2791,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}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.` + }); } // 삭제 @@ -2806,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..cb6df7c4 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({ @@ -116,7 +117,7 @@ export const createScreen = async ( userName: (req.user as any)?.userName || "", action: "CREATE", resourceType: "SCREEN", - resourceId: String(newScreen?.id || ""), + resourceId: String(newScreen?.screenId || ""), resourceName: newScreen?.screenName || screenData.screenName, summary: `화면 "${newScreen?.screenName || screenData.screenName}" 생성`, changes: { after: { screenName: newScreen?.screenName, tableName: newScreen?.tableName } }, @@ -668,7 +669,7 @@ export const copyScreen = async ( userName: (req.user as any)?.userName || "", action: "COPY", resourceType: "SCREEN", - resourceId: String(copiedScreen?.id || ""), + resourceId: String(copiedScreen?.screenId || ""), resourceName: screenName, summary: `화면 "${screenName}" 복사 (원본 ID:${id})`, changes: { after: { sourceScreenId: id, screenName, screenCode } }, @@ -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/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 791940ec..9dea4037 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -513,6 +513,15 @@ export class TableManagementService { detailSettingsStr = JSON.stringify(settings.detailSettings); } + // 입력타입에 해당하지 않는 설정값은 NULL로 강제 초기화 + const inputType = settings.inputType; + const referenceTable = inputType === "entity" ? (settings.referenceTable || null) : null; + const referenceColumn = inputType === "entity" ? (settings.referenceColumn || null) : null; + const displayColumn = inputType === "entity" ? (settings.displayColumn || null) : null; + const codeCategory = inputType === "code" ? (settings.codeCategory || null) : null; + const codeValue = inputType === "code" ? (settings.codeValue || null) : null; + const categoryRef = inputType === "category" ? (settings.categoryRef || null) : null; + await query( `INSERT INTO table_type_columns ( table_name, column_name, column_label, input_type, detail_settings, @@ -525,11 +534,11 @@ export class TableManagementService { column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type), detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings), - code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category), - code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value), - reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table), - reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column), - display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), + code_category = EXCLUDED.code_category, + code_value = EXCLUDED.code_value, + reference_table = EXCLUDED.reference_table, + reference_column = EXCLUDED.reference_column, + display_column = EXCLUDED.display_column, display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), category_ref = EXCLUDED.category_ref, @@ -538,17 +547,17 @@ export class TableManagementService { tableName, columnName, settings.columnLabel, - settings.inputType, + inputType, detailSettingsStr, - settings.codeCategory, - settings.codeValue, - settings.referenceTable, - settings.referenceColumn, - settings.displayColumn, + codeCategory, + codeValue, + referenceTable, + referenceColumn, + displayColumn, settings.displayOrder || 0, settings.isVisible !== undefined ? settings.isVisible : true, companyCode, - settings.categoryRef || null, + categoryRef, ] ); @@ -849,16 +858,26 @@ export class TableManagementService { ...detailSettings, }; - // table_type_columns 테이블에서 업데이트 (company_code 추가) + // 입력타입 변경 시 이전 타입의 설정값 초기화 + const clearEntity = finalInputType !== "entity"; + const clearCode = finalInputType !== "code"; + const clearCategory = finalInputType !== "category"; + await query( `INSERT INTO table_type_columns ( - table_name, column_name, input_type, detail_settings, + table_name, column_name, input_type, detail_settings, is_nullable, display_order, company_code, created_date, updated_date ) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now()) - ON CONFLICT (table_name, column_name, company_code) - DO UPDATE SET + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + reference_table = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_table END, + reference_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_column END, + display_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.display_column END, + code_category = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_category END, + code_value = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_value END, + category_ref = CASE WHEN $8 THEN NULL ELSE table_type_columns.category_ref END, updated_date = now()`, [ tableName, @@ -866,6 +885,9 @@ export class TableManagementService { finalInputType, JSON.stringify(finalDetailSettings), companyCode, + clearEntity, + clearCode, + clearCategory, ] ); 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/docs/v2-table-list-entity-join-analysis.md b/docs/v2-table-list-entity-join-analysis.md new file mode 100644 index 00000000..c8ab1ca8 --- /dev/null +++ b/docs/v2-table-list-entity-join-analysis.md @@ -0,0 +1,263 @@ +# v2-table-list Entity 조인 기능 분석 + +v2-repeater에 동일 기능을 추가하기 위한 상세 분석 문서입니다. + +--- + +## 1. 개요 + +v2-table-list의 Entity 조인 기능은 두 가지 유형으로 구분됩니다: + +| 유형 | 설명 | 설정 방식 | +|------|------|-----------| +| **isEntityJoin** | 테이블 컬럼이 `input_type=entity`인 경우 (테이블 타입 관리에서 참조 테이블 설정됨) | 자동 감지 + entityDisplayConfig로 표시 컬럼 선택 | +| **additionalJoinInfo** | ConfigPanel "Entity 조인 컬럼" 탭에서 수동 추가한 참조 테이블 컬럼 | addEntityColumn으로 추가, additionalJoinInfo 저장 | + +--- + +## 2. Entity 조인 설정 UI 구조 (TableListConfigPanel) + +### 2.1 데이터 소스 + +- **entityJoinApi.getEntityJoinColumns(tableName)** 호출 +- targetTableName 변경 시 useEffect로 재호출 + +### 2.2 entityJoinColumns 상태 구조 + +```typescript +{ + availableColumns: Array<{ + tableName: string; // 참조 테이블명 (예: dept_info) + columnName: string; // 참조 테이블 컬럼명 (예: company_name) + columnLabel: string; + dataType: string; + joinAlias: string; // 예: dept_code_company_name (sourceColumn_columnName) + suggestedLabel: string; + }>; + joinTables: Array<{ + tableName: string; // 참조 테이블명 + currentDisplayColumn: string; + joinConfig: { // 백엔드 entity-join-columns API에서 반환 + sourceColumn: string; // 기준 테이블 FK 컬럼 (예: dept_code) + referenceTable: string; + referenceColumn: string; + displayColumn: string; + // ... + }; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + inputType?: string; + }>; + }>; +} +``` + +### 2.3 Entity 조인 컬럼 UI (ConfigPanel) + +- **위치**: 기본 컬럼 선택 영역 아래, "Entity 조인 컬럼" 섹션 +- **조건**: `entityJoinColumns.joinTables.length > 0` 일 때만 표시 +- **구조**: joinTables별로 그룹화 → 각 그룹 내 availableColumns를 체크박스로 표시 +- **추가 로직**: `addEntityColumn(joinColumn)` 호출 + +### 2.4 addEntityColumn 함수 (핵심) + +```typescript +const addEntityColumn = (joinColumn: availableColumns[0]) => { + // joinTables에서 sourceColumn 추출 (필수!) + const joinTableInfo = entityJoinColumns.joinTables?.find( + (jt) => jt.tableName === joinColumn.tableName + ); + const sourceColumn = joinTableInfo?.joinConfig?.sourceColumn || ""; + + const newColumn: ColumnConfig = { + columnName: joinColumn.joinAlias, // 예: dept_code_company_name + displayName: joinColumn.columnLabel, + // ... + isEntityJoin: false, // 조인 탭에서 추가한 컬럼은 엔티티 타입이 아님 + additionalJoinInfo: { + sourceTable: config.selectedTable || screenTableName || "", + sourceColumn: sourceColumn, // dept_code + referenceTable: joinColumn.tableName, // dept_info + joinAlias: joinColumn.joinAlias, // dept_code_company_name + }, + }; + handleChange("columns", [...config.columns, newColumn]); +}; +``` + +**주의**: `sourceColumn`은 반드시 `joinTableInfo.joinConfig.sourceColumn`에서 가져와야 합니다. `joinColumn`에는 없습니다. + +--- + +## 3. additionalJoinInfo 데이터 구조 + +### 3.1 타입 정의 (types.ts) + +```typescript +additionalJoinInfo?: { + sourceTable: string; // 기준 테이블 (예: user_info) + sourceColumn: string; // 기준 테이블 FK 컬럼 (예: dept_code) + referenceTable?: string; // 참조 테이블 (예: dept_info) + joinAlias: string; // 조인 결과 컬럼 별칭 (예: dept_code_company_name) +}; +``` + +### 3.2 네이밍 규칙 + +- **joinAlias**: `${sourceColumn}_${referenceTable컬럼명}` +- 예: `dept_code` + `company_name` → `dept_code_company_name` +- 백엔드가 이 규칙으로 SELECT 시 alias를 생성하고, 응답 row에 `dept_code_company_name` 키로 값이 들어옴 + +--- + +## 4. 백엔드 API 호출 흐름 + +### 4.1 TableListComponent 데이터 로딩 + +```typescript +// 1. additionalJoinInfo가 있는 컬럼만 추출 +const entityJoinColumns = (tableConfig.columns || []) + .filter((col) => col.additionalJoinInfo) + .map((col) => ({ + sourceTable: col.additionalJoinInfo!.sourceTable, + sourceColumn: col.additionalJoinInfo!.sourceColumn, + joinAlias: col.additionalJoinInfo!.joinAlias, + referenceTable: col.additionalJoinInfo!.referenceTable, + })); + +// 2. entityDisplayConfig가 있는 컬럼 (isEntityJoin) - 화면별 표시 설정 +const screenEntityConfigs: Record = {}; +(tableConfig.columns || []) + .filter((col) => col.entityDisplayConfig?.displayColumns?.length > 0) + .forEach((col) => { + screenEntityConfigs[col.columnName] = { + displayColumns: col.entityDisplayConfig!.displayColumns, + separator: col.entityDisplayConfig!.separator || " - ", + sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable, + joinTable: col.entityDisplayConfig!.joinTable, + }; + }); + +// 3. API 호출 +response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { + page, size, sortBy, sortOrder, + search: hasFilters ? filters : undefined, + enableEntityJoin: true, + additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, + screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, + dataFilter: tableConfig.dataFilter, + excludeFilter: excludeFilterParam, +}); +``` + +### 4.2 entityJoinApi.getTableDataWithJoins 파라미터 + +```typescript +additionalJoinColumns?: Array<{ + sourceTable: string; + sourceColumn: string; + joinAlias: string; + referenceTable?: string; // 백엔드에서 referenceTable로 기존 조인 찾을 때 사용 +}>; +``` + +- **전달 방식**: `JSON.stringify(additionalJoinColumns)` 후 쿼리 파라미터로 전달 +- **백엔드**: `entityJoinController` → `tableManagementService.getTableDataWithEntityJoins` + +### 4.3 백엔드 처리 (tableManagementService) + +1. `detectEntityJoins`로 기본 Entity 조인 설정 조회 +2. `additionalJoinColumns`가 있으면: + - `sourceColumn` 또는 `referenceTable`로 기존 joinConfig 찾기 + - `joinAlias`에서 실제 컬럼명 추출 (예: `dept_code_company_name` → `company_name`) + - 기존 config에 `displayColumns` 병합 또는 새 config 추가 + - `aliasColumn`: `${sourceColumn}_${actualColumnName}` (예: `dept_code_company_name`) +3. `additionalJoinColumns`가 있으면 **full_join** 전략 강제 사용 (캐시 미사용) + +--- + +## 5. 데이터 표시 시 조인 데이터 매핑 + +### 5.1 additionalJoinInfo 컬럼 (조인 탭에서 추가한 컬럼) + +- **백엔드 응답**: row에 `joinAlias` 키로 값이 직접 들어옴 + - 예: `row.dept_code_company_name = "개발팀"` +- **프론트엔드**: `column.columnName`이 `joinAlias`와 동일하므로 `rowData[column.columnName]`으로 바로 접근 +- **formatCellValue**: `entityDisplayConfig`가 없으면 일반 컬럼처럼 `value` 사용 (이미 row에 joinAlias로 들어있음) + +### 5.2 entityDisplayConfig 컬럼 (isEntityJoin, 테이블 타입 관리에서 entity 설정된 컬럼) + +- **formatCellValue** 로직: + ```typescript + if (column.entityDisplayConfig && rowData) { + const displayColumns = column.entityDisplayConfig.displayColumns; + const separator = column.entityDisplayConfig.separator; + const values = displayColumns.map((colName) => { + const joinedKey = `${column.columnName}_${colName}`; // 예: manager_user_name + let cellValue = rowData[joinedKey]; + if (cellValue == null) cellValue = rowData[colName]; + return cellValue ?? ""; + }); + return values.filter(v => v !== "").join(separator || " - "); + } + ``` +- **백엔드 alias 규칙**: `${sourceColumn}_${displayColumn}` (예: `manager_user_name`) + +### 5.3 joinedColumnMeta (inputType/category 매핑) + +- additionalJoinInfo 컬럼도 `joinedColumnMeta`에 등록됨 +- `actualColumn` 추출: `joinAlias.replace(\`${sourceColumn}_\`, "")` → 참조 테이블의 실제 컬럼명 +- 조인 테이블별로 `tableTypeApi.getColumnInputTypes` 호출하여 inputType 로드 + +--- + +## 6. entity-join-columns API (ConfigPanel용) + +- **엔드포인트**: `GET /api/table-management/tables/:tableName/entity-join-columns` +- **역할**: 화면 편집기에서 "Entity 조인 컬럼" 탭에 표시할 데이터 제공 +- **응답**: + - `joinTables`: 각 Entity 조인별 `joinConfig`, `tableName`, `availableColumns` + - `availableColumns`: 모든 조인 컬럼을 flat하게 (joinAlias 포함) +- **joinConfig**: `entityJoinService.detectEntityJoins` 결과에서 옴 (테이블 타입 관리의 reference_table 설정 기반) + +--- + +## 7. v2-repeater 적용 시 체크리스트 + +### ConfigPanel + +- [ ] `entityJoinApi.getEntityJoinColumns(targetTableName)` 호출 +- [ ] `entityJoinColumns` 상태 (availableColumns, joinTables) +- [ ] "Entity 조인 컬럼" UI 섹션 (joinTables.length > 0일 때) +- [ ] `addEntityColumn` 함수: `joinConfig.sourceColumn` 사용 +- [ ] RepeaterColumnConfig에 `additionalJoinInfo` 타입 추가 + +### 데이터 로딩 (RepeaterComponent) + +- [ ] `additionalJoinInfo`가 있는 컬럼 추출 → `entityJoinColumns` 배열 생성 +- [ ] `entityJoinApi.getTableDataWithJoins` 호출 시 `additionalJoinColumns` 전달 +- [ ] `entityDisplayConfig`가 있으면 `screenEntityConfigs`에도 포함 (isEntityJoin 컬럼용) + +### 셀 렌더링 + +- [ ] additionalJoinInfo 컬럼: `rowData[column.columnName]` (joinAlias와 동일) +- [ ] entityDisplayConfig 컬럼: displayColumns + separator로 조합, `joinedKey = ${columnName}_${colName}` + +### 타입 정의 + +- [ ] `RepeaterColumnConfig`에 `additionalJoinInfo?: { sourceTable, sourceColumn, referenceTable, joinAlias }` 추가 + +--- + +## 8. 참고 파일 + +| 파일 | 용도 | +|------|------| +| `frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx` | Entity 조인 UI, addEntityColumn | +| `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | 데이터 로딩, formatCellValue | +| `frontend/lib/registry/components/v2-table-list/types.ts` | additionalJoinInfo 타입 | +| `frontend/lib/api/entityJoin.ts` | getTableDataWithJoins, getEntityJoinColumns | +| `backend-node/src/controllers/entityJoinController.ts` | entity-join-columns, data-with-joins | +| `backend-node/src/services/tableManagementService.ts` | additionalJoinColumns 병합 로직 | 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({
- + 트리거: setDeleteNumberingRules(e.target.checked)} + className="h-4 w-4 rounded border-destructive text-destructive focus:ring-destructive" + /> + +
+
+ )} {/* 로딩 오버레이 */} {isDeleting && ( @@ -1551,7 +1597,7 @@ export function ScreenGroupTreeView({ { - e.preventDefault(); // 자동 닫힘 방지 + e.preventDefault(); confirmDeleteGroup(); }} disabled={isDeleting} diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 1f1853be..2c900442 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -224,7 +224,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const loadGroups = async () => { try { setLoadingGroups(true); - const response = await getScreenGroups(); + const response = await getScreenGroups({ excludePop: true }); if (response.success && response.data) { setGroups(response.data); } diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index f6f1fc6b..7bf7e6fa 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -89,6 +89,67 @@ export const V2Repeater: React.FC = ({ const onDataChangeRef = useRef(onDataChange); onDataChangeRef.current = onDataChange; + // Entity 조인 설정을 ref로 보관 (이벤트 핸들러 closure에서 항상 최신값 참조) + const entityJoinsRef = useRef(config.entityJoins); + useEffect(() => { + entityJoinsRef.current = config.entityJoins; + }, [config.entityJoins]); + + // Entity 조인 해석: FK 값을 기반으로 참조 테이블에서 표시 데이터를 가져와 행에 채움 + const resolveEntityJoins = useCallback(async (rows: any[]): Promise => { + const entityJoins = entityJoinsRef.current; + console.log("🔍 [V2Repeater] resolveEntityJoins 시작:", { + entityJoins, + rowCount: rows.length, + sampleRow: rows[0], + }); + + if (!entityJoins || entityJoins.length === 0) { + console.warn("⚠️ [V2Repeater] entityJoins 설정 없음 - 해석 스킵"); + return rows; + } + + const resolvedRows = rows.map((r) => ({ ...r })); + + for (const join of entityJoins) { + const fkValues = [...new Set(resolvedRows.map((r) => r[join.sourceColumn]).filter(Boolean))]; + console.log(`🔍 [V2Repeater] FK 값 추출: ${join.sourceColumn} → [${fkValues.join(", ")}]`); + if (fkValues.length === 0) continue; + + try { + const response = await apiClient.post(`/table-management/tables/${join.referenceTable}/data`, { + page: 1, + size: fkValues.length + 10, + dataFilter: { + enabled: true, + filters: [{ columnName: "id", operator: "in", value: fkValues }], + }, + autoFilter: true, + }); + + console.log(`🔍 [V2Repeater] API 응답:`, response.data); + const refData = response.data?.data?.data || response.data?.data?.rows || []; + const lookupMap = new Map(refData.map((r: any) => [String(r.id), r])); + + resolvedRows.forEach((row) => { + const fkVal = String(row[join.sourceColumn] || ""); + const refRecord = lookupMap.get(fkVal); + if (refRecord) { + join.columns.forEach((col) => { + row[col.displayField] = refRecord[col.referenceField]; + }); + } + }); + + console.log(`✅ [V2Repeater] Entity 조인 해석 완료: ${join.referenceTable} (${fkValues.length}건, 조회결과: ${refData.length}건)`); + } catch (error) { + console.error(`❌ [V2Repeater] Entity 조인 해석 실패: ${join.referenceTable}`, error); + } + } + + return resolvedRows; + }, []); + const handleReceiveData = useCallback( async (incomingData: any[], configOrMode?: any) => { console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode }); @@ -98,6 +159,9 @@ export const V2Repeater: React.FC = ({ return; } + // mappingRules 처리: configOrMode에 mappingRules가 있으면 적용 + const mappingRules = configOrMode?.mappingRules; + // 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거 const metaFieldsToStrip = new Set([ "id", @@ -107,12 +171,33 @@ export const V2Repeater: React.FC = ({ "updated_by", "company_code", ]); - const normalizedData = incomingData.map((item: any) => { + let normalizedData = incomingData.map((item: any, index: number) => { let raw = item; if (item && typeof item === "object" && item[0] && typeof item[0] === "object") { const { 0: originalData, ...additionalFields } = item; raw = { ...originalData, ...additionalFields }; } + + // mappingRules가 있으면 규칙에 따라 매핑 (필요한 필드만 추출) + if (mappingRules && mappingRules.length > 0) { + const mapped: Record = { _id: `receive_${Date.now()}_${index}` }; + for (const rule of mappingRules) { + mapped[rule.targetField] = raw[rule.sourceField]; + } + // additionalSources에서 추가된 필드도 유지 (mappingRules에 없는 필드 중 메타가 아닌 것) + for (const [key, value] of Object.entries(raw)) { + if (!metaFieldsToStrip.has(key) && !(key in mapped) && !key.startsWith("_")) { + // 소스 테이블의 컬럼이 아닌 추가 데이터만 유지 (additionalSources 등) + const isMappingSource = mappingRules.some((r: any) => r.sourceField === key); + if (!isMappingSource) { + mapped[key] = value; + } + } + } + return mapped; + } + + // mappingRules 없으면 기존 로직: 메타 필드만 제거 const cleaned: Record = {}; for (const [key, value] of Object.entries(raw)) { if (!metaFieldsToStrip.has(key)) { @@ -122,10 +207,16 @@ export const V2Repeater: React.FC = ({ return cleaned; }); + console.log("📥 [V2Repeater] 매핑 후 데이터:", normalizedData); + + // Entity 조인 해석 (FK → 참조 테이블 데이터) + normalizedData = await resolveEntityJoins(normalizedData); + + console.log("📥 [V2Repeater] Entity 조인 후 데이터:", normalizedData); + const mode = configOrMode?.mode || configOrMode || "append"; // 카테고리 코드 → 라벨 변환 - // allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환 const codesToResolve = new Set(); for (const item of normalizedData) { for (const [key, val] of Object.entries(item)) { @@ -167,7 +258,7 @@ export const V2Repeater: React.FC = ({ toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`); }, - [], + [resolveEntityJoins], ); useEffect(() => { @@ -1412,32 +1503,31 @@ export const V2Repeater: React.FC = ({ } // 데이터 매핑 처리 - const mappedData = transferData.map((item: any, index: number) => { + let mappedData = transferData.map((item: any, index: number) => { const newRow: any = { _id: `transfer_${Date.now()}_${index}` }; if (mappingRules && mappingRules.length > 0) { - // 매핑 규칙이 있으면 적용 mappingRules.forEach((rule: any) => { newRow[rule.targetField] = item[rule.sourceField]; }); } else { - // 매핑 규칙 없으면 그대로 복사 Object.assign(newRow, item); } return newRow; }); + // Entity 조인 해석 (FK → 참조 테이블 데이터) + mappedData = await resolveEntityJoins(mappedData); + // mode에 따라 데이터 처리 if (mode === "replace") { handleDataChange(mappedData); } else if (mode === "merge") { - // 중복 제거 후 병합 (id 기준) const existingIds = new Set(data.map((row) => row.id || row._id)); const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id)); handleDataChange([...data, ...newItems]); } else { - // 기본: append handleDataChange([...data, ...mappedData]); } }; @@ -1447,12 +1537,21 @@ export const V2Repeater: React.FC = ({ const customEvent = event as CustomEvent; const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {}; + console.log("📨 [V2Repeater] splitPanelDataTransfer 수신:", { + dataCount: transferData?.length, + mappingRules, + mode, + sourcePosition, + sampleSourceData: transferData?.[0], + entityJoinsConfig: entityJoinsRef.current, + }); + if (!transferData || transferData.length === 0) { return; } // 데이터 매핑 처리 - const mappedData = transferData.map((item: any, index: number) => { + let mappedData = transferData.map((item: any, index: number) => { const newRow: any = { _id: `transfer_${Date.now()}_${index}` }; if (mappingRules && mappingRules.length > 0) { @@ -1466,6 +1565,13 @@ export const V2Repeater: React.FC = ({ return newRow; }); + console.log("📨 [V2Repeater] 매핑 후 데이터:", mappedData); + + // Entity 조인 해석 (FK → 참조 테이블 데이터) + mappedData = await resolveEntityJoins(mappedData); + + console.log("📨 [V2Repeater] Entity 조인 후 데이터:", mappedData); + // mode에 따라 데이터 처리 if (mode === "replace") { handleDataChange(mappedData); diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 66f0f18b..877b1523 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -48,12 +48,14 @@ import { } from "@/components/ui/popover"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; +import { entityJoinApi } from "@/lib/api/entityJoin"; import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; import { NumberingRuleConfig } from "@/types/numbering-rule"; import { cn } from "@/lib/utils"; import { V2RepeaterConfig, RepeaterColumnConfig, + RepeaterEntityJoin, DEFAULT_REPEATER_CONFIG, RENDER_MODE_OPTIONS, MODAL_SIZE_OPTIONS, @@ -151,6 +153,28 @@ export const V2RepeaterConfigPanel: React.FC = ({ const [loadingRelations, setLoadingRelations] = useState(false); const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태 + // Entity 조인 관련 상태 + const [entityJoinData, setEntityJoinData] = useState<{ + joinTables: Array<{ + tableName: string; + currentDisplayColumn: string; + joinConfig?: { sourceColumn?: string }; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + inputType?: string; + }>; + }>; + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + joinAlias: string; + }>; + }>({ joinTables: [], availableColumns: [] }); + const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); + // 🆕 확장된 컬럼 (상세 설정 표시용) const [expandedColumn, setExpandedColumn] = useState(null); @@ -316,6 +340,89 @@ export const V2RepeaterConfigPanel: React.FC = ({ loadRelatedTables(); }, [currentTableName, config.mainTableName]); + // Entity 조인 컬럼 정보 로드 (저장 테이블 기준) + const entityJoinTargetTable = config.useCustomTable && config.mainTableName + ? config.mainTableName + : currentTableName; + + useEffect(() => { + const fetchEntityJoinColumns = async () => { + if (!entityJoinTargetTable) return; + setLoadingEntityJoins(true); + try { + const result = await entityJoinApi.getEntityJoinColumns(entityJoinTargetTable); + setEntityJoinData({ + joinTables: result.joinTables || [], + availableColumns: result.availableColumns || [], + }); + } catch (error) { + console.error("Entity 조인 컬럼 조회 오류:", error); + setEntityJoinData({ joinTables: [], availableColumns: [] }); + } finally { + setLoadingEntityJoins(false); + } + }; + fetchEntityJoinColumns(); + }, [entityJoinTargetTable]); + + // Entity 조인 컬럼 토글 (추가/제거) + const toggleEntityJoinColumn = useCallback( + (joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => { + const currentJoins = config.entityJoins || []; + const existingJoinIdx = currentJoins.findIndex( + (j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName, + ); + + if (existingJoinIdx >= 0) { + const existingJoin = currentJoins[existingJoinIdx]; + const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName); + + if (existingColIdx >= 0) { + const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx); + if (updatedColumns.length === 0) { + updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) }); + } else { + const updated = [...currentJoins]; + updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns }; + updateConfig({ entityJoins: updated }); + } + } else { + const updated = [...currentJoins]; + updated[existingJoinIdx] = { + ...existingJoin, + columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }], + }; + updateConfig({ entityJoins: updated }); + } + } else { + updateConfig({ + entityJoins: [ + ...currentJoins, + { + sourceColumn, + referenceTable: joinTableName, + columns: [{ referenceField: refColumnName, displayField }], + }, + ], + }); + } + }, + [config.entityJoins, updateConfig], + ); + + // Entity 조인에 특정 컬럼이 설정되어 있는지 확인 + const isEntityJoinColumnActive = useCallback( + (joinTableName: string, sourceColumn: string, refColumnName: string) => { + return (config.entityJoins || []).some( + (j) => + j.sourceColumn === sourceColumn && + j.referenceTable === joinTableName && + j.columns.some((c) => c.referenceField === refColumnName), + ); + }, + [config.entityJoins], + ); + // 설정 업데이트 헬퍼 const updateConfig = useCallback( (updates: Partial) => { @@ -654,9 +761,10 @@ export const V2RepeaterConfigPanel: React.FC = ({ return (
- + 기본 컬럼 + Entity 조인 {/* 기본 설정 탭 */} @@ -1704,6 +1812,120 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} + {/* Entity 조인 설정 탭 */} + +
+
+

Entity 조인 연결

+

+ FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다 +

+
+
+ + {loadingEntityJoins ? ( +

로딩 중...

+ ) : entityJoinData.joinTables.length === 0 ? ( +
+

+ {entityJoinTargetTable + ? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다` + : "저장 테이블을 먼저 설정해주세요"} +

+
+ ) : ( +
+ {entityJoinData.joinTables.map((joinTable, tableIndex) => { + const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || ""; + + return ( +
+
+ + {joinTable.tableName} + ({sourceColumn}) +
+
+ {joinTable.availableColumns.map((column, colIndex) => { + const isActive = isEntityJoinColumnActive( + joinTable.tableName, + sourceColumn, + column.columnName, + ); + const matchingCol = config.columns.find((c) => c.key === column.columnName); + const displayField = matchingCol?.key || column.columnName; + + return ( +
+ toggleEntityJoinColumn( + joinTable.tableName, + sourceColumn, + column.columnName, + column.columnLabel, + displayField, + ) + } + > + + + {column.columnLabel} + + {column.inputType || column.dataType} + +
+ ); + })} +
+
+ ); + })} +
+ )} + + {/* 현재 설정된 Entity 조인 목록 */} + {config.entityJoins && config.entityJoins.length > 0 && ( +
+

설정된 조인

+
+ {config.entityJoins.map((join, idx) => ( +
+ + {join.sourceColumn} + + {join.referenceTable} + + ({join.columns.map((c) => c.referenceField).join(", ")}) + + +
+ ))} +
+
+ )} +
+
+
); diff --git a/frontend/contexts/BarcodeDesignerContext.tsx b/frontend/contexts/BarcodeDesignerContext.tsx new file mode 100644 index 00000000..cd5b668a --- /dev/null +++ b/frontend/contexts/BarcodeDesignerContext.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useCallback, + ReactNode, + useEffect, + useRef, +} from "react"; +import { + BarcodeLabelComponent, + BarcodeLabelLayout, + BarcodeLabelMaster, +} from "@/types/barcode"; +import { barcodeApi } from "@/lib/api/barcodeApi"; +import { useToast } from "@/hooks/use-toast"; +import { v4 as uuidv4 } from "uuid"; + +interface BarcodeDesignerContextType { + labelId: string; + labelMaster: BarcodeLabelMaster | null; + widthMm: number; + heightMm: number; + components: BarcodeLabelComponent[]; + selectedComponentId: string | null; + isLoading: boolean; + isSaving: boolean; + + setWidthMm: (v: number) => void; + setHeightMm: (v: number) => void; + addComponent: (component: BarcodeLabelComponent) => void; + updateComponent: (id: string, updates: Partial) => void; + removeComponent: (id: string) => void; + selectComponent: (id: string | null) => void; + reorderComponent: (id: string, direction: "up" | "down") => void; + + loadLabel: () => Promise; + loadLayout: () => Promise; + saveLayout: () => Promise; + applyTemplate: (templateId: string) => Promise; + + gridSize: number; + showGrid: boolean; + setShowGrid: (v: boolean) => void; + snapValueToGrid: (v: number) => number; +} + +const BarcodeDesignerContext = createContext(undefined); + +const MM_TO_PX = 4; +const DEFAULT_WIDTH_MM = 50; +const DEFAULT_HEIGHT_MM = 30; + +export function BarcodeDesignerProvider({ + labelId, + children, +}: { + labelId: string; + children: ReactNode; +}) { + const [labelMaster, setLabelMaster] = useState(null); + const [widthMm, setWidthMm] = useState(DEFAULT_WIDTH_MM); + const [heightMm, setHeightMm] = useState(DEFAULT_HEIGHT_MM); + const [components, setComponents] = useState([]); + const [selectedComponentId, setSelectedComponentId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showGrid, setShowGrid] = useState(true); + const [gridSize] = useState(2); // mm + const { toast } = useToast(); + const selectedComponentIdRef = useRef(null); + selectedComponentIdRef.current = selectedComponentId; + + const snapValueToGrid = useCallback( + (v: number) => Math.round(v / (gridSize * MM_TO_PX)) * (gridSize * MM_TO_PX), + [gridSize] + ); + + const loadLabel = useCallback(async () => { + if (labelId === "new") { + setLabelMaster(null); + setWidthMm(DEFAULT_WIDTH_MM); + setHeightMm(DEFAULT_HEIGHT_MM); + setComponents([]); + setIsLoading(false); + return; + } + try { + const res = await barcodeApi.getLabelById(labelId); + if (res.success && res.data) { + setLabelMaster(res.data); + if (res.data.width_mm != null) setWidthMm(res.data.width_mm); + if (res.data.height_mm != null) setHeightMm(res.data.height_mm); + } + } catch (e: any) { + toast({ + title: "오류", + description: e.message || "라벨 정보를 불러오지 못했습니다.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }, [labelId, toast]); + + const loadLayout = useCallback(async () => { + if (labelId === "new") return; + try { + const res = await barcodeApi.getLayout(labelId); + if (res.success && res.data) { + setWidthMm(res.data.width_mm); + setHeightMm(res.data.height_mm); + setComponents(res.data.components || []); + } + } catch { + // 레이아웃 없으면 빈 상태 유지 + } + }, [labelId]); + + // labelId 변경 시에만 초기 로드 (loadLabel/loadLayout을 deps에 넣지 않아 무한 루프 방지) + useEffect(() => { + let cancelled = false; + setIsLoading(true); + + const run = async () => { + if (labelId === "new") { + setLabelMaster(null); + setWidthMm(DEFAULT_WIDTH_MM); + setHeightMm(DEFAULT_HEIGHT_MM); + setComponents([]); + if (!cancelled) setIsLoading(false); + return; + } + try { + const res = await barcodeApi.getLabelById(labelId); + if (cancelled) return; + if (res.success && res.data) { + setLabelMaster(res.data); + if (res.data.width_mm != null) setWidthMm(res.data.width_mm); + if (res.data.height_mm != null) setHeightMm(res.data.height_mm); + } + const layoutRes = await barcodeApi.getLayout(labelId); + if (cancelled) return; + if (layoutRes.success && layoutRes.data) { + setWidthMm(layoutRes.data.width_mm); + setHeightMm(layoutRes.data.height_mm); + setComponents(layoutRes.data.components || []); + } + } catch (e: any) { + if (!cancelled) { + toast({ + title: "오류", + description: e.message || "라벨을 불러오지 못했습니다.", + variant: "destructive", + }); + } + } finally { + if (!cancelled) setIsLoading(false); + } + }; + + run(); + return () => { + cancelled = true; + }; + }, [labelId]); // eslint-disable-line react-hooks/exhaustive-deps + + const addComponent = useCallback((component: BarcodeLabelComponent) => { + setComponents((prev) => [...prev, { ...component, id: component.id || `comp_${uuidv4()}` }]); + setSelectedComponentId(component.id || null); + }, []); + + const updateComponent = useCallback((id: string, updates: Partial) => { + setComponents((prev) => + prev.map((c) => (c.id === id ? { ...c, ...updates } : c)) + ); + }, []); + + const removeComponent = useCallback((id: string) => { + setComponents((prev) => prev.filter((c) => c.id !== id)); + setSelectedComponentId((sid) => (sid === id ? null : sid)); + }, []); + + // Delete / Backspace 키로 선택된 요소 삭제 (입력 필드에서는 무시) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Delete" && e.key !== "Backspace") return; + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + const sid = selectedComponentIdRef.current; + if (sid) { + e.preventDefault(); + removeComponent(sid); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [removeComponent]); + + const reorderComponent = useCallback((id: string, direction: "up" | "down") => { + setComponents((prev) => { + const idx = prev.findIndex((c) => c.id === id); + if (idx < 0) return prev; + const next = [...prev]; + const swap = direction === "up" ? idx - 1 : idx + 1; + if (swap < 0 || swap >= next.length) return prev; + [next[idx], next[swap]] = [next[swap], next[idx]]; + return next.map((c, i) => ({ ...c, zIndex: i })); + }); + }, []); + + const saveLayout = useCallback(async () => { + if (labelId === "new") { + toast({ + title: "저장 불가", + description: "먼저 라벨을 저장한 뒤 레이아웃을 저장할 수 있습니다.", + variant: "destructive", + }); + return; + } + setIsSaving(true); + try { + const layout: BarcodeLabelLayout = { + width_mm: widthMm, + height_mm: heightMm, + components: components.map((c, i) => ({ ...c, zIndex: i })), + }; + await barcodeApi.saveLayout(labelId, layout); + toast({ title: "저장됨", description: "레이아웃이 저장되었습니다." }); + } catch (e: any) { + toast({ + title: "저장 실패", + description: e.message || "레이아웃 저장에 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }, [labelId, widthMm, heightMm, components, toast]); + + const applyTemplate = useCallback( + async (templateId: string) => { + try { + const res = await barcodeApi.getTemplateById(templateId); + const layout = res.success && res.data ? (res.data as { layout?: BarcodeLabelLayout }).layout : null; + if (layout && typeof layout.width_mm === "number" && typeof layout.height_mm === "number") { + setWidthMm(layout.width_mm); + setHeightMm(layout.height_mm); + setComponents( + (layout.components || []).map((c) => ({ + ...c, + id: c.id || `comp_${uuidv4()}`, + })) + ); + setSelectedComponentId(null); + const name = (res.data as { template_name_kor?: string }).template_name_kor || "템플릿"; + toast({ + title: "템플릿 적용", + description: `${name} 적용됨 (${layout.width_mm}×${layout.height_mm}mm)`, + }); + } else { + toast({ + title: "템플릿 적용 실패", + description: "레이아웃 데이터가 없습니다.", + variant: "destructive", + }); + } + } catch (e: any) { + toast({ + title: "템플릿 적용 실패", + description: e.message || "템플릿을 불러오지 못했습니다.", + variant: "destructive", + }); + } + }, + [toast] + ); + + const value: BarcodeDesignerContextType = { + labelId, + labelMaster, + widthMm, + heightMm, + components, + selectedComponentId, + isLoading, + isSaving, + setWidthMm, + setHeightMm, + addComponent, + updateComponent, + removeComponent, + selectComponent: setSelectedComponentId, + reorderComponent, + loadLabel, + loadLayout, + saveLayout, + applyTemplate, + gridSize, + showGrid, + setShowGrid, + snapValueToGrid, + }; + + return ( + + {children} + {isLoading && ( +
+
+
+ 라벨 불러오는 중... +
+
+ )} + + ); +} + +export function useBarcodeDesigner() { + const ctx = useContext(BarcodeDesignerContext); + if (ctx === undefined) { + throw new Error("useBarcodeDesigner must be used within BarcodeDesignerProvider"); + } + return ctx; +} + +export { MM_TO_PX }; diff --git a/frontend/hooks/pop/useCartSync.ts b/frontend/hooks/pop/useCartSync.ts index e3b76ed5..8060f67d 100644 --- a/frontend/hooks/pop/useCartSync.ts +++ b/frontend/hooks/pop/useCartSync.ts @@ -99,10 +99,8 @@ function dbRowToCartItem(dbRow: Record): CartItemWithId { function cartItemToDbRecord( item: CartItemWithId, screenId: string, - cartType: string = "pop", selectedColumns?: string[], ): Record { - // selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장 const rowData = selectedColumns && selectedColumns.length > 0 ? Object.fromEntries( @@ -111,7 +109,7 @@ function cartItemToDbRecord( : item.row; return { - cart_type: cartType, + cart_type: "pop", screen_id: screenId, source_table: item.sourceTable, row_key: item.rowKey, @@ -144,7 +142,6 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean { export function useCartSync( screenId: string, sourceTable: string, - cartType?: string, ): UseCartSyncReturn { const [cartItems, setCartItems] = useState([]); const [savedItems, setSavedItems] = useState([]); @@ -153,21 +150,19 @@ export function useCartSync( const screenIdRef = useRef(screenId); const sourceTableRef = useRef(sourceTable); - const cartTypeRef = useRef(cartType || "pop"); screenIdRef.current = screenId; sourceTableRef.current = sourceTable; - cartTypeRef.current = cartType || "pop"; // ----- DB에서 장바구니 로드 ----- const loadFromDb = useCallback(async () => { - if (!screenId) return; + if (!screenId || !sourceTable) return; setLoading(true); try { const result = await dataApi.getTableData("cart_items", { size: 500, filters: { screen_id: screenId, - cart_type: cartTypeRef.current, + cart_type: "pop", status: "in_cart", }, }); @@ -181,7 +176,7 @@ export function useCartSync( } finally { setLoading(false); } - }, [screenId]); + }, [screenId, sourceTable]); // 마운트 시 자동 로드 useEffect(() => { @@ -286,18 +281,16 @@ export function useCartSync( const promises: Promise[] = []; for (const item of toDelete) { - promises.push(dataApi.deleteRecord("cart_items", item.cartId!)); + promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" })); } - const currentCartType = cartTypeRef.current; - for (const item of toCreate) { - const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns); + const record = cartItemToDbRecord(item, currentScreenId, selectedColumns); promises.push(dataApi.createRecord("cart_items", record)); } for (const item of toUpdate) { - const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns); + const record = cartItemToDbRecord(item, currentScreenId, selectedColumns); promises.push(dataApi.updateRecord("cart_items", item.cartId!, record)); } diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts index 3c20acc2..14bd321a 100644 --- a/frontend/hooks/pop/useConnectionResolver.ts +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -8,47 +8,119 @@ * 이벤트 규칙: * 소스: __comp_output__${sourceComponentId}__${outputKey} * 타겟: __comp_input__${targetComponentId}__${inputKey} + * + * _auto 모드: + * sourceOutput="_auto"인 연결은 소스/타겟의 connectionMeta를 비교하여 + * key가 같고 category="event"인 쌍을 양방향으로 자동 라우팅한다. + * (정방향: 소스->타겟, 역방향: 타겟->소스) */ import { useEffect, useRef } from "react"; import { usePopEvent } from "./usePopEvent"; import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; +import { + PopComponentRegistry, + type ConnectionMetaItem, +} from "@/lib/registry/PopComponentRegistry"; interface UseConnectionResolverOptions { screenId: string; connections: PopDataConnection[]; + componentTypes?: Map; +} + +/** + * 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다. + * 규칙: category="event"이고 key가 동일한 쌍 + */ +function getAutoMatchPairs( + sourceType: string, + targetType: string +): { sourceKey: string; targetKey: string }[] { + const sourceDef = PopComponentRegistry.getComponent(sourceType); + const targetDef = PopComponentRegistry.getComponent(targetType); + + if (!sourceDef?.connectionMeta?.sendable || !targetDef?.connectionMeta?.receivable) { + return []; + } + + const pairs: { sourceKey: string; targetKey: string }[] = []; + + for (const s of sourceDef.connectionMeta.sendable) { + if (s.category !== "event") continue; + for (const r of targetDef.connectionMeta.receivable) { + if (r.category !== "event") continue; + if (s.key === r.key) { + pairs.push({ sourceKey: s.key, targetKey: r.key }); + } + } + } + + return pairs; } export function useConnectionResolver({ screenId, connections, + componentTypes, }: UseConnectionResolverOptions): void { const { publish, subscribe } = usePopEvent(screenId); - // 연결 목록을 ref로 저장하여 콜백 안정성 확보 const connectionsRef = useRef(connections); connectionsRef.current = connections; + const componentTypesRef = useRef(componentTypes); + componentTypesRef.current = componentTypes; + useEffect(() => { if (!connections || connections.length === 0) return; const unsubscribers: (() => void)[] = []; - // 소스별로 그룹핑하여 구독 생성 - const sourceGroups = new Map(); for (const conn of connections) { - const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`; - const existing = sourceGroups.get(sourceEvent) || []; - existing.push(conn); - sourceGroups.set(sourceEvent, existing); - } + const isAutoMode = conn.sourceOutput === "_auto" || !conn.sourceOutput; - for (const [sourceEvent, conns] of sourceGroups) { - const unsub = subscribe(sourceEvent, (payload: unknown) => { - for (const conn of conns) { + if (isAutoMode && componentTypesRef.current) { + const sourceType = componentTypesRef.current.get(conn.sourceComponent); + const targetType = componentTypesRef.current.get(conn.targetComponent); + + if (!sourceType || !targetType) continue; + + // 정방향: 소스 sendable -> 타겟 receivable + const forwardPairs = getAutoMatchPairs(sourceType, targetType); + for (const pair of forwardPairs) { + const sourceEvent = `__comp_output__${conn.sourceComponent}__${pair.sourceKey}`; + const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`; + + const unsub = subscribe(sourceEvent, (payload: unknown) => { + publish(targetEvent, { + value: payload, + _connectionId: conn.id, + }); + }); + unsubscribers.push(unsub); + } + + // 역방향: 타겟 sendable -> 소스 receivable + const reversePairs = getAutoMatchPairs(targetType, sourceType); + for (const pair of reversePairs) { + const sourceEvent = `__comp_output__${conn.targetComponent}__${pair.sourceKey}`; + const targetEvent = `__comp_input__${conn.sourceComponent}__${pair.targetKey}`; + + const unsub = subscribe(sourceEvent, (payload: unknown) => { + publish(targetEvent, { + value: payload, + _connectionId: conn.id, + }); + }); + unsubscribers.push(unsub); + } + } else { + const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`; + + const unsub = subscribe(sourceEvent, (payload: unknown) => { const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; - // 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId } const enrichedPayload = { value: payload, filterConfig: conn.filterConfig, @@ -56,9 +128,9 @@ export function useConnectionResolver({ }; publish(targetEvent, enrichedPayload); - } - }); - unsubscribers.push(unsub); + }); + unsubscribers.push(unsub); + } } return () => { diff --git a/frontend/hooks/useBarcodeList.ts b/frontend/hooks/useBarcodeList.ts new file mode 100644 index 00000000..e20fe811 --- /dev/null +++ b/frontend/hooks/useBarcodeList.ts @@ -0,0 +1,65 @@ +import { useState, useEffect } from "react"; +import { BarcodeLabelMaster, GetBarcodeLabelsParams } from "@/types/barcode"; +import { barcodeApi } from "@/lib/api/barcodeApi"; +import { useToast } from "@/hooks/use-toast"; + +export function useBarcodeList() { + const [labels, setLabels] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [limit] = useState(20); + const [isLoading, setIsLoading] = useState(false); + const [searchText, setSearchText] = useState(""); + const { toast } = useToast(); + + const fetchLabels = async () => { + setIsLoading(true); + try { + const params: GetBarcodeLabelsParams = { + page, + limit, + searchText, + useYn: "Y", + sortBy: "created_at", + sortOrder: "DESC", + }; + + const response = await barcodeApi.getLabels(params); + + if (response.success && response.data) { + setLabels(response.data.items); + setTotal(response.data.total); + } + } catch (error: any) { + console.error("바코드 라벨 목록 조회 에러:", error); + toast({ + title: "오류", + description: error.message || "바코드 라벨 목록을 불러오는데 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchLabels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, searchText]); + + const handleSearch = (text: string) => { + setSearchText(text); + setPage(1); + }; + + return { + labels, + total, + page, + limit, + isLoading, + refetch: fetchLabels, + setPage, + handleSearch, + }; +} diff --git a/frontend/lib/api/barcodeApi.ts b/frontend/lib/api/barcodeApi.ts new file mode 100644 index 00000000..d056331e --- /dev/null +++ b/frontend/lib/api/barcodeApi.ts @@ -0,0 +1,115 @@ +import { apiClient } from "./client"; +import { + BarcodeLabelMaster, + BarcodeLabelLayout, + GetBarcodeLabelsParams, + GetBarcodeLabelsResponse, + CreateBarcodeLabelRequest, + UpdateBarcodeLabelRequest, +} from "@/types/barcode"; + +const BASE_URL = "/admin/barcode-labels"; + +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 const barcodeApi = { + /** 바코드 라벨 목록 조회 */ + getLabels: async (params: GetBarcodeLabelsParams) => { + const response = await apiClient.get<{ + success: boolean; + data: GetBarcodeLabelsResponse; + }>(BASE_URL, { params }); + return response.data; + }, + + /** 바코드 라벨 상세 조회 */ + getLabelById: async (labelId: string) => { + const response = await apiClient.get<{ + success: boolean; + data: BarcodeLabelMaster; + }>(`${BASE_URL}/${labelId}`); + return response.data; + }, + + /** 라벨 레이아웃 조회 */ + getLayout: async (labelId: string) => { + const response = await apiClient.get<{ + success: boolean; + data: BarcodeLabelLayout; + }>(`${BASE_URL}/${labelId}/layout`); + return response.data; + }, + + /** 라벨 레이아웃 저장 */ + saveLayout: async (labelId: string, layout: BarcodeLabelLayout) => { + const response = await apiClient.put<{ + success: boolean; + message: string; + }>(`${BASE_URL}/${labelId}/layout`, layout); + return response.data; + }, + + /** 기본 템플릿 목록 */ + getTemplates: async () => { + const response = await apiClient.get<{ + success: boolean; + data: BarcodeLabelTemplate[]; + }>(`${BASE_URL}/templates`); + return response.data; + }, + + /** 템플릿 상세 (레이아웃 적용용) */ + getTemplateById: async (templateId: string) => { + const response = await apiClient.get<{ + success: boolean; + data: BarcodeLabelTemplate & { layout: BarcodeLabelLayout }; + }>(`${BASE_URL}/templates/${templateId}`); + return response.data; + }, + + /** 바코드 라벨 생성 (templateId 선택 시 해당 레이아웃 적용) */ + createLabel: async (data: CreateBarcodeLabelRequest & { templateId?: string }) => { + const response = await apiClient.post<{ + success: boolean; + data: { labelId: string }; + message: string; + }>(BASE_URL, data); + return response.data; + }, + + /** 바코드 라벨 수정 */ + updateLabel: async (labelId: string, data: UpdateBarcodeLabelRequest) => { + const response = await apiClient.put<{ + success: boolean; + message: string; + }>(`${BASE_URL}/${labelId}`, data); + return response.data; + }, + + /** 바코드 라벨 삭제 */ + deleteLabel: async (labelId: string) => { + const response = await apiClient.delete<{ + success: boolean; + message: string; + }>(`${BASE_URL}/${labelId}`); + return response.data; + }, + + /** 바코드 라벨 복사 */ + copyLabel: async (labelId: string) => { + const response = await apiClient.post<{ + success: boolean; + data: { labelId: string }; + message: string; + }>(`${BASE_URL}/${labelId}/copy`); + return response.data; + }, +}; diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index cc61f7ff..178e14cc 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -16,6 +16,7 @@ export const screenApi = { size?: number; companyCode?: string; searchTerm?: string; + excludePop?: boolean; }): Promise> => { const response = await apiClient.get("/screen-management/screens", { params }); const raw = response.data || {}; @@ -347,6 +348,59 @@ export const screenApi = { return response.data.data; }, + // POP 화면 연결 분석 (다른 화면과의 참조 관계) + analyzePopScreenLinks: async ( + screenId: number, + ): Promise<{ + linkedScreenIds: number[]; + references: Array<{ + componentId: string; + referenceType: string; + targetScreenId: number; + }>; + }> => { + const response = await apiClient.get( + `/screen-management/screens/${screenId}/pop-links`, + ); + return response.data.data || { linkedScreenIds: [], references: [] }; + }, + + // POP 화면 배포 (다른 회사로 복사) + deployPopScreens: async (data: { + screens: Array<{ + sourceScreenId: number; + screenName: string; + screenCode: string; + }>; + targetCompanyCode: string; + groupStructure?: { + sourceGroupId: number; + groupName: string; + groupCode: string; + screenIds: number[]; + children?: Array<{ + sourceGroupId: number; + groupName: string; + groupCode: string; + screenIds: number[]; + }>; + }; + }): Promise<{ + deployedScreens: Array<{ + sourceScreenId: number; + newScreenId: number; + screenName: string; + screenCode: string; + }>; + createdGroups?: number; + }> => { + const response = await apiClient.post( + `/screen-management/deploy-pop-screens`, + data, + ); + return response.data.data; + }, + // 메인 화면 + 모달 화면들 일괄 복사 copyScreenWithModals: async ( sourceScreenId: number, diff --git a/frontend/lib/api/screenGroup.ts b/frontend/lib/api/screenGroup.ts index f3883240..59402fd2 100644 --- a/frontend/lib/api/screenGroup.ts +++ b/frontend/lib/api/screenGroup.ts @@ -115,12 +115,14 @@ export async function getScreenGroups(params?: { page?: number; size?: number; searchTerm?: string; + excludePop?: boolean; }): Promise> { try { const queryParams = new URLSearchParams(); if (params?.page) queryParams.append("page", params.page.toString()); if (params?.size) queryParams.append("size", params.size.toString()); if (params?.searchTerm) queryParams.append("searchTerm", params.searchTerm); + if (params?.excludePop) queryParams.append("excludePop", "true"); const response = await apiClient.get(`/screen-groups/groups?${queryParams.toString()}`); return response.data; @@ -156,9 +158,15 @@ export async function updateScreenGroup(id: number, data: Partial): } } -export async function deleteScreenGroup(id: number): Promise> { +export async function deleteScreenGroup(id: number, options?: { deleteNumberingRules?: boolean }): Promise> { try { - const response = await apiClient.delete(`/screen-groups/groups/${id}`); + const params = new URLSearchParams(); + if (options?.deleteNumberingRules) { + params.set("deleteNumberingRules", "true"); + } + const queryString = params.toString(); + const url = `/screen-groups/groups/${id}${queryString ? `?${queryString}` : ""}`; + const response = await apiClient.delete(url); return response.data; } catch (error: any) { return { success: false, error: error.message }; diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index 0d7df5ec..3793bc2d 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -9,6 +9,7 @@ export interface ConnectionMetaItem { key: string; label: string; type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string; + category?: "event" | "filter" | "data"; description?: string; } diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx index c101d286..f5efbba8 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx @@ -305,12 +305,26 @@ export function ItemSelectionModal({ onOpenChange(false); }; - // 이미 추가된 항목인지 확인 + // 이미 추가된 항목인지 확인 (매핑된 데이터의 _sourceData도 검사) const isAlreadyAdded = (item: any): boolean => { - if (!uniqueField) return false; - return alreadySelected.some( - (selected) => selected[uniqueField] === item[uniqueField] - ); + const checkField = uniqueField || "id"; + const itemValue = item[checkField]; + if (itemValue === undefined || itemValue === null) return false; + const strItemValue = String(itemValue); + + return alreadySelected.some((selected) => { + // _sourceData 우선 확인 (DB 로드 항목의 참조 ID가 매핑되어 있음) + const sourceValue = selected._sourceData?.[checkField]; + if (sourceValue !== undefined && sourceValue !== null && String(sourceValue) === strItemValue) { + return true; + } + // _sourceData에 없으면 직접 필드 비교 (동일 필드명인 경우) + const directValue = selected[checkField]; + if (directValue !== undefined && directValue !== null && String(directValue) === strItemValue) { + return true; + } + return false; + }); }; // 이미 추가된 항목 제외한 결과 필터링 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 6c631d83..f3ed2145 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -642,6 +642,12 @@ export const SplitPanelLayout2Component: React.FC { if (selectedLeftItem) { loadRightData(selectedLeftItem); } + loadLeftData(); }, }, }); @@ -661,9 +668,11 @@ export const SplitPanelLayout2Component: React.FC { if (selectedLeftItem) { loadRightData(selectedLeftItem); } + loadLeftData(); }, }, }); window.dispatchEvent(event); console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData); }, - [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData], + [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, config.leftPanel?.primaryKeyColumn, selectedLeftItem, loadRightData, loadLeftData], ); // 좌측 패널 수정 버튼 클릭 @@ -835,10 +851,11 @@ export const SplitPanelLayout2Component: React.FC d[pkColumn] === selectedId); if (item) { - // 액션 버튼에 모달 화면이 설정되어 있으면 해당 화면 사용 const modalScreenId = btn.modalScreenId || config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; if (!modalScreenId) { @@ -925,17 +948,25 @@ export const SplitPanelLayout2Component: React.FC { if (selectedLeftItem) { loadRightData(selectedLeftItem); } + loadLeftData(); }, }, }); @@ -966,6 +997,7 @@ export const SplitPanelLayout2Component: React.FC = ({ return; } - if (categoryColumns.length === 0) { - setCategoryMappings({}); - return; - } - try { const mappings: Record> = {}; const apiClient = (await import("@/lib/api/client")).apiClient; + // 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용) + const flattenTree = (items: any[], mapping: Record) => { + items.forEach((item: any) => { + if (item.valueCode) { + mapping[String(item.valueCode)] = { + label: item.valueLabel, + color: item.color, + }; + } + if (item.valueId !== undefined && item.valueId !== null) { + mapping[String(item.valueId)] = { + label: item.valueLabel, + color: item.color, + }; + } + if (item.children && Array.isArray(item.children) && item.children.length > 0) { + flattenTree(item.children, mapping); + } + }); + }; + for (const columnName of categoryColumns) { try { - // 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인 let targetTable = tableConfig.selectedTable; let targetColumn = columnName; if (columnName.includes(".")) { const parts = columnName.split("."); - targetTable = parts[0]; // 조인된 테이블명 (예: item_info) - targetColumn = parts[1]; // 실제 컬럼명 (예: material) + targetTable = parts[0]; + targetColumn = parts[1]; } const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - - response.data.data.forEach((item: any) => { - // valueCode를 문자열로 변환하여 키로 사용 - const key = String(item.valueCode); - mapping[key] = { - label: item.valueLabel, - color: item.color, - }; - }); + flattenTree(response.data.data, mapping); if (Object.keys(mapping).length > 0) { - // 🆕 원래 컬럼명(item_info.material)으로 매핑 저장 mappings[columnName] = mapping; - } else { - console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`); } - } else { - console.warn(`⚠️ [TableList] 카테고리 값 없음 [${columnName}]:`, { - success: response.data.success, - hasData: !!response.data.data, - isArray: Array.isArray(response.data.data), - response: response.data, - }); } } catch (error: any) { - console.error(`❌ [TableList] 카테고리 값 로드 실패 [${columnName}]:`, { - error: error.message, - stack: error.stack, - response: error.response?.data, - status: error.response?.status, - }); + console.error(`[TableList] 카테고리 값 로드 실패 [${columnName}]:`, error.message); } } @@ -1393,14 +1385,7 @@ export const TableListComponent: React.FC = ({ if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - - response.data.data.forEach((item: any) => { - const key = String(item.valueCode); - mapping[key] = { - label: item.valueLabel, - color: item.color, - }; - }); + flattenTree(response.data.data, mapping); if (Object.keys(mapping).length > 0) { mappings[col.columnName] = mapping; @@ -1449,8 +1434,8 @@ export const TableListComponent: React.FC = ({ // 연쇄관계 매핑이 없는 경우 무시 (404 등) } + setCategoryMappings(mappings); if (Object.keys(mappings).length > 0) { - setCategoryMappings(mappings); setCategoryMappingsKey((prev) => prev + 1); } } catch (error) { @@ -1464,7 +1449,7 @@ export const TableListComponent: React.FC = ({ categoryColumns.length, JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns), - ]); // 더 명확한 의존성 + ]); // ======================================== // 데이터 가져오기 diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 0b7aa47f..8f9cf859 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -2132,6 +2132,101 @@ export function TableSectionRenderer({ return Object.values(conditionalTableData).reduce((sum, data) => sum + data.length, 0); }, [conditionalTableData]); + // 조건부 테이블: 모달 중복 체크용 alreadySelected 구성 + // DB에서 로드된 항목은 _sourceData가 없으므로 참조 ID 필드를 기반으로 _sourceData를 생성 + const conditionalAlreadySelected = useMemo(() => { + const allItems = Object.values(conditionalTableData).flat(); + if (allItems.length === 0) return allItems; + + // 참조 ID 필드 탐색 (소스 테이블의 id를 저장하는 디테일 테이블 컬럼) + const referenceIdField = (tableConfig.columns || []) + .map((col) => col.saveConfig?.referenceDisplay?.referenceIdField) + .find(Boolean) + || (tableConfig.columns || []) + .map((col) => (col as any).dynamicSelectOptions?.rowSelectionMode?.targetIdField) + .find(Boolean); + + // sourceField 매핑 수집 (소스 테이블 필드 → 디테일 테이블 필드) + const sourceFieldMap: Record = {}; + for (const col of tableConfig.columns || []) { + if (col.sourceField && col.sourceField !== col.field) { + sourceFieldMap[col.sourceField] = col.field; + } + } + + return allItems.map((item) => { + if (item._sourceData) return item; + + // DB에서 로드된 항목: _sourceData 재구성 + const sourceData: any = {}; + + // 참조 ID 필드가 있으면 소스 테이블의 id로 매핑 + if (referenceIdField && item[referenceIdField] !== undefined) { + sourceData.id = item[referenceIdField]; + } + + // sourceField 매핑을 역으로 적용 (디테일 필드 → 소스 필드) + for (const [srcField, detailField] of Object.entries(sourceFieldMap)) { + if (item[detailField] !== undefined) { + sourceData[srcField] = item[detailField]; + } + } + + // 디테일 테이블의 필드도 소스 데이터에 포함 (동일 필드명인 경우) + for (const col of tableConfig.columns || []) { + if (!col.sourceField && item[col.field] !== undefined) { + sourceData[col.field] = item[col.field]; + } + } + + return Object.keys(sourceData).length > 0 + ? { ...item, _sourceData: sourceData } + : item; + }); + }, [conditionalTableData, tableConfig.columns]); + + // 일반 테이블: 모달 중복 체크용 alreadySelected 구성 (DB 로드 항목 대응) + const normalAlreadySelected = useMemo(() => { + if (tableData.length === 0) return tableData; + + const referenceIdField = (tableConfig.columns || []) + .map((col) => col.saveConfig?.referenceDisplay?.referenceIdField) + .find(Boolean) + || (tableConfig.columns || []) + .map((col) => (col as any).dynamicSelectOptions?.rowSelectionMode?.targetIdField) + .find(Boolean); + + const sourceFieldMap: Record = {}; + for (const col of tableConfig.columns || []) { + if (col.sourceField && col.sourceField !== col.field) { + sourceFieldMap[col.sourceField] = col.field; + } + } + + return tableData.map((item) => { + if (item._sourceData) return item; + + const sourceData: any = {}; + if (referenceIdField && item[referenceIdField] !== undefined) { + sourceData.id = item[referenceIdField]; + } + for (const [srcField, detailField] of Object.entries(sourceFieldMap)) { + if (item[detailField] !== undefined) { + sourceData[srcField] = item[detailField]; + } + } + for (const col of tableConfig.columns || []) { + if (!col.sourceField && item[col.field] !== undefined) { + sourceData[col.field] = item[col.field]; + } + } + + return Object.keys(sourceData).length > 0 + ? { ...item, _sourceData: sourceData } + : item; + }); + }, [tableData, tableConfig.columns]); + // ============================================ // 조건부 테이블 렌더링 // ============================================ @@ -2449,7 +2544,7 @@ export function TableSectionRenderer({ multiSelect={multiSelect} filterCondition={conditionalFilterCondition} modalTitle={`${effectiveOptions.find((o) => o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`} - alreadySelected={conditionalTableData[modalCondition] || []} + alreadySelected={conditionalAlreadySelected} uniqueField={tableConfig.saveConfig?.uniqueField} onSelect={handleConditionalAddItems} columnLabels={columnLabels} @@ -2560,7 +2655,7 @@ export function TableSectionRenderer({ multiSelect={multiSelect} filterCondition={baseFilterCondition} modalTitle={modalTitle} - alreadySelected={tableData} + alreadySelected={normalAlreadySelected} uniqueField={tableConfig.saveConfig?.uniqueField} onSelect={handleAddItems} columnLabels={columnLabels} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 7e91d8b9..94149e4f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -468,6 +468,11 @@ export function UniversalFormModalComponent({ console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`); } + // 분할패널에서 전달한 메인 레코드 ID 전달 + if (latestFormData._mainRecordId) { + event.detail.formData._mainRecordId = latestFormData._mainRecordId; + } + // 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트 // onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트 for (const parentKey of Object.keys(event.detail.formData)) { @@ -993,6 +998,11 @@ export function UniversalFormModalComponent({ } } + // 분할패널에서 전달한 메인 레코드 ID 보존 + if (effectiveInitialData?._mainRecordId) { + newFormData._mainRecordId = effectiveInitialData._mainRecordId; + } + setFormData(newFormData); formDataRef.current = newFormData; setRepeatSections(newRepeatSections); diff --git a/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx b/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx index a756cf6c..7eac3d83 100644 --- a/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx +++ b/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx @@ -44,9 +44,11 @@ const V2RepeaterRenderer: React.FC = ({ console.log("📋 V2RepeaterRenderer config 추출:", { hasComponentConfig: !!component?.componentConfig, hasConfig: !!component?.config, + hasOverrides: !!(component as any)?.overrides, useCustomTable: componentConfig.useCustomTable, mainTableName: componentConfig.mainTableName, foreignKeyColumn: componentConfig.foreignKeyColumn, + entityJoins: componentConfig.entityJoins, }); return { diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 7b3ace6d..eae50795 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1396,15 +1396,31 @@ export const TableListComponent: React.FC = ({ return; } - if (categoryColumns.length === 0) { - setCategoryMappings({}); - return; - } - try { const mappings: Record> = {}; const apiClient = (await import("@/lib/api/client")).apiClient; + // 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용) + const flattenTree = (items: any[], mapping: Record) => { + items.forEach((item: any) => { + if (item.valueCode) { + mapping[String(item.valueCode)] = { + label: item.valueLabel, + color: item.color, + }; + } + if (item.valueId !== undefined && item.valueId !== null) { + mapping[String(item.valueId)] = { + label: item.valueLabel, + color: item.color, + }; + } + if (item.children && Array.isArray(item.children) && item.children.length > 0) { + flattenTree(item.children, mapping); + } + }); + }; + for (const columnName of categoryColumns) { try { let targetTable = tableConfig.selectedTable; @@ -1429,39 +1445,10 @@ export const TableListComponent: React.FC = ({ if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - - // 트리 구조를 평탄화하는 헬퍼 함수 - const flattenTree = (items: any[]) => { - items.forEach((item: any) => { - // valueCode를 문자열로 변환하여 키로 사용 - if (item.valueCode) { - const key = String(item.valueCode); - mapping[key] = { - label: item.valueLabel, - color: item.color, - }; - } - // valueId도 키로 추가 (숫자 ID 저장 시 라벨 표시용) - if (item.valueId !== undefined && item.valueId !== null) { - mapping[String(item.valueId)] = { - label: item.valueLabel, - color: item.color, - }; - } - // 자식 노드도 재귀적으로 처리 - if (item.children && Array.isArray(item.children) && item.children.length > 0) { - flattenTree(item.children); - } - }); - }; - - flattenTree(response.data.data); + flattenTree(response.data.data, mapping); if (Object.keys(mapping).length > 0) { - // 🆕 원래 컬럼명(item_info.material)으로 매핑 저장 mappings[columnName] = mapping; - } else { - // 매핑 데이터가 비어있음 - 해당 컬럼에 카테고리 값이 없음 } } } catch { @@ -1541,24 +1528,7 @@ export const TableListComponent: React.FC = ({ if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - - response.data.data.forEach((item: any) => { - // valueCode로 매핑 - if (item.valueCode) { - const key = String(item.valueCode); - mapping[key] = { - label: item.valueLabel, - color: item.color, - }; - } - // valueId도 키로 추가 (숫자 ID 저장 시 라벨 표시용) - if (item.valueId !== undefined && item.valueId !== null) { - mapping[String(item.valueId)] = { - label: item.valueLabel, - color: item.color, - }; - } - }); + flattenTree(response.data.data, mapping); if (Object.keys(mapping).length > 0) { mappings[col.columnName] = mapping; @@ -1608,8 +1578,8 @@ export const TableListComponent: React.FC = ({ // 연쇄관계 매핑이 없는 경우 무시 } + setCategoryMappings(mappings); if (Object.keys(mappings).length > 0) { - setCategoryMappings(mappings); setCategoryMappingsKey((prev) => prev + 1); } } catch { diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index 36d1109c..85d07e83 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -21,6 +21,7 @@ import "./pop-button"; import "./pop-string-list"; import "./pop-search"; +import "./pop-field"; + // 향후 추가될 컴포넌트들: -// import "./pop-field"; // import "./pop-list"; diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index a9ea0ece..0dc5aa50 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -48,9 +48,20 @@ import { ChevronDown, ShoppingCart, ShoppingBag, + PackageCheck, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; +import type { CollectedDataResponse, StatusChangeRule } from "./types"; +import { apiClient } from "@/lib/api/client"; +import { TableCombobox } from "./pop-shared/TableCombobox"; +import { ColumnCombobox } from "./pop-shared/ColumnCombobox"; +import { + fetchTableList, + fetchTableColumns, + type TableInfo, + type ColumnInfo, +} from "./pop-dashboard/utils/dataFetcher"; // ======================================== // STEP 1: 타입 정의 @@ -118,6 +129,7 @@ export type ButtonPreset = | "menu" | "modal-open" | "cart" + | "inbound-confirm" | "custom"; /** row_data 저장 모드 */ @@ -141,6 +153,9 @@ export interface PopButtonConfig { action: ButtonMainAction; followUpActions?: FollowUpAction[]; cart?: CartButtonConfig; + statusChangeRules?: StatusChangeRule[]; + /** @deprecated inboundConfirm.statusChangeRules -> statusChangeRules로 이동 */ + inboundConfirm?: { statusChangeRules?: StatusChangeRule[] }; } // ======================================== @@ -180,6 +195,7 @@ const PRESET_LABELS: Record = { menu: "메뉴 (드롭다운)", "modal-open": "모달 열기", cart: "장바구니 저장", + "inbound-confirm": "입고 확정", custom: "직접 설정", }; @@ -270,6 +286,13 @@ const PRESET_DEFAULTS: Record> = { confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" }, action: { type: "event" }, }, + "inbound-confirm": { + label: "입고 확정", + variant: "default", + icon: "PackageCheck", + confirm: { enabled: true, message: "선택한 품목을 입고 확정하시겠습니까?" }, + action: { type: "event" }, + }, custom: { label: "버튼", variant: "default", @@ -341,6 +364,7 @@ const LUCIDE_ICON_MAP: Record = { Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown, ShoppingCart, ShoppingBag, + PackageCheck, }; /** Lucide 아이콘 동적 렌더링 */ @@ -389,10 +413,28 @@ export function PopButtonComponent({ // 장바구니 모드 상태 const isCartMode = config?.preset === "cart"; + const isInboundConfirmMode = config?.preset === "inbound-confirm"; const [cartCount, setCartCount] = useState(0); const [cartIsDirty, setCartIsDirty] = useState(false); const [cartSaving, setCartSaving] = useState(false); const [showCartConfirm, setShowCartConfirm] = useState(false); + const [confirmProcessing, setConfirmProcessing] = useState(false); + const [showInboundConfirm, setShowInboundConfirm] = useState(false); + const [inboundSelectedCount, setInboundSelectedCount] = useState(0); + + // 입고 확정 모드: 선택 항목 수 수신 + useEffect(() => { + if (!isInboundConfirmMode || !componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__selected_items`, + (payload: unknown) => { + const data = payload as { value?: unknown[] } | undefined; + const items = Array.isArray(data?.value) ? data.value : []; + setInboundSelectedCount(items.length); + } + ); + return unsub; + }, [isInboundConfirmMode, componentId, subscribe]); // 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달) useEffect(() => { @@ -474,12 +516,117 @@ export function PopButtonComponent({ } }, [cartSaving]); + // 입고 확정: 데이터 수집 → API 호출 + const handleInboundConfirm = useCallback(async () => { + if (!componentId) return; + setConfirmProcessing(true); + + try { + // 동기적 이벤트 수집 (connectionResolver가 동기 중계) + const responses: CollectedDataResponse[] = []; + const unsub = subscribe( + `__comp_input__${componentId}__collected_data`, + (payload: unknown) => { + const enriched = payload as { value?: CollectedDataResponse }; + if (enriched?.value) { + responses.push(enriched.value); + } + } + ); + + publish(`__comp_output__${componentId}__collect_data`, { + requestId: crypto.randomUUID(), + action: "inbound-confirm", + }); + + unsub(); + + if (responses.length === 0) { + toast.error("연결된 컴포넌트에서 데이터를 수집할 수 없습니다. 연결 설정을 확인하세요."); + return; + } + + const cardListData = responses.find(r => r.componentType === "pop-card-list"); + const fieldData = responses.find(r => r.componentType === "pop-field"); + + const selectedItems = cardListData?.data?.items ?? []; + if (selectedItems.length === 0) { + toast.error("확정할 항목을 선택해주세요."); + return; + } + + const fieldValues = fieldData?.data?.values ?? {}; + + const statusChangeRules = config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? []; + + const cardListMapping = cardListData?.mapping ?? null; + const fieldMapping = fieldData?.mapping ?? null; + + const result = await apiClient.post("/pop/execute-action", { + action: "inbound-confirm", + data: { + items: selectedItems, + fieldValues, + }, + mappings: { + cardList: cardListMapping, + field: fieldMapping, + }, + statusChanges: statusChangeRules, + }); + + if (result.data?.success) { + toast.success(`${selectedItems.length}건 입고 확정 완료`); + publish(`__comp_output__${componentId}__action_completed`, { + action: "inbound-confirm", + success: true, + count: selectedItems.length, + }); + + // 후속 액션 실행 (navigate, refresh 등) + const followUps = config?.followUpActions ?? []; + for (const fa of followUps) { + switch (fa.type) { + case "navigate": + if (fa.targetScreenId) { + publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params }); + } + break; + case "refresh": + publish("__pop_refresh__"); + break; + case "event": + if (fa.eventName) publish(fa.eventName, fa.eventPayload); + break; + } + } + } else { + toast.error(result.data?.message || "입고 확정에 실패했습니다."); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "입고 확정 중 오류가 발생했습니다."; + toast.error(message); + } finally { + setConfirmProcessing(false); + setShowInboundConfirm(false); + } + }, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules, config?.followUpActions]); + // 클릭 핸들러 const handleClick = useCallback(async () => { if (isDesignMode) { - toast.info( - `[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션` - ); + const modeLabel = isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"]; + toast.info(`[디자인 모드] ${modeLabel} 액션`); + return; + } + + // 입고 확정 모드: confirm 다이얼로그 후 데이터 수집 → API 호출 + if (isInboundConfirmMode) { + if (config?.confirm?.enabled !== false) { + setShowInboundConfirm(true); + } else { + await handleInboundConfirm(); + } return; } @@ -513,7 +660,7 @@ export function PopButtonComponent({ confirm: config?.confirm, followUpActions: config?.followUpActions, }); - }, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]); + }, [isDesignMode, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm]); // 외형 const buttonLabel = config?.label || label || "버튼"; @@ -541,6 +688,20 @@ export function PopButtonComponent({ return ""; }, [isCartMode, cartCount, cartIsDirty]); + // 입고 확정 2상태 아이콘: 미선택(기본 아이콘) / 선택됨(체크 아이콘) + const inboundIconName = useMemo(() => { + if (!isInboundConfirmMode) return iconName; + return inboundSelectedCount > 0 ? (config?.icon || "PackageCheck") : (config?.icon || "PackageCheck"); + }, [isInboundConfirmMode, inboundSelectedCount, config?.icon, iconName]); + + // 입고 확정 2상태 버튼 색상: 미선택(기본) / 선택됨(초록) + const inboundButtonClass = useMemo(() => { + if (!isInboundConfirmMode) return ""; + return inboundSelectedCount > 0 + ? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600" + : ""; + }, [isInboundConfirmMode, inboundSelectedCount]); + return ( <>
@@ -548,16 +709,17 @@ export function PopButtonComponent({
)} + + {/* 입고 확정 선택 개수 배지 */} + {isInboundConfirmMode && inboundSelectedCount > 0 && ( +
+ {inboundSelectedCount} +
+ )}
@@ -610,6 +782,35 @@ export function PopButtonComponent({ + {/* 입고 확정 확인 다이얼로그 */} + + + + + 입고 확정 + + + {config?.confirm?.message || "선택한 품목을 입고 확정하시겠습니까?"} + + + + + 취소 + + { handleInboundConfirm(); }} + disabled={confirmProcessing} + className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" + > + {confirmProcessing ? "처리 중..." : "확정"} + + + + + {/* 일반 확인 다이얼로그 */} { if (!open) cancelConfirm(); }}> @@ -1029,7 +1230,7 @@ export function PopButtonConfigPanel({

자동 설정

- + @@ -1130,6 +1331,17 @@ export function PopButtonConfigPanel({ )}
+ {/* 상태 변경 규칙 (cart 프리셋 제외 모두 표시) */} + {config?.preset !== "cart" && ( + <> + + onUpdate({ ...config, statusChangeRules: rules })} + /> + + )} + {/* 후속 액션 */} void; +}) { + const [tables, setTables] = useState([]); + const [columnsMap, setColumnsMap] = useState>({}); + + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + const loadColumns = (tableName: string) => { + if (!tableName || columnsMap[tableName]) return; + fetchTableColumns(tableName).then((cols) => { + setColumnsMap((prev) => ({ ...prev, [tableName]: cols })); + }); + }; + + const updateRule = (idx: number, partial: Partial) => { + const next = [...rules]; + next[idx] = { ...next[idx], ...partial }; + onUpdate(next); + }; + + const removeRule = (idx: number) => { + const next = [...rules]; + next.splice(idx, 1); + onUpdate(next); + }; + + const addRule = () => { + onUpdate([ + ...rules, + { + targetTable: "", + targetColumn: "", + valueType: "fixed", + fixedValue: "", + }, + ]); + }; + + return ( +
+ {rules.map((rule, idx) => ( + updateRule(idx, partial)} + onRemove={() => removeRule(idx)} + /> + ))} + +
+ ); +} + +function SingleRuleEditor({ + rule, + idx, + tables, + columns, + onLoadColumns, + onUpdate, + onRemove, +}: { + rule: StatusChangeRule; + idx: number; + tables: TableInfo[]; + columns: ColumnInfo[]; + onLoadColumns: (tableName: string) => void; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + useEffect(() => { + if (rule.targetTable) onLoadColumns(rule.targetTable); + }, [rule.targetTable]); // eslint-disable-line react-hooks/exhaustive-deps + + const conditions = rule.conditionalValue?.conditions ?? []; + const defaultValue = rule.conditionalValue?.defaultValue ?? ""; + + const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => { + const next = [...conditions]; + next[cIdx] = { ...next[cIdx], ...partial }; + onUpdate({ + conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue }, + }); + }; + + const removeCondition = (cIdx: number) => { + const next = [...conditions]; + next.splice(cIdx, 1); + onUpdate({ + conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue }, + }); + }; + + const addCondition = () => { + onUpdate({ + conditionalValue: { + ...rule.conditionalValue, + conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }], + defaultValue, + }, + }); + }; + + return ( +
+
+ 규칙 {idx + 1} + +
+ + {/* 대상 테이블 */} +
+ + onUpdate({ targetTable: v, targetColumn: "" })} + /> +
+ + {/* 변경 컬럼 */} + {rule.targetTable && ( +
+ + onUpdate({ targetColumn: v })} + /> +
+ )} + + {/* 조회 키 */} + {rule.targetColumn && ( +
+
+ + +
+ {(rule.lookupMode ?? "auto") === "auto" ? ( +

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

+ ) : ( +
+ + + onUpdate({ manualPkColumn: v })} + placeholder="대상 PK 컬럼" + /> +
+ )} +
+ )} + + {/* 변경 값 타입 */} + {rule.targetColumn && ( + <> +
+ +
+ + +
+
+ + {/* 고정값 */} + {rule.valueType === "fixed" && ( +
+ onUpdate({ fixedValue: e.target.value })} + className="h-7 text-xs" + placeholder="변경할 값 입력" + /> +
+ )} + + {/* 조건부 */} + {rule.valueType === "conditional" && ( +
+ {conditions.map((cond, cIdx) => ( +
+
+ 만약 + updateCondition(cIdx, { whenColumn: v })} + placeholder="컬럼" + /> + + updateCondition(cIdx, { whenValue: e.target.value })} + className="h-7 w-16 text-[10px]" + placeholder="값" + /> + +
+
+ 이면 -> + updateCondition(cIdx, { thenValue: e.target.value })} + className="h-7 text-[10px]" + placeholder="변경할 값" + /> +
+
+ ))} + +
+ 그 외 -> + + onUpdate({ + conditionalValue: { ...rule.conditionalValue, conditions, defaultValue: e.target.value }, + }) + } + className="h-7 text-[10px]" + placeholder="기본값" + /> +
+
+ )} + + + )} +
+ ); +} + // 레지스트리 등록 PopComponentRegistry.registerComponent({ id: "pop-button", @@ -1486,11 +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/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 37d506dc..c4604920 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -2339,9 +2339,12 @@ export class ButtonActionExecutor { } // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) - // 🔧 수정 모드 체크: formData.id 또는 originalGroupedData가 있으면 UPDATE 모드 + // 🔧 수정 모드 체크: _mainRecordId, formData.id 또는 originalGroupedData가 있으면 UPDATE 모드 + const parentMainRecordId = modalData._mainRecordId || formData._mainRecordId; const isEditModeUniversal = - (formData.id !== undefined && formData.id !== null && formData.id !== "") || originalGroupedData.length > 0; + (parentMainRecordId !== undefined && parentMainRecordId !== null && parentMainRecordId !== "") || + (formData.id !== undefined && formData.id !== null && formData.id !== "") || + originalGroupedData.length > 0; const fieldsWithNumbering: Record = {}; @@ -2417,6 +2420,13 @@ export class ButtonActionExecutor { ); if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) { + // _mainRecordId: 분할패널에서 전달한 실제 메인 레코드 ID (formData.id는 디테일 레코드 ID일 수 있음) + const mainRecordIdFromParent = + modalData._mainRecordId || formData._mainRecordId || commonFieldsData._mainRecordId; + // 메인 테이블 ID 결정: _mainRecordId > formData.id 순서로 탐색 + const existingMainId = mainRecordIdFromParent || formData.id; + const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== ""; + const mainRowToSave = { ...commonFieldsData, ...userInfo }; // 메타데이터 제거 @@ -2426,24 +2436,15 @@ export class ButtonActionExecutor { } }); - // 🆕 메인 테이블 UPDATE/INSERT 판단 - // - formData.id가 있으면 편집 모드 → UPDATE - // - formData.id가 없으면 신규 등록 → INSERT - const existingMainId = formData.id; - const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== ""; - let mainSaveResult: { success: boolean; data?: any; message?: string }; if (isMainUpdate) { - // 🔄 편집 모드: UPDATE 실행 - mainSaveResult = await DynamicFormApi.updateFormData(existingMainId, { tableName: tableName!, data: mainRowToSave, }); mainRecordId = existingMainId; } else { - // ➕ 신규 등록: INSERT 실행 console.log("➕ [handleUniversalFormModalTableSectionSave] 메인 테이블 INSERT 실행"); mainSaveResult = await DynamicFormApi.saveFormData({ screenId: screenId!, @@ -2471,8 +2472,32 @@ export class ButtonActionExecutor { // 1️⃣ 신규 품목 INSERT (id가 없는 항목) const newItems = currentItems.filter((item) => !item.id); + const existingItemsForRef = currentItems.filter((item) => item.id); + + // 기존 DB 항목에서 공통 필드 추출 (수정 모달에서 commonFieldsData에 누락된 필드 보완) + // 예: item_code, item_name 등이 commonFieldsData에 없으면 기존 항목에서 가져옴 + const sharedFieldsFromExisting: Record = {}; + if (existingItemsForRef.length > 0 && newItems.length > 0) { + const refItem = existingItemsForRef[0]; + for (const [key, val] of Object.entries(refItem)) { + if ( + key !== "id" && + !key.startsWith("_") && + val !== undefined && + val !== null && + val !== "" && + commonFieldsData[key] === undefined + ) { + sharedFieldsFromExisting[key] = val; + } + } + if (Object.keys(sharedFieldsFromExisting).length > 0) { + console.log("📋 [INSERT] 기존 항목에서 공통 필드 보완:", Object.keys(sharedFieldsFromExisting)); + } + } + for (const item of newItems) { - const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; + const rowToSave = { ...sharedFieldsFromExisting, ...commonFieldsData, ...item, ...userInfo }; Object.keys(rowToSave).forEach((key) => { if (key.startsWith("_")) { @@ -6484,6 +6509,12 @@ export class ButtonActionExecutor { return true; } else { // 기본: 분할 패널 데이터 전달 이벤트 + console.log("📤 [transferData] splitPanelDataTransfer 발송:", { + rowCount: selectedRows.length, + mappingRules, + sampleRow: selectedRows[0], + hasItemId: selectedRows[0]?.item_id, + }); const transferEvent = new CustomEvent("splitPanelDataTransfer", { detail: { 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; +} diff --git a/frontend/types/v2-repeater.ts b/frontend/types/v2-repeater.ts index 2d9199b4..7a31e948 100644 --- a/frontend/types/v2-repeater.ts +++ b/frontend/types/v2-repeater.ts @@ -142,6 +142,16 @@ export interface CalculationRule { label?: string; } +// Entity 조인 설정 (리피터 컬럼의 FK를 참조 테이블과 조인하여 표시) +export interface RepeaterEntityJoin { + sourceColumn: string; // FK 컬럼 (예: "item_id") + referenceTable: string; // 참조 테이블 (예: "item_info") + columns: Array<{ + referenceField: string; // 참조 테이블 컬럼 (예: "item_name") + displayField: string; // 리피터 표시 컬럼 키 (예: "item_name") + }>; +} + // 소스 디테일 설정 (모달에서 전달받은 마스터 데이터의 디테일을 자동 조회) export interface SourceDetailConfig { tableName: string; // 디테일 테이블명 (예: "sales_order_detail") @@ -185,6 +195,9 @@ export interface V2RepeaterConfig { // 모달 설정 (modal, mixed 모드) modal?: RepeaterModalConfig; + // Entity 조인 설정 (FK 기반으로 참조 테이블 데이터를 자동 해석하여 표시) + entityJoins?: RepeaterEntityJoin[]; + // 기능 옵션 features: RepeaterFeatureOptions;