From e31bb970a280fa64e67d564df03ef2e3debcc7cb Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Feb 2026 17:38:06 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EA=B0=80=EB=8F=85=EC=84=B1=20=ED=96=A5?= =?UTF-8?q?=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - numberingRuleController.ts에서 API 엔드포인트의 코드 스타일을 일관되게 정리하여 가독성을 높였습니다. - 불필요한 줄바꿈을 제거하고, 코드 블록을 명확하게 정리하여 유지보수성을 개선했습니다. - tableManagementService.ts와 ButtonConfigPanel.tsx에서 코드 정리를 통해 일관성을 유지하고, 가독성을 향상시켰습니다. - 전반적으로 코드의 깔끔함을 유지하고, 향후 개발 시 이해하기 쉽게 개선했습니다. --- .../controllers/numberingRuleController.ts | 755 ++++++++------ .../src/services/tableManagementService.ts | 31 +- .../config-panels/ButtonConfigPanel.tsx | 441 ++++---- .../components/screen/widgets/TabsWidget.tsx | 86 +- .../components/unified/UnifiedRepeater.tsx | 181 ++-- frontend/components/v2/V2Repeater.tsx | 332 +++--- frontend/lib/api/numberingRule.ts | 54 +- .../RepeatScreenModalComponent.tsx | 971 +++++++++--------- .../UniversalFormModalComponent.tsx | 50 +- .../components/v2-input/V2InputRenderer.tsx | 2 +- frontend/lib/utils/buttonActions.ts | 15 +- 11 files changed, 1570 insertions(+), 1348 deletions(-) diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index a8f99b36..d307b41a 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -3,392 +3,545 @@ */ import { Router, Response } from "express"; -import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware"; +import { + authenticateToken, + AuthenticatedRequest, +} from "../middleware/authMiddleware"; import { numberingRuleService } from "../services/numberingRuleService"; import { logger } from "../utils/logger"; const router = Router(); // 규칙 목록 조회 (전체) -router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; +router.get( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; - try { - const rules = await numberingRuleService.getRuleList(companyCode); - return res.json({ success: true, data: rules }); - } catch (error: any) { - logger.error("규칙 목록 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const rules = await numberingRuleService.getRuleList(companyCode); + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("규칙 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 메뉴별 사용 가능한 규칙 조회 -router.get("/available/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; +router.get( + "/available/:menuObjid?", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const menuObjid = req.params.menuObjid + ? parseInt(req.params.menuObjid) + : undefined; - logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode }); + logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode }); - try { - const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid); - - logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", { - companyCode, - menuObjid, - rulesCount: rules.length - }); - - return res.json({ success: true, data: rules }); - } catch (error: any) { - logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", { - error: error.message, - errorCode: error.code, - errorStack: error.stack, - companyCode, - menuObjid, - }); - return res.status(500).json({ success: false, error: error.message }); + try { + const rules = await numberingRuleService.getAvailableRulesForMenu( + companyCode, + menuObjid + ); + + logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", { + companyCode, + menuObjid, + rulesCount: rules.length, + }); + + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", { + error: error.message, + errorCode: error.code, + errorStack: error.stack, + companyCode, + menuObjid, + }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화) -router.get("/available-for-screen", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { tableName } = req.query; +router.get( + "/available-for-screen", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName } = req.query; - try { - // tableName 필수 검증 - if (!tableName || typeof tableName !== "string") { - return res.status(400).json({ - success: false, - error: "tableName is required", - }); - } - - const rules = await numberingRuleService.getAvailableRulesForScreen( - companyCode, - tableName - ); - - logger.info("화면용 채번 규칙 조회 성공", { - companyCode, - tableName, - count: rules.length, - }); - - return res.json({ success: true, data: rules }); - } catch (error: any) { - logger.error("화면용 채번 규칙 조회 실패", { - error: error.message, - tableName, - }); - return res.status(500).json({ - success: false, - error: error.message, - }); - } -}); - -// 특정 규칙 조회 -router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - - try { - const rule = await numberingRuleService.getRuleById(ruleId, companyCode); - if (!rule) { - return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" }); - } - return res.json({ success: true, data: rule }); - } catch (error: any) { - logger.error("규칙 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); - } -}); - -// 규칙 생성 -router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const ruleConfig = req.body; - - logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", { - companyCode, - userId, - ruleId: ruleConfig.ruleId, - ruleName: ruleConfig.ruleName, - scopeType: ruleConfig.scopeType, - menuObjid: ruleConfig.menuObjid, - tableName: ruleConfig.tableName, - partsCount: ruleConfig.parts?.length, - }); - - try { - if (!ruleConfig.ruleId || !ruleConfig.ruleName) { - return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" }); - } - - if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) { - return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" }); - } - - // 🆕 scopeType이 'table'인 경우 tableName 필수 체크 - if (ruleConfig.scopeType === "table") { - if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") { + try { + // tableName 필수 검증 + if (!tableName || typeof tableName !== "string") { return res.status(400).json({ success: false, - error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다", + error: "tableName is required", }); } - } - const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId); - - logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", { - ruleId: newRule.ruleId, - menuObjid: newRule.menuObjid, - }); + const rules = await numberingRuleService.getAvailableRulesForScreen( + companyCode, + tableName + ); - return res.status(201).json({ success: true, data: newRule }); - } catch (error: any) { - if (error.code === "23505") { - return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" }); + logger.info("화면용 채번 규칙 조회 성공", { + companyCode, + tableName, + count: rules.length, + }); + + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("화면용 채번 규칙 조회 실패", { + error: error.message, + tableName, + }); + return res.status(500).json({ + success: false, + error: error.message, + }); } - logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", { - error: error.message, - stack: error.stack, - code: error.code, - }); - return res.status(500).json({ success: false, error: error.message }); } -}); +); + +// 특정 규칙 조회 +router.get( + "/:ruleId", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + const rule = await numberingRuleService.getRuleById(ruleId, companyCode); + if (!rule) { + return res + .status(404) + .json({ success: false, error: "규칙을 찾을 수 없습니다" }); + } + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("규칙 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } + } +); + +// 규칙 생성 +router.post( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const ruleConfig = req.body; + + logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", { + companyCode, + userId, + ruleId: ruleConfig.ruleId, + ruleName: ruleConfig.ruleName, + scopeType: ruleConfig.scopeType, + menuObjid: ruleConfig.menuObjid, + tableName: ruleConfig.tableName, + partsCount: ruleConfig.parts?.length, + }); + + try { + if (!ruleConfig.ruleId || !ruleConfig.ruleName) { + return res + .status(400) + .json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" }); + } + + if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) { + return res + .status(400) + .json({ + success: false, + error: "최소 1개 이상의 규칙 파트가 필요합니다", + }); + } + + // 🆕 scopeType이 'table'인 경우 tableName 필수 체크 + if (ruleConfig.scopeType === "table") { + if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") { + return res.status(400).json({ + success: false, + error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다", + }); + } + } + + const newRule = await numberingRuleService.createRule( + ruleConfig, + companyCode, + userId + ); + + logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", { + ruleId: newRule.ruleId, + menuObjid: newRule.menuObjid, + }); + + return res.status(201).json({ success: true, data: newRule }); + } catch (error: any) { + if (error.code === "23505") { + return res + .status(409) + .json({ success: false, error: "이미 존재하는 규칙 ID입니다" }); + } + logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", { + error: error.message, + stack: error.stack, + code: error.code, + }); + return res.status(500).json({ success: false, error: error.message }); + } + } +); // 규칙 수정 -router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - const updates = req.body; +router.put( + "/:ruleId", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const updates = req.body; - logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates }); + logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates }); - try { - const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode); - logger.info("채번 규칙 수정 성공", { ruleId, companyCode }); - return res.json({ success: true, data: updatedRule }); - } catch (error: any) { - logger.error("채번 규칙 수정 실패", { - ruleId, - companyCode, - error: error.message, - stack: error.stack - }); - if (error.message.includes("찾을 수 없거나")) { - return res.status(404).json({ success: false, error: error.message }); + try { + const updatedRule = await numberingRuleService.updateRule( + ruleId, + updates, + companyCode + ); + logger.info("채번 규칙 수정 성공", { ruleId, companyCode }); + return res.json({ success: true, data: updatedRule }); + } catch (error: any) { + logger.error("채번 규칙 수정 실패", { + ruleId, + companyCode, + error: error.message, + stack: error.stack, + }); + if (error.message.includes("찾을 수 없거나")) { + return res.status(404).json({ success: false, error: error.message }); + } + return res.status(500).json({ success: false, error: error.message }); } - return res.status(500).json({ success: false, error: error.message }); } -}); +); // 규칙 삭제 -router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; +router.delete( + "/:ruleId", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; - try { - await numberingRuleService.deleteRule(ruleId, companyCode); - return res.json({ success: true, message: "규칙이 삭제되었습니다" }); - } catch (error: any) { - if (error.message.includes("찾을 수 없거나")) { - return res.status(404).json({ success: false, error: error.message }); + try { + await numberingRuleService.deleteRule(ruleId, companyCode); + return res.json({ success: true, message: "규칙이 삭제되었습니다" }); + } catch (error: any) { + if (error.message.includes("찾을 수 없거나")) { + return res.status(404).json({ success: false, error: error.message }); + } + logger.error("규칙 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); } - logger.error("규칙 삭제 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); } -}); +); // 코드 미리보기 (순번 증가 없음) -router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) +router.post( + "/:ruleId/preview", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) - try { - const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData); - return res.json({ success: true, data: { generatedCode: previewCode } }); - } catch (error: any) { - logger.error("코드 미리보기 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const previewCode = await numberingRuleService.previewCode( + ruleId, + companyCode, + formData + ); + return res.json({ success: true, data: { generatedCode: previewCode } }); + } catch (error: any) { + logger.error("코드 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 코드 할당 (저장 시점에 실제 순번 증가) -router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드 +router.post( + "/:ruleId/allocate", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드 - logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData, userInputCode }); + logger.info("코드 할당 요청", { + ruleId, + companyCode, + hasFormData: !!formData, + userInputCode, + }); - try { - const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode); - logger.info("코드 할당 성공", { ruleId, allocatedCode }); - return res.json({ success: true, data: { generatedCode: allocatedCode } }); - } catch (error: any) { - logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const allocatedCode = await numberingRuleService.allocateCode( + ruleId, + companyCode, + formData, + userInputCode + ); + logger.info("코드 할당 성공", { ruleId, allocatedCode }); + return res.json({ + success: true, + data: { generatedCode: allocatedCode }, + }); + } catch (error: any) { + logger.error("코드 할당 실패", { + ruleId, + companyCode, + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 코드 생성 (기존 호환성 유지, deprecated) -router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; +router.post( + "/:ruleId/generate", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; - try { - const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode); - return res.json({ success: true, data: { generatedCode } }); - } catch (error: any) { - logger.error("코드 생성 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const generatedCode = await numberingRuleService.generateCode( + ruleId, + companyCode + ); + return res.json({ success: true, data: { generatedCode } }); + } catch (error: any) { + logger.error("코드 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // 시퀀스 초기화 -router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; +router.post( + "/:ruleId/reset", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; - try { - await numberingRuleService.resetSequence(ruleId, companyCode); - return res.json({ success: true, message: "시퀀스가 초기화되었습니다" }); - } catch (error: any) { - logger.error("시퀀스 초기화 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + await numberingRuleService.resetSequence(ruleId, companyCode); + return res.json({ success: true, message: "시퀀스가 초기화되었습니다" }); + } catch (error: any) { + logger.error("시퀀스 초기화 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // ==================== 테스트 테이블용 API ==================== // [테스트] 테스트 테이블에서 채번 규칙 목록 조회 -router.get("/test/list/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; +router.get( + "/test/list/:menuObjid?", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const menuObjid = req.params.menuObjid + ? parseInt(req.params.menuObjid) + : undefined; - logger.info("[테스트] 채번 규칙 목록 조회 요청", { companyCode, menuObjid }); + logger.info("[테스트] 채번 규칙 목록 조회 요청", { + companyCode, + menuObjid, + }); - try { - const rules = await numberingRuleService.getRulesFromTest(companyCode, menuObjid); - logger.info("[테스트] 채번 규칙 목록 조회 성공", { companyCode, menuObjid, count: rules.length }); - return res.json({ success: true, data: rules }); - } catch (error: any) { - logger.error("[테스트] 채번 규칙 목록 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const rules = await numberingRuleService.getRulesFromTest( + companyCode, + menuObjid + ); + logger.info("[테스트] 채번 규칙 목록 조회 성공", { + companyCode, + menuObjid, + count: rules.length, + }); + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("[테스트] 채번 규칙 목록 조회 실패", { + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // [테스트] 테이블+컬럼 기반 채번 규칙 조회 -router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { tableName, columnName } = req.params; +router.get( + "/test/by-column/:tableName/:columnName", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; - try { - const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, columnName); - return res.json({ success: true, data: rule }); - } catch (error: any) { - logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + tableName, + columnName + ); + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // [테스트] 테스트 테이블에 채번 규칙 저장 // 채번 규칙은 독립적으로 생성 가능 (나중에 테이블 타입 관리에서 컬럼에 연결) -router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const ruleConfig = req.body; +router.post( + "/test/save", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const ruleConfig = req.body; - logger.info("[테스트] 채번 규칙 저장 요청", { - ruleId: ruleConfig.ruleId, - ruleName: ruleConfig.ruleName, - tableName: ruleConfig.tableName || "(미지정)", - columnName: ruleConfig.columnName || "(미지정)", - }); + logger.info("[테스트] 채번 규칙 저장 요청", { + ruleId: ruleConfig.ruleId, + ruleName: ruleConfig.ruleName, + tableName: ruleConfig.tableName || "(미지정)", + columnName: ruleConfig.columnName || "(미지정)", + }); - try { - // ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결) - if (!ruleConfig.ruleName) { - return res.status(400).json({ - success: false, - error: "ruleName is required" - }); + try { + // ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결) + if (!ruleConfig.ruleName) { + return res.status(400).json({ + success: false, + error: "ruleName is required", + }); + } + + const savedRule = await numberingRuleService.saveRuleToTest( + ruleConfig, + companyCode, + userId + ); + return res.json({ success: true, data: savedRule }); + } catch (error: any) { + logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); } - - const savedRule = await numberingRuleService.saveRuleToTest(ruleConfig, companyCode, userId); - return res.json({ success: true, data: savedRule }); - } catch (error: any) { - logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); } -}); +); // [테스트] 테스트 테이블에서 채번 규칙 삭제 -router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; +router.delete( + "/test/:ruleId", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; - try { - await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); - return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다" }); - } catch (error: any) { - logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); + return res.json({ + success: true, + message: "테스트 채번 규칙이 삭제되었습니다", + }); + } catch (error: any) { + logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // [테스트] 코드 미리보기 (테스트 테이블 사용) -router.post("/test/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { ruleId } = req.params; - const { formData } = req.body; +router.post( + "/test/:ruleId/preview", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const { formData } = req.body; - try { - const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData); - return res.json({ success: true, data: { generatedCode: previewCode } }); - } catch (error: any) { - logger.error("[테스트] 코드 미리보기 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const previewCode = await numberingRuleService.previewCode( + ruleId, + companyCode, + formData + ); + return res.json({ success: true, data: { generatedCode: previewCode } }); + } catch (error: any) { + logger.error("[테스트] 코드 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); // ==================== 회사별 채번규칙 복제 API ==================== // 회사별 채번규칙 복제 -router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const userCompanyCode = req.user!.companyCode; - const { sourceCompanyCode, targetCompanyCode } = req.body; +router.post( + "/copy-for-company", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const userCompanyCode = req.user!.companyCode; + const { sourceCompanyCode, targetCompanyCode } = req.body; - // 최고 관리자만 사용 가능 - if (userCompanyCode !== "*") { - return res.status(403).json({ - success: false, - error: "최고 관리자만 사용할 수 있습니다" - }); - } + // 최고 관리자만 사용 가능 + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + error: "최고 관리자만 사용할 수 있습니다", + }); + } - if (!sourceCompanyCode || !targetCompanyCode) { - return res.status(400).json({ - success: false, - error: "sourceCompanyCode와 targetCompanyCode가 필요합니다" - }); - } + if (!sourceCompanyCode || !targetCompanyCode) { + return res.status(400).json({ + success: false, + error: "sourceCompanyCode와 targetCompanyCode가 필요합니다", + }); + } - try { - const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode); - return res.json({ success: true, data: result }); - } catch (error: any) { - logger.error("회사별 채번규칙 복제 실패", { error: error.message }); - return res.status(500).json({ success: false, error: error.message }); + try { + const result = await numberingRuleService.copyRulesForCompany( + sourceCompanyCode, + targetCompanyCode + ); + return res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("회사별 채번규칙 복제 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } } -}); +); export default router; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e8d0b7b..2d4aa581 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -322,7 +322,9 @@ export class TableManagementService { }); } else { // menu_objid 컬럼이 없는 경우 - 매핑 없이 진행 - logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"); + logger.info( + "⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵" + ); } } catch (mappingError: any) { logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", { @@ -488,7 +490,10 @@ export class TableManagementService { // table_type_columns에 모든 설정 저장 (멀티테넌시 지원) // detailSettings가 문자열이면 그대로, 객체면 JSON.stringify let detailSettingsStr = settings.detailSettings; - if (typeof settings.detailSettings === "object" && settings.detailSettings !== null) { + if ( + typeof settings.detailSettings === "object" && + settings.detailSettings !== null + ) { detailSettingsStr = JSON.stringify(settings.detailSettings); } @@ -734,7 +739,7 @@ export class TableManagementService { inputType?: string ): Promise { try { - // 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로 + // 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로 // DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환 let finalWebType = webType; if (webType === "direct" || webType === "auto") { @@ -749,7 +754,8 @@ export class TableManagementService { ); // 웹 타입별 기본 상세 설정 생성 - const defaultDetailSettings = this.generateDefaultDetailSettings(finalWebType); + const defaultDetailSettings = + this.generateDefaultDetailSettings(finalWebType); // 사용자 정의 설정과 기본 설정 병합 const finalDetailSettings = { @@ -768,7 +774,12 @@ export class TableManagementService { input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, updated_date = NOW()`, - [tableName, columnName, finalWebType, JSON.stringify(finalDetailSettings)] + [ + tableName, + columnName, + finalWebType, + JSON.stringify(finalDetailSettings), + ] ); logger.info( `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}` @@ -796,7 +807,7 @@ export class TableManagementService { detailSettings?: Record ): Promise { try { - // 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로 + // 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로 // DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환 let finalInputType = inputType; if (inputType === "direct" || inputType === "auto") { @@ -1473,7 +1484,11 @@ export class TableManagementService { columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime") ) { - return this.buildDateRangeCondition(columnName, actualValue, paramIndex); + return this.buildDateRangeCondition( + columnName, + actualValue, + paramIndex + ); } // 그 외 타입이면 다중선택(IN 조건)으로 처리 @@ -3464,7 +3479,7 @@ export class TableManagementService { // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; const alias = aliasMap.get(aliasKey); - + // 🔧 파이프로 구분된 다중 선택값 처리 if (safeValue.includes("|")) { const multiValues = safeValue diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 6ea347c2..8d6df989 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -51,13 +51,9 @@ export const ButtonConfigPanel: React.FC = ({ }) => { // 🔧 component가 없는 경우 방어 처리 if (!component) { - return ( -
- 컴포넌트 정보를 불러올 수 없습니다. -
- ); + return
컴포넌트 정보를 불러올 수 없습니다.
; } - + // 🔧 component에서 직접 읽기 (useMemo 제거) const config = component.componentConfig || {}; const currentAction = component.componentConfig?.action || {}; @@ -122,7 +118,9 @@ export const ButtonConfigPanel: React.FC = ({ const [modalActionTargetTable, setModalActionTargetTable] = useState(null); const [modalActionSourceColumns, setModalActionSourceColumns] = useState>([]); const [modalActionTargetColumns, setModalActionTargetColumns] = useState>([]); - const [modalActionFieldMappings, setModalActionFieldMappings] = useState>([]); + const [modalActionFieldMappings, setModalActionFieldMappings] = useState< + Array<{ sourceField: string; targetField: string }> + >([]); const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState>({}); const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState>({}); const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState>({}); @@ -353,7 +351,7 @@ export const ButtonConfigPanel: React.FC = ({ useEffect(() => { const actionType = config.action?.type; if (actionType !== "modal") return; - + const autoDetect = config.action?.autoDetectDataSource; if (!autoDetect) { // 데이터 전달이 비활성화되면 상태 초기화 @@ -363,19 +361,19 @@ export const ButtonConfigPanel: React.FC = ({ setModalActionTargetColumns([]); return; } - + const targetScreenId = config.action?.targetScreenId; if (!targetScreenId) return; - + const loadModalActionMappingData = async () => { // 1. 소스 테이블 감지 (현재 화면) let sourceTableName: string | null = currentTableName || null; - + // allComponents에서 분할패널/테이블리스트/통합목록 감지 for (const comp of allComponents) { const compType = comp.componentType || (comp as any).componentConfig?.type; const compConfig = (comp as any).componentConfig || {}; - + if (compType === "split-panel-layout" || compType === "screen-split-panel") { sourceTableName = compConfig.leftPanel?.tableName || compConfig.tableName || null; if (sourceTableName) break; @@ -389,9 +387,9 @@ export const ButtonConfigPanel: React.FC = ({ if (sourceTableName) break; } } - + setModalActionSourceTable(sourceTableName); - + // 2. 대상 화면의 테이블 조회 let targetTableName: string | null = null; try { @@ -405,9 +403,9 @@ export const ButtonConfigPanel: React.FC = ({ } catch (error) { console.error("대상 화면 정보 로드 실패:", error); } - + setModalActionTargetTable(targetTableName); - + // 3. 소스 테이블 컬럼 로드 if (sourceTableName) { try { @@ -416,7 +414,7 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, @@ -429,7 +427,7 @@ export const ButtonConfigPanel: React.FC = ({ console.error("소스 테이블 컬럼 로드 실패:", error); } } - + // 4. 대상 테이블 컬럼 로드 if (targetTableName) { try { @@ -438,7 +436,7 @@ export const ButtonConfigPanel: React.FC = ({ let columnData = response.data.data; if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - + if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ name: col.name || col.columnName, @@ -451,7 +449,7 @@ export const ButtonConfigPanel: React.FC = ({ console.error("대상 테이블 컬럼 로드 실패:", error); } } - + // 5. 기존 필드 매핑 로드 또는 자동 매핑 생성 const existingMappings = config.action?.fieldMappings || []; if (existingMappings.length > 0) { @@ -461,10 +459,16 @@ export const ButtonConfigPanel: React.FC = ({ setModalActionFieldMappings([]); // 빈 배열 = 자동 매핑 } }; - + loadModalActionMappingData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + config.action?.type, + config.action?.autoDetectDataSource, + config.action?.targetScreenId, + currentTableName, + allComponents, + ]); // 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용) useEffect(() => { @@ -818,25 +822,25 @@ export const ButtonConfigPanel: React.FC = ({ 페이지 이동 모달 열기 데이터 전달 - + {/* 엑셀 관련 */} 엑셀 다운로드 엑셀 업로드 - + {/* 고급 기능 */} 즉시 저장 제어 흐름 - + {/* 특수 기능 (필요 시 사용) */} 바코드 스캔 운행알림 및 종료 - + {/* 이벤트 버스 */} 이벤트 발송 - + {/* 복사 */} 복사 (품목코드 초기화) - + {/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 연관 데이터 버튼 모달 열기 (deprecated) 데이터 전달 + 모달 열기 @@ -985,10 +989,10 @@ export const ButtonConfigPanel: React.FC = ({ }} />
-
@@ -996,11 +1000,11 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */} {component.componentConfig?.action?.autoDetectDataSource === true && ( -
+
{/* 테이블 정보 표시 */}
- + 소스: {modalActionSourceTable || "감지 중..."}
@@ -1012,171 +1016,210 @@ export const ButtonConfigPanel: React.FC = ({
{/* 테이블이 같으면 자동 매핑 안내 */} - {modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && ( -
- 동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다. -
- )} + {modalActionSourceTable && + modalActionTargetTable && + modalActionSourceTable === modalActionTargetTable && ( +
+ 동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다. +
+ )} {/* 테이블이 다르면 필드 매핑 UI 표시 */} - {modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && ( -
-
- - -
- - {(component.componentConfig?.action?.fieldMappings || []).length === 0 && ( -

- 컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다. -

- )} - - {(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => ( -
- {/* 소스 필드 선택 */} - setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))} - > - - - - - - setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))} - /> - - 컬럼을 찾을 수 없습니다. - - {modalActionSourceColumns - .filter((col) => - col.name.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) || - col.label.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) - ) - .map((col) => ( - { - const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])]; - newMappings[index] = { ...newMappings[index], sourceField: col.name }; - setModalActionFieldMappings(newMappings); - onUpdateProperty("componentConfig.action.fieldMappings", newMappings); - setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false })); - }} - > - -
- {col.label} - {col.name} -
-
- ))} -
-
-
-
-
- - - - {/* 대상 필드 선택 */} - setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))} - > - - - - - - setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))} - /> - - 컬럼을 찾을 수 없습니다. - - {modalActionTargetColumns - .filter((col) => - col.name.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) || - col.label.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) - ) - .map((col) => ( - { - const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])]; - newMappings[index] = { ...newMappings[index], targetField: col.name }; - setModalActionFieldMappings(newMappings); - onUpdateProperty("componentConfig.action.fieldMappings", newMappings); - setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false })); - }} - > - -
- {col.label} - {col.name} -
-
- ))} -
-
-
-
-
- - {/* 삭제 버튼 */} + {modalActionSourceTable && + modalActionTargetTable && + modalActionSourceTable !== modalActionTargetTable && ( +
+
+
- ))} -
- )} + + {(component.componentConfig?.action?.fieldMappings || []).length === 0 && ( +

+ 컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다. +

+ )} + + {(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => ( +
+ {/* 소스 필드 선택 */} + + setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open })) + } + > + + + + + + + setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val })) + } + /> + + 컬럼을 찾을 수 없습니다. + + {modalActionSourceColumns + .filter( + (col) => + col.name + .toLowerCase() + .includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) || + col.label + .toLowerCase() + .includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()), + ) + .map((col) => ( + { + const newMappings = [ + ...(component.componentConfig?.action?.fieldMappings || []), + ]; + newMappings[index] = { ...newMappings[index], sourceField: col.name }; + setModalActionFieldMappings(newMappings); + onUpdateProperty("componentConfig.action.fieldMappings", newMappings); + setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false })); + }} + > + +
+ {col.label} + {col.name} +
+
+ ))} +
+
+
+
+
+ + + + {/* 대상 필드 선택 */} + + setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open })) + } + > + + + + + + + setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val })) + } + /> + + 컬럼을 찾을 수 없습니다. + + {modalActionTargetColumns + .filter( + (col) => + col.name + .toLowerCase() + .includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) || + col.label + .toLowerCase() + .includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()), + ) + .map((col) => ( + { + const newMappings = [ + ...(component.componentConfig?.action?.fieldMappings || []), + ]; + newMappings[index] = { ...newMappings[index], targetField: col.name }; + setModalActionFieldMappings(newMappings); + onUpdateProperty("componentConfig.action.fieldMappings", newMappings); + setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false })); + }} + > + +
+ {col.label} + {col.name} +
+
+ ))} +
+
+
+
+
+ + {/* 삭제 버튼 */} + +
+ ))} +
+ )}
)}
@@ -1185,9 +1228,10 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */} {component.componentConfig?.action?.type === "openModalWithData" && (
-

데이터 전달 + 모달 설정

+

데이터 전달 + 모달 설정

- 이 옵션은 "모달 열기" 액션으로 통합되었습니다. 새 개발에서는 "모달 열기" + "선택된 데이터 전달"을 사용하세요. + 이 옵션은 "모달 열기" 액션으로 통합되었습니다. 새 개발에서는 "모달 열기" + "선택된 데이터 전달"을 + 사용하세요.

{/* 🆕 블록 기반 제목 빌더 */} @@ -3546,8 +3590,8 @@ export const ButtonConfigPanel: React.FC = ({

이벤트 발송 설정

- 버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다. - 다른 컴포넌트나 서비스에서 이 이벤트를 수신하여 처리할 수 있습니다. + 버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다. 다른 컴포넌트나 서비스에서 이 이벤트를 수신하여 + 처리할 수 있습니다.

@@ -3597,11 +3641,13 @@ export const ButtonConfigPanel: React.FC = ({ type="number" className="h-8 text-xs" placeholder="3" - value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3} + value={ + component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3 + } onChange={(e) => { onUpdateProperty( "componentConfig.action.eventConfig.eventPayload.config.scheduling.leadTimeDays", - parseInt(e.target.value) || 3 + parseInt(e.target.value) || 3, ); }} /> @@ -3613,11 +3659,14 @@ export const ButtonConfigPanel: React.FC = ({ type="number" className="h-8 text-xs" placeholder="100" - value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.maxDailyCapacity || 100} + value={ + component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling + ?.maxDailyCapacity || 100 + } onChange={(e) => { onUpdateProperty( "componentConfig.action.eventConfig.eventPayload.config.scheduling.maxDailyCapacity", - parseInt(e.target.value) || 100 + parseInt(e.target.value) || 100, ); }} /> @@ -3625,8 +3674,8 @@ export const ButtonConfigPanel: React.FC = ({

- 동작 방식: 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다. - 생성 전 미리보기 확인 다이얼로그가 표시됩니다. + 동작 방식: 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다. 생성 전 + 미리보기 확인 다이얼로그가 표시됩니다.

diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 8b48c461..6c770e48 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -65,7 +65,7 @@ export function TabsWidget({ const [selectedTab, setSelectedTab] = useState(getInitialTab()); const [visibleTabs, setVisibleTabs] = useState(tabs as ExtendedTabItem[]); const [mountedTabs, setMountedTabs] = useState>(() => new Set([getInitialTab()])); - + // 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트 useEffect(() => { // 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택 @@ -92,7 +92,7 @@ export function TabsWidget({ }); } }, [tabs]); // tabs가 변경될 때마다 실행 - + // screenId 기반 화면 로드 상태 const [screenLayouts, setScreenLayouts] = useState>({}); const [screenLoadingStates, setScreenLoadingStates] = useState>({}); @@ -109,23 +109,28 @@ export function TabsWidget({ for (const tab of visibleTabs) { const extTab = tab as ExtendedTabItem; // screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드 - if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) { - setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true })); + if ( + extTab.screenId && + !screenLayouts[tab.id] && + !screenLoadingStates[tab.id] && + (!extTab.components || extTab.components.length === 0) + ) { + setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true })); try { const layoutData = await screenApi.getLayout(extTab.screenId); if (layoutData && layoutData.components) { - setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components })); + setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components })); } } catch (error) { console.error(`탭 "${tab.label}" 화면 로드 실패:`, error); - setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); + setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); } finally { - setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false })); + setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: false })); } } } }; - + loadScreenLayouts(); }, [visibleTabs, screenLayouts, screenLoadingStates]); @@ -180,11 +185,7 @@ export function TabsWidget({ const getTabsListClass = () => { const baseClass = orientation === "vertical" ? "flex-col" : ""; const variantClass = - variant === "pills" - ? "bg-muted p-1 rounded-lg" - : variant === "underline" - ? "border-b" - : "bg-muted p-1"; + variant === "pills" ? "bg-muted p-1 rounded-lg" : variant === "underline" ? "border-b" : "bg-muted p-1"; return `${baseClass} ${variantClass}`; }; @@ -192,47 +193,47 @@ export function TabsWidget({ const renderTabContent = (tab: ExtendedTabItem) => { const extTab = tab as ExtendedTabItem; const inlineComponents = tab.components || []; - + // 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식 if (extTab.screenId && inlineComponents.length === 0) { // 로딩 중 if (screenLoadingStates[tab.id]) { return (
- - 화면을 불러오는 중... + + 화면을 불러오는 중...
); } - + // 에러 발생 if (screenErrors[tab.id]) { return ( -
+

{screenErrors[tab.id]}

); } - + // 화면 레이아웃이 로드된 경우 const loadedComponents = screenLayouts[tab.id]; if (loadedComponents && loadedComponents.length > 0) { return renderScreenComponents(loadedComponents); } - + // 아직 로드되지 않은 경우 return (
- +
); } - + // 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식 if (inlineComponents.length > 0) { return renderInlineComponents(tab, inlineComponents); } - + // 3. 둘 다 없는 경우 return (
@@ -246,22 +247,17 @@ export function TabsWidget({ // screenId로 로드한 화면 컴포넌트 렌더링 const renderScreenComponents = (components: ComponentData[]) => { // InteractiveScreenViewerDynamic 동적 로드 - const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic; - + const InteractiveScreenViewerDynamic = + require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic; + // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 - const maxBottom = Math.max( - ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), - 300 - ); - const maxRight = Math.max( - ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), - 400 - ); - + const maxBottom = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 300); + const maxRight = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400); + return ( -
(c.position?.y || 0) + (c.size?.height || 100)), - 300 // 최소 높이 + 300, // 최소 높이 ); const maxRight = Math.max( ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), - 400 // 최소 너비 + 400, // 최소 너비 ); return ( -
{tab.label} {tab.components && tab.components.length > 0 && ( - - ({tab.components.length}) - + ({tab.components.length}) )} {allowCloseable && ( @@ -390,7 +384,7 @@ export function TabsWidget({ onClick={(e) => handleCloseTab(tab.id, e)} variant="ghost" size="sm" - className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-destructive/10" + className="hover:bg-destructive/10 absolute top-1/2 right-1 h-5 w-5 -translate-y-1/2 p-0" > diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index d802baa7..2f521665 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -8,7 +8,7 @@ * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 * * RepeaterTable 및 ItemSelectionModal 재사용 - * + * * 데이터 전달 인터페이스: * - DataProvidable: 선택된 데이터 제공 * - DataReceivable: 외부에서 데이터 수신 @@ -124,83 +124,91 @@ export const UnifiedRepeater: React.FC = ({ // DataProvidable 인터페이스 구현 // 다른 컴포넌트에서 이 리피터의 데이터를 가져갈 수 있게 함 // ============================================================ - const dataProvider: DataProvidable = useMemo(() => ({ - componentId: parentId || config.fieldName || "unified-repeater", - componentType: "unified-repeater", - - // 선택된 행 데이터 반환 - getSelectedData: () => { - return Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean); - }, - - // 전체 데이터 반환 - getAllData: () => { - return [...data]; - }, - - // 선택 초기화 - clearSelection: () => { - setSelectedRows(new Set()); - }, - }), [parentId, config.fieldName, data, selectedRows]); + const dataProvider: DataProvidable = useMemo( + () => ({ + componentId: parentId || config.fieldName || "unified-repeater", + componentType: "unified-repeater", + + // 선택된 행 데이터 반환 + getSelectedData: () => { + return Array.from(selectedRows) + .map((idx) => data[idx]) + .filter(Boolean); + }, + + // 전체 데이터 반환 + getAllData: () => { + return [...data]; + }, + + // 선택 초기화 + clearSelection: () => { + setSelectedRows(new Set()); + }, + }), + [parentId, config.fieldName, data, selectedRows], + ); // ============================================================ // DataReceivable 인터페이스 구현 // 외부에서 이 리피터로 데이터를 전달받을 수 있게 함 // ============================================================ - const dataReceiver: DataReceivable = useMemo(() => ({ - componentId: parentId || config.fieldName || "unified-repeater", - componentType: "repeater", - - // 데이터 수신 (append, replace, merge 모드 지원) - receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => { - if (!incomingData || incomingData.length === 0) return; + const dataReceiver: DataReceivable = useMemo( + () => ({ + componentId: parentId || config.fieldName || "unified-repeater", + componentType: "repeater", - // 매핑 규칙 적용 - const mappedData = incomingData.map((item, index) => { - const newRow: any = { _id: `received_${Date.now()}_${index}` }; - - if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) { - receiverConfig.mappingRules.forEach((rule) => { - const sourceValue = item[rule.sourceField]; - newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue; - }); - } else { - // 매핑 규칙 없으면 그대로 복사 - Object.assign(newRow, item); + // 데이터 수신 (append, replace, merge 모드 지원) + receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => { + if (!incomingData || incomingData.length === 0) return; + + // 매핑 규칙 적용 + const mappedData = incomingData.map((item, index) => { + const newRow: any = { _id: `received_${Date.now()}_${index}` }; + + if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) { + receiverConfig.mappingRules.forEach((rule) => { + const sourceValue = item[rule.sourceField]; + newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue; + }); + } else { + // 매핑 규칙 없으면 그대로 복사 + Object.assign(newRow, item); + } + + return newRow; + }); + + // 모드에 따라 데이터 처리 + switch (receiverConfig.mode) { + case "replace": + setData(mappedData); + onDataChange?.(mappedData); + break; + case "merge": + // 중복 제거 후 병합 (id 또는 _id 기준) + const existingIds = new Set(data.map((row) => row.id || row._id)); + const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id)); + const mergedData = [...data, ...newItems]; + setData(mergedData); + onDataChange?.(mergedData); + break; + case "append": + default: + const appendedData = [...data, ...mappedData]; + setData(appendedData); + onDataChange?.(appendedData); + break; } - - return newRow; - }); + }, - // 모드에 따라 데이터 처리 - switch (receiverConfig.mode) { - case "replace": - setData(mappedData); - onDataChange?.(mappedData); - break; - case "merge": - // 중복 제거 후 병합 (id 또는 _id 기준) - const existingIds = new Set(data.map((row) => row.id || row._id)); - const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id)); - const mergedData = [...data, ...newItems]; - setData(mergedData); - onDataChange?.(mergedData); - break; - case "append": - default: - const appendedData = [...data, ...mappedData]; - setData(appendedData); - onDataChange?.(appendedData); - break; - } - }, - - // 현재 데이터 반환 - getData: () => { - return [...data]; - }, - }), [parentId, config.fieldName, data, onDataChange]); + // 현재 데이터 반환 + getData: () => { + return [...data]; + }, + }), + [parentId, config.fieldName, data, onDataChange], + ); // ============================================================ // ScreenContext에 DataProvider/DataReceiver 등록 @@ -208,7 +216,7 @@ export const UnifiedRepeater: React.FC = ({ useEffect(() => { if (screenContext && (parentId || config.fieldName)) { const componentId = parentId || config.fieldName || "unified-repeater"; - + screenContext.registerDataProvider(componentId, dataProvider); screenContext.registerDataReceiver(componentId, dataReceiver); @@ -231,7 +239,9 @@ export const UnifiedRepeater: React.FC = ({ componentId: parentId || config.fieldName || "unified-repeater", tableName: config.dataSource?.tableName || "", data: data, - selectedData: Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean), + selectedData: Array.from(selectedRows) + .map((idx) => data[idx]) + .filter(Boolean), }); prevDataLengthRef.current = data.length; } @@ -701,19 +711,22 @@ export const UnifiedRepeater: React.FC = ({ // 🆕 채번 API 호출 (비동기) // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 - const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { - try { - const result = await allocateNumberingCode(ruleId, userInputCode, formData); - if (result.success && result.data?.generatedCode) { - return result.data.generatedCode; + const generateNumberingCode = useCallback( + async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { + try { + const result = await allocateNumberingCode(ruleId, userInputCode, formData); + if (result.success && result.data?.generatedCode) { + return result.data.generatedCode; + } + console.error("채번 실패:", result.error); + return ""; + } catch (error) { + console.error("채번 API 호출 실패:", error); + return ""; } - console.error("채번 실패:", result.error); - return ""; - } catch (error) { - console.error("채번 API 호출 실패:", error); - return ""; - } - }, []); + }, + [], + ); // 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 const handleAddRow = useCallback(async () => { diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 5c66ba00..eda9e5b2 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -6,7 +6,7 @@ * 렌더링 모드: * - inline: 현재 테이블 컬럼 직접 입력 * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 - * + * * RepeaterTable 및 ItemSelectionModal 재사용 */ @@ -63,7 +63,7 @@ export const V2Repeater: React.FC = ({ // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); - + // 소스 테이블 컬럼 라벨 매핑 const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); @@ -72,10 +72,10 @@ export const V2Repeater: React.FC = ({ // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) const [categoryLabelMap, setCategoryLabelMap] = useState>({}); - + // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); - + // 동적 데이터 소스 상태 const [activeDataSources, setActiveDataSources] = useState>({}); @@ -88,10 +88,9 @@ export const V2Repeater: React.FC = ({ // 전역 리피터 등록 // 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블) useEffect(() => { - const targetTableName = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; - + const targetTableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + if (targetTableName) { if (!window.__v2RepeaterInstances) { window.__v2RepeaterInstances = new Set(); @@ -110,22 +109,21 @@ export const V2Repeater: React.FC = ({ useEffect(() => { const handleSaveEvent = async (event: CustomEvent) => { // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 - const tableName = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; + const tableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; const eventParentId = event.detail?.parentId; const mainFormData = event.detail?.mainFormData; - + // 🆕 마스터 테이블에서 생성된 ID (FK 연결용) const masterRecordId = event.detail?.masterRecordId || mainFormData?.id; - + if (!tableName || data.length === 0) { return; } // V2Repeater 저장 시작 - const saveInfo = { - tableName, + const saveInfo = { + tableName, useCustomTable: config.useCustomTable, mainTableName: config.mainTableName, foreignKeyColumn: config.foreignKeyColumn, @@ -145,10 +143,10 @@ export const V2Repeater: React.FC = ({ } catch { console.warn("테이블 컬럼 정보 조회 실패"); } - + for (let i = 0; i < data.length; i++) { const row = data[i]; - + // 내부 필드 제거 const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); @@ -157,14 +155,14 @@ export const V2Repeater: React.FC = ({ if (config.useCustomTable && config.mainTableName) { // 커스텀 테이블: 리피터 데이터만 저장 mergedData = { ...cleanRow }; - + // 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용 if (config.foreignKeyColumn) { // foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용 // 없으면 마스터 레코드 ID 사용 (기존 동작) const sourceColumn = config.foreignKeySourceColumn; let fkValue: any; - + if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) { // mainFormData에서 참조 컬럼 값 가져오기 fkValue = mainFormData[sourceColumn]; @@ -172,18 +170,18 @@ export const V2Repeater: React.FC = ({ // 기본: 마스터 레코드 ID 사용 fkValue = masterRecordId; } - + if (fkValue !== undefined && fkValue !== null) { mergedData[config.foreignKeyColumn] = fkValue; } } } else { // 기존 방식: 메인 폼 데이터 병합 - const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; + const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; mergedData = { - ...mainFormDataWithoutId, - ...cleanRow, - }; + ...mainFormDataWithoutId, + ...cleanRow, + }; } // 유효하지 않은 컬럼 제거 @@ -193,10 +191,9 @@ export const V2Repeater: React.FC = ({ filteredData[key] = value; } } - + await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); } - } catch (error) { console.error("❌ V2Repeater 저장 실패:", error); throw error; @@ -207,14 +204,13 @@ export const V2Repeater: React.FC = ({ const unsubscribe = v2EventBus.subscribe( V2_EVENTS.REPEATER_SAVE, async (payload) => { - const tableName = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; + const tableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; if (payload.tableName === tableName) { await handleSaveEvent({ detail: payload } as CustomEvent); } }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` } + { componentId: `v2-repeater-${config.dataSource?.tableName}` }, ); // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) @@ -223,7 +219,14 @@ export const V2Repeater: React.FC = ({ unsubscribe(); window.removeEventListener("repeaterSave" as any, handleSaveEvent); }; - }, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]); + }, [ + data, + config.dataSource?.tableName, + config.useCustomTable, + config.mainTableName, + config.foreignKeyColumn, + parentId, + ]); // 현재 테이블 컬럼 정보 로드 useEffect(() => { @@ -234,7 +237,7 @@ export const V2Repeater: React.FC = ({ try { const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); const columns = response.data?.data?.columns || response.data?.columns || response.data || []; - + const columnMap: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; @@ -320,7 +323,7 @@ export const V2Repeater: React.FC = ({ try { const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`); const columns = response.data?.data?.columns || response.data?.columns || response.data || []; - + const labels: Record = {}; const categoryCols: string[] = []; @@ -364,13 +367,13 @@ export const V2Repeater: React.FC = ({ calculated: true, width: col.width === "auto" ? undefined : col.width, }; - } - + } + // 일반 입력 컬럼 let type: "text" | "number" | "date" | "select" | "category" = "text"; - if (inputType === "number" || inputType === "decimal") type = "number"; - else if (inputType === "date" || inputType === "datetime") type = "date"; - else if (inputType === "code") type = "select"; + if (inputType === "number" || inputType === "decimal") type = "number"; + else if (inputType === "date" || inputType === "datetime") type = "date"; + else if (inputType === "code") type = "select"; else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) @@ -383,19 +386,19 @@ export const V2Repeater: React.FC = ({ categoryRef = `${tableName}.${col.key}`; } } - - return { - field: col.key, - label: col.title || colInfo?.displayName || col.key, - type, - editable: col.editable !== false, - width: col.width === "auto" ? undefined : col.width, - required: false, + + return { + field: col.key, + label: col.title || colInfo?.displayName || col.key, + type, + editable: col.editable !== false, + width: col.width === "auto" ? undefined : col.width, + required: false, categoryRef, // 🆕 카테고리 참조 ID 전달 hidden: col.hidden, // 🆕 히든 처리 autoFill: col.autoFill, // 🆕 자동 입력 설정 - }; - }); + }; + }); }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) @@ -451,26 +454,25 @@ export const V2Repeater: React.FC = ({ // 데이터 변경 핸들러 const handleDataChange = useCallback( (newData: any[]) => { - setData(newData); - - // 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용) - if (onDataChange) { - const targetTable = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; - - if (targetTable) { - // 각 행에 _targetTable 추가 - const dataWithTarget = newData.map(row => ({ - ...row, - _targetTable: targetTable, - })); - onDataChange(dataWithTarget); - } else { - onDataChange(newData); + setData(newData); + + // 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용) + if (onDataChange) { + const targetTable = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + + if (targetTable) { + // 각 행에 _targetTable 추가 + const dataWithTarget = newData.map((row) => ({ + ...row, + _targetTable: targetTable, + })); + onDataChange(dataWithTarget); + } else { + onDataChange(newData); + } } - } - + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 setAutoWidthTrigger((prev) => prev + 1); }, @@ -480,26 +482,25 @@ export const V2Repeater: React.FC = ({ // 행 변경 핸들러 const handleRowChange = useCallback( (index: number, newRow: any) => { - const newData = [...data]; - newData[index] = newRow; - setData(newData); - - // 🆕 _targetTable 메타데이터 포함 - if (onDataChange) { - const targetTable = config.useCustomTable && config.mainTableName - ? config.mainTableName - : config.dataSource?.tableName; - - if (targetTable) { - const dataWithTarget = newData.map(row => ({ - ...row, - _targetTable: targetTable, - })); - onDataChange(dataWithTarget); - } else { - onDataChange(newData); + const newData = [...data]; + newData[index] = newRow; + setData(newData); + + // 🆕 _targetTable 메타데이터 포함 + if (onDataChange) { + const targetTable = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + + if (targetTable) { + const dataWithTarget = newData.map((row) => ({ + ...row, + _targetTable: targetTable, + })); + onDataChange(dataWithTarget); + } else { + onDataChange(newData); + } } - } }, [data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], ); @@ -507,16 +508,16 @@ export const V2Repeater: React.FC = ({ // 행 삭제 핸들러 const handleRowDelete = useCallback( (index: number) => { - const newData = data.filter((_, i) => i !== index); + const newData = data.filter((_, i) => i !== index); handleDataChange(newData); // 🆕 handleDataChange 사용 - - // 선택 상태 업데이트 - const newSelected = new Set(); - selectedRows.forEach((i) => { - if (i < index) newSelected.add(i); - else if (i > index) newSelected.add(i - 1); - }); - setSelectedRows(newSelected); + + // 선택 상태 업데이트 + const newSelected = new Set(); + selectedRows.forEach((i) => { + if (i < index) newSelected.add(i); + else if (i > index) newSelected.add(i - 1); + }); + setSelectedRows(newSelected); }, [data, selectedRows, handleDataChange], ); @@ -535,30 +536,30 @@ export const V2Repeater: React.FC = ({ if (!col.autoFill || col.autoFill.type === "none") return undefined; const now = new Date(); - + switch (col.autoFill.type) { case "currentDate": return now.toISOString().split("T")[0]; // YYYY-MM-DD - + case "currentDateTime": return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss - + case "sequence": return rowIndex + 1; // 1부터 시작하는 순번 - + case "numbering": // 채번은 별도 비동기 처리 필요 return null; // null 반환하여 비동기 처리 필요함을 표시 - + case "fromMainForm": if (col.autoFill.sourceField && mainFormData) { return mainFormData[col.autoFill.sourceField]; } return ""; - + case "fixed": return col.autoFill.fixedValue ?? ""; - + default: return undefined; } @@ -568,19 +569,22 @@ export const V2Repeater: React.FC = ({ // 🆕 채번 API 호출 (비동기) // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 - const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { - try { - const result = await allocateNumberingCode(ruleId, userInputCode, formData); - if (result.success && result.data?.generatedCode) { - return result.data.generatedCode; + const generateNumberingCode = useCallback( + async (ruleId: string, userInputCode?: string, formData?: Record): Promise => { + try { + const result = await allocateNumberingCode(ruleId, userInputCode, formData); + if (result.success && result.data?.generatedCode) { + return result.data.generatedCode; + } + console.error("채번 실패:", result.error); + return ""; + } catch (error) { + console.error("채번 API 호출 실패:", error); + return ""; } - console.error("채번 실패:", result.error); - return ""; - } catch (error) { - console.error("채번 API 호출 실패:", error); - return ""; - } - }, []); + }, + [], + ); // 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 const handleAddRow = useCallback(async () => { @@ -589,7 +593,7 @@ export const V2Repeater: React.FC = ({ } else { const newRow: any = { _id: `new_${Date.now()}` }; const currentRowCount = data.length; - + // 먼저 동기적 자동 입력 값 적용 for (const col of config.columns) { const autoValue = generateAutoFillValueSync(col, currentRowCount); @@ -599,10 +603,10 @@ export const V2Repeater: React.FC = ({ } else if (autoValue !== undefined) { newRow[col.key] = autoValue; } else { - newRow[col.key] = ""; + newRow[col.key] = ""; } } - + const newData = [...data, newRow]; handleDataChange(newData); } @@ -611,23 +615,23 @@ export const V2Repeater: React.FC = ({ // 모달에서 항목 선택 - 비동기로 변경 const handleSelectItems = useCallback( async (items: Record[]) => { - const fkColumn = config.dataSource?.foreignKey; + const fkColumn = config.dataSource?.foreignKey; const currentRowCount = data.length; // 채번이 필요한 컬럼 찾기 const numberingColumns = config.columns.filter( - (col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId + (col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId, ); - + const newRows = await Promise.all( items.map(async (item, index) => { - const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; - + const row: any = { _id: `new_${Date.now()}_${Math.random()}` }; + // FK 값 저장 (resolvedReferenceKey 사용) if (fkColumn && item[resolvedReferenceKey]) { row[fkColumn] = item[resolvedReferenceKey]; - } - + } + // 모든 컬럼 처리 (순서대로) for (const col of config.columns) { if (col.isSourceDisplay) { @@ -643,20 +647,28 @@ export const V2Repeater: React.FC = ({ row[col.key] = autoValue; } else if (row[col.key] === undefined) { // 입력 컬럼: 빈 값으로 초기화 - row[col.key] = ""; - } + row[col.key] = ""; + } } } - - return row; - }) + + return row; + }), ); - - const newData = [...data, ...newRows]; + + const newData = [...data, ...newRows]; handleDataChange(newData); - setModalOpen(false); + setModalOpen(false); }, - [config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode], + [ + config.dataSource?.foreignKey, + resolvedReferenceKey, + config.columns, + data, + handleDataChange, + generateAutoFillValueSync, + generateNumberingCode, + ], ); // 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링 @@ -670,19 +682,19 @@ export const V2Repeater: React.FC = ({ // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 const dataRef = useRef(data); dataRef.current = data; - + useEffect(() => { const handleBeforeFormSave = async (event: Event) => { const customEvent = event as CustomEvent; const formData = customEvent.detail?.formData; - + if (!formData || !dataRef.current.length) return; // 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환 const processedData = await Promise.all( dataRef.current.map(async (row) => { const newRow = { ...row }; - + for (const key of Object.keys(newRow)) { const value = newRow[key]; if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) { @@ -706,16 +718,16 @@ export const V2Repeater: React.FC = ({ } } } - + return newRow; }), ); - + // 처리된 데이터를 formData에 추가 const fieldName = config.fieldName || "repeaterData"; formData[fieldName] = processedData; }; - + // V2 EventBus 구독 const unsubscribe = v2EventBus.subscribe( V2_EVENTS.FORM_SAVE_COLLECT, @@ -726,12 +738,12 @@ export const V2Repeater: React.FC = ({ } as CustomEvent; await handleBeforeFormSave(fakeEvent); }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` } + { componentId: `v2-repeater-${config.dataSource?.tableName}` }, ); // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) window.addEventListener("beforeFormSave", handleBeforeFormSave); - + return () => { unsubscribe(); window.removeEventListener("beforeFormSave", handleBeforeFormSave); @@ -744,20 +756,20 @@ export const V2Repeater: React.FC = ({ const handleComponentDataTransfer = async (event: Event) => { const customEvent = event as CustomEvent; const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {}; - + // 이 컴포넌트가 대상인지 확인 if (targetComponentId !== parentId && targetComponentId !== config.fieldName) { return; } - + if (!transferData || transferData.length === 0) { return; } - + // 데이터 매핑 처리 const mappedData = transferData.map((item: any, index: number) => { const newRow: any = { _id: `transfer_${Date.now()}_${index}` }; - + if (mappingRules && mappingRules.length > 0) { // 매핑 규칙이 있으면 적용 mappingRules.forEach((rule: any) => { @@ -767,10 +779,10 @@ export const V2Repeater: React.FC = ({ // 매핑 규칙 없으면 그대로 복사 Object.assign(newRow, item); } - + return newRow; }); - + // mode에 따라 데이터 처리 if (mode === "replace") { handleDataChange(mappedData); @@ -784,20 +796,20 @@ export const V2Repeater: React.FC = ({ handleDataChange([...data, ...mappedData]); } }; - + // splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달 const handleSplitPanelDataTransfer = async (event: Event) => { const customEvent = event as CustomEvent; const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {}; - + if (!transferData || transferData.length === 0) { return; } - + // 데이터 매핑 처리 const 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]; @@ -805,10 +817,10 @@ export const V2Repeater: React.FC = ({ } else { Object.assign(newRow, item); } - + return newRow; }); - + // mode에 따라 데이터 처리 if (mode === "replace") { handleDataChange(mappedData); @@ -816,7 +828,7 @@ export const V2Repeater: React.FC = ({ handleDataChange([...data, ...mappedData]); } }; - + // V2 EventBus 구독 const unsubscribeComponent = v2EventBus.subscribe( V2_EVENTS.COMPONENT_DATA_TRANSFER, @@ -831,7 +843,7 @@ export const V2Repeater: React.FC = ({ } as CustomEvent; handleComponentDataTransfer(fakeEvent); }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` } + { componentId: `v2-repeater-${config.dataSource?.tableName}` }, ); const unsubscribeSplitPanel = v2EventBus.subscribe( @@ -846,13 +858,13 @@ export const V2Repeater: React.FC = ({ } as CustomEvent; handleSplitPanelDataTransfer(fakeEvent); }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` } + { componentId: `v2-repeater-${config.dataSource?.tableName}` }, ); // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener); window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener); - + return () => { unsubscribeComponent(); unsubscribeSplitPanel(); @@ -928,11 +940,7 @@ V2Repeater.displayName = "V2Repeater"; // V2ErrorBoundary로 래핑된 안전한 버전 export export const SafeV2Repeater: React.FC = (props) => { return ( - + ); diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index 0800e752..b0ec38e2 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -26,13 +26,9 @@ export async function getNumberingRules(): Promise> { +export async function getAvailableNumberingRules(menuObjid?: number): Promise> { try { - const url = menuObjid - ? `/numbering-rules/available/${menuObjid}` - : "/numbering-rules/available"; + const url = menuObjid ? `/numbering-rules/available/${menuObjid}` : "/numbering-rules/available"; const response = await apiClient.get(url); return response.data; } catch (error: any) { @@ -46,7 +42,7 @@ export async function getAvailableNumberingRules( * @returns 해당 테이블의 채번 규칙 목록 */ export async function getAvailableNumberingRulesForScreen( - tableName: string + tableName: string, ): Promise> { try { const response = await apiClient.get("/numbering-rules/available-for-screen", { @@ -70,9 +66,7 @@ export async function getNumberingRuleById(ruleId: string): Promise> { +export async function createNumberingRule(config: NumberingRuleConfig): Promise> { try { const response = await apiClient.post("/numbering-rules", config); return response.data; @@ -83,7 +77,7 @@ export async function createNumberingRule( export async function updateNumberingRule( ruleId: string, - config: Partial + config: Partial, ): Promise> { try { const response = await apiClient.put(`/numbering-rules/${ruleId}`, config); @@ -110,7 +104,7 @@ export async function deleteNumberingRule(ruleId: string): Promise + formData?: Record, ): Promise> { // ruleId 유효성 검사 if (!ruleId || ruleId === "undefined" || ruleId === "null") { @@ -127,11 +121,8 @@ export async function previewNumberingCode( return response.data; } catch (error: unknown) { const err = error as { response?: { data?: { error?: string; message?: string } }; message?: string }; - const errorMessage = - err.response?.data?.error || - err.response?.data?.message || - err.message || - "코드 미리보기 실패"; + const errorMessage = + err.response?.data?.error || err.response?.data?.message || err.message || "코드 미리보기 실패"; return { success: false, error: errorMessage }; } } @@ -146,7 +137,7 @@ export async function previewNumberingCode( export async function allocateNumberingCode( ruleId: string, userInputCode?: string, - formData?: Record + formData?: Record, ): Promise> { try { const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, { @@ -162,9 +153,7 @@ export async function allocateNumberingCode( /** * @deprecated 기존 generateNumberingCode는 previewNumberingCode를 사용하세요 */ -export async function generateNumberingCode( - ruleId: string -): Promise> { +export async function generateNumberingCode(ruleId: string): Promise> { console.warn("generateNumberingCode는 deprecated. previewNumberingCode 사용 권장"); return previewNumberingCode(ruleId); } @@ -188,13 +177,9 @@ export async function resetSequence(ruleId: string): Promise> * numbering_rules 테이블 사용 * @param menuObjid 메뉴 OBJID (선택) - 필터링용 */ -export async function getNumberingRulesFromTest( - menuObjid?: number -): Promise> { +export async function getNumberingRulesFromTest(menuObjid?: number): Promise> { try { - const url = menuObjid - ? `/numbering-rules/test/list/${menuObjid}` - : "/numbering-rules/test/list"; + const url = menuObjid ? `/numbering-rules/test/list/${menuObjid}` : "/numbering-rules/test/list"; const response = await apiClient.get(url); return response.data; } catch (error: any) { @@ -211,7 +196,7 @@ export async function getNumberingRulesFromTest( */ export async function getNumberingRuleByColumn( tableName: string, - columnName: string + columnName: string, ): Promise> { try { const response = await apiClient.get("/numbering-rules/test/by-column", { @@ -230,9 +215,7 @@ export async function getNumberingRuleByColumn( * [테스트] 테스트 테이블에 채번규칙 저장 * numbering_rules 테이블 사용 */ -export async function saveNumberingRuleToTest( - config: NumberingRuleConfig -): Promise> { +export async function saveNumberingRuleToTest(config: NumberingRuleConfig): Promise> { try { const response = await apiClient.post("/numbering-rules/test/save", config); return response.data; @@ -248,9 +231,7 @@ export async function saveNumberingRuleToTest( * [테스트] 테스트 테이블에서 채번규칙 삭제 * numbering_rules 테이블 사용 */ -export async function deleteNumberingRuleFromTest( - ruleId: string -): Promise> { +export async function deleteNumberingRuleFromTest(ruleId: string): Promise> { try { const response = await apiClient.delete(`/numbering-rules/test/${ruleId}`); return response.data; @@ -270,7 +251,7 @@ export async function getNumberingRuleByColumnWithCategory( tableName: string, columnName: string, categoryColumn?: string, - categoryValueId?: number + categoryValueId?: number, ): Promise> { try { const response = await apiClient.get("/numbering-rules/test/by-column-with-category", { @@ -290,7 +271,7 @@ export async function getNumberingRuleByColumnWithCategory( */ export async function getRulesByTableColumn( tableName: string, - columnName: string + columnName: string, ): Promise> { try { const response = await apiClient.get("/numbering-rules/test/rules-by-table-column", { @@ -304,4 +285,3 @@ export async function getRulesByTableColumn( }; } } - diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 0cfdd542..6765e6c7 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -74,13 +74,13 @@ export function RepeatScreenModalComponent({ const showCardTitle = componentConfig?.showCardTitle ?? true; const cardTitle = componentConfig?.cardTitle || "카드 {index}"; const grouping = componentConfig?.grouping; - + // 🆕 v3: 자유 레이아웃 const contentRows = componentConfig?.contentRows || []; - + // 🆕 v3.1: Footer 설정 const footerConfig = componentConfig?.footerConfig; - + // (레거시 호환) const cardLayout = componentConfig?.cardLayout || []; const cardMode = componentConfig?.cardMode || "simple"; @@ -93,7 +93,7 @@ export function RepeatScreenModalComponent({ const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [isSaving, setIsSaving] = useState(false); - + // 🆕 v3.1: 외부 테이블 데이터 (테이블 행별로 관리) const [externalTableData, setExternalTableData] = useState>({}); // 🆕 v3.1: 삭제 확인 다이얼로그 @@ -108,12 +108,12 @@ export function RepeatScreenModalComponent({ useEffect(() => { const handleTriggerSave = async (event: Event) => { if (!(event instanceof CustomEvent)) return; - + console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신"); - + try { setIsSaving(true); - + // 기존 데이터 저장 if (cardMode === "withTable") { await saveGroupedData(); @@ -128,24 +128,28 @@ export function RepeatScreenModalComponent({ await processSyncSaves(); console.log("[RepeatScreenModal] 외부 트리거 저장 완료"); - + // 저장 완료 이벤트 발생 - window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", { - detail: { success: true } - })); - + window.dispatchEvent( + new CustomEvent("repeatScreenModalSaveComplete", { + detail: { success: true }, + }), + ); + // 성공 콜백 실행 if (event.detail?.onSuccess) { event.detail.onSuccess(); } } catch (error: any) { console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error); - + // 저장 실패 이벤트 발생 - window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", { - detail: { success: false, error: error.message } - })); - + window.dispatchEvent( + new CustomEvent("repeatScreenModalSaveComplete", { + detail: { success: false, error: error.message }, + }), + ); + // 실패 콜백 실행 if (event.detail?.onError) { event.detail.onError(error); @@ -177,7 +181,7 @@ export function RepeatScreenModalComponent({ // key 형식: cardId-contentRowId const keyParts = key.split("-"); const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId - + // contentRow 찾기 const contentRow = contentRows.find((r) => key.includes(r.id)); if (!contentRow?.tableDataSource?.enabled) continue; @@ -187,24 +191,22 @@ export function RepeatScreenModalComponent({ const representativeData = card?._representativeData || {}; const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; - + // dirty 행 또는 새로운 행 필터링 (삭제된 행 제외) // 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음) const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted); - + console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, { totalRows: rows.length, dirtyRows: dirtyRows.length, - rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted })) + rowDetails: rows.map((r) => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted })), }); - + if (dirtyRows.length === 0) continue; // 저장할 필드만 추출 - const editableFields = (contentRow.tableColumns || []) - .filter((col) => col.editable) - .map((col) => col.field); - + const editableFields = (contentRow.tableColumns || []).filter((col) => col.editable).map((col) => col.field); + // 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출 const joinConditions = contentRow.tableDataSource.joinConditions || []; const joinKeys = joinConditions.map((cond) => cond.sourceKey); @@ -217,14 +219,14 @@ export function RepeatScreenModalComponent({ for (const row of dirtyRows) { const saveData: Record = {}; - + // 허용된 필드만 포함 for (const field of allowedFields) { if (row[field] !== undefined) { saveData[field] = row[field]; } } - + // 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기 // 예: sales_order_id (sourceKey) = card의 id (targetKey) for (const joinCond of joinConditions) { @@ -232,14 +234,16 @@ export function RepeatScreenModalComponent({ // sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴 if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) { saveData[sourceKey] = representativeData[targetKey]; - console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`); + console.log( + `[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`, + ); } } - + // _isNew 플래그 유지 saveData._isNew = row._isNew; saveData._targetTable = targetTable; - + // 기존 레코드의 경우 id 포함 if (!row._isNew && row._originalData?.id) { saveData.id = row._originalData.id; @@ -333,7 +337,7 @@ export function RepeatScreenModalComponent({ // formData에서 선택된 행 ID 가져오기 let selectedIds: any[] = []; - + if (formData) { // 1. 명시적으로 설정된 filterField 확인 if (dataSource.filterField) { @@ -342,10 +346,10 @@ export function RepeatScreenModalComponent({ selectedIds = Array.isArray(filterValue) ? filterValue : [filterValue]; } } - + // 2. 일반적인 선택 필드 확인 (fallback) if (selectedIds.length === 0) { - const commonFields = ['selectedRows', 'selectedIds', 'checkedRows', 'checkedIds', 'ids']; + const commonFields = ["selectedRows", "selectedIds", "checkedRows", "checkedIds", "ids"]; for (const field of commonFields) { if (formData[field]) { const value = formData[field]; @@ -355,7 +359,7 @@ export function RepeatScreenModalComponent({ } } } - + // 3. formData에 id가 있으면 단일 행 if (selectedIds.length === 0 && formData.id) { selectedIds = [formData.id]; @@ -412,10 +416,10 @@ export function RepeatScreenModalComponent({ // 🆕 v3: contentRows가 있으면 새로운 방식 사용 const useNewLayout = contentRows && contentRows.length > 0; - + // 그룹핑 모드 확인 (groupByField가 없어도 enabled면 그룹핑 모드로 처리) const useGrouping = grouping?.enabled; - + if (useGrouping) { // 그룹핑 모드 const grouped = processGroupedData(loadedData, grouping); @@ -428,7 +432,7 @@ export function RepeatScreenModalComponent({ _originalData: { ...row }, _isDirty: false, ...(await loadCardData(row)), - })) + })), ); setCardsData(initialCards); } @@ -448,7 +452,7 @@ export function RepeatScreenModalComponent({ const loadExternalTableData = async () => { // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 const tableRowsWithExternalSource = contentRows.filter( - (row) => row.type === "table" && row.tableDataSource?.enabled + (row) => row.type === "table" && row.tableDataSource?.enabled, ); if (tableRowsWithExternalSource.length === 0) return; @@ -473,7 +477,7 @@ export function RepeatScreenModalComponent({ // 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189) // 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로 // 정확한 ID 매칭을 위해 숫자로 변환해야 함 - if (condition.sourceKey.endsWith('_id') || condition.sourceKey === 'id') { + if (condition.sourceKey.endsWith("_id") || condition.sourceKey === "id") { const numValue = Number(refValue); if (!isNaN(numValue)) { refValue = numValue; @@ -497,24 +501,21 @@ export function RepeatScreenModalComponent({ }); // API 호출 - 메인 테이블 데이터 - const response = await apiClient.post( - `/table-management/tables/${dataSourceConfig.sourceTable}/data`, - { - search: filters, - page: 1, - size: dataSourceConfig.limit || 100, - sort: dataSourceConfig.orderBy - ? { - column: dataSourceConfig.orderBy.column, - direction: dataSourceConfig.orderBy.direction, - } - : undefined, - } - ); + const response = await apiClient.post(`/table-management/tables/${dataSourceConfig.sourceTable}/data`, { + search: filters, + page: 1, + size: dataSourceConfig.limit || 100, + sort: dataSourceConfig.orderBy + ? { + column: dataSourceConfig.orderBy.column, + direction: dataSourceConfig.orderBy.direction, + } + : undefined, + }); if (response.data.success && response.data.data?.data) { let tableData = response.data.data.data; - + console.log(`[RepeatScreenModal] 소스 테이블 데이터 로드 완료:`, { sourceTable: dataSourceConfig.sourceTable, rowCount: tableData.length, @@ -538,7 +539,7 @@ export function RepeatScreenModalComponent({ // 🆕 v3.4: 필터 조건 적용 if (dataSourceConfig.filterConfig?.enabled) { const { filterField, filterType, referenceField, referenceSource } = dataSourceConfig.filterConfig; - + // 비교 값 가져오기 let referenceValue: any; if (referenceSource === "formData") { @@ -558,8 +559,10 @@ export function RepeatScreenModalComponent({ return rowValue !== referenceValue; } }); - - console.log(`[RepeatScreenModal] 필터 적용: ${filterField} ${filterType} ${referenceValue}, 결과: ${tableData.length}건`); + + console.log( + `[RepeatScreenModal] 필터 적용: ${filterField} ${filterType} ${referenceValue}, 결과: ${tableData.length}건`, + ); } } @@ -573,7 +576,7 @@ export function RepeatScreenModalComponent({ _isDeleted: false, ...row, })); - + // 디버그: 저장된 외부 테이블 데이터 확인 console.log(`[RepeatScreenModal] 외부 테이블 데이터 저장:`, { key, @@ -595,17 +598,17 @@ export function RepeatScreenModalComponent({ if (prevKeys === newKeys) { // 키가 같으면 데이터 내용 비교 const isSame = Object.keys(newExternalData).every( - (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]) + (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]), ); if (isSame) return prev; } - + // 🆕 v3.2: 외부 테이블 데이터 로드 후 집계 재계산 // 비동기적으로 처리하여 무한 루프 방지 setTimeout(() => { recalculateAggregationsWithExternalData(newExternalData); }, 0); - + return newExternalData; }); }; @@ -617,7 +620,7 @@ export function RepeatScreenModalComponent({ // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 const loadAndMergeJoinData = async ( mainData: any[], - additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[] + additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[], ): Promise => { if (mainData.length === 0) return mainData; @@ -627,23 +630,20 @@ export function RepeatScreenModalComponent({ // 메인 데이터에서 조인 키 값들 추출 const joinKeyValues = [...new Set(mainData.map((row) => row[joinConfig.sourceKey]).filter(Boolean))]; - + if (joinKeyValues.length === 0) continue; try { // 조인 테이블 데이터 조회 - const joinResponse = await apiClient.post( - `/table-management/tables/${joinConfig.joinTable}/data`, - { - search: { [joinConfig.targetKey]: joinKeyValues }, - page: 1, - size: 1000, // 충분히 큰 값 - } - ); + const joinResponse = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, { + search: { [joinConfig.targetKey]: joinKeyValues }, + page: 1, + size: 1000, // 충분히 큰 값 + }); if (joinResponse.data.success && joinResponse.data.data?.data) { const joinData = joinResponse.data.data.data; - + // 조인 데이터를 맵으로 변환 (빠른 조회를 위해) const joinDataMap = new Map(); for (const joinRow of joinData) { @@ -654,7 +654,7 @@ export function RepeatScreenModalComponent({ mainData = mainData.map((row) => { const joinKey = row[joinConfig.sourceKey]; const joinRow = joinDataMap.get(joinKey); - + if (joinRow) { // 조인 테이블의 컬럼들을 메인 데이터에 추가 (접두사 없이) const mergedRow = { ...row }; @@ -700,7 +700,7 @@ export function RepeatScreenModalComponent({ // contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기 const tableRowsWithExternalSource = contentRows.filter( - (row) => row.type === "table" && row.tableDataSource?.enabled + (row) => row.type === "table" && row.tableDataSource?.enabled, ); if (tableRowsWithExternalSource.length === 0) return; @@ -710,10 +710,10 @@ export function RepeatScreenModalComponent({ // 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장 const externalRowsByTableId: Record = {}; const allExternalRows: any[] = []; - + for (const tableRow of tableRowsWithExternalSource) { const key = `${card._cardId}-${tableRow.id}`; - // 🆕 v3.7: 삭제된 행은 집계에서 제외 + // 🆕 v3.7: 삭제된 행은 집계에서 제외 const rows = (extData[key] || []).filter((row) => !row._isDeleted); externalRowsByTableId[tableRow.id] = rows; allExternalRows.push(...rows); @@ -721,30 +721,31 @@ export function RepeatScreenModalComponent({ // 집계 재계산 const newAggregations: Record = {}; - + grouping.aggregations!.forEach((agg) => { const sourceType = agg.sourceType || "column"; - + if (sourceType === "column") { const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - + if (isExternalTable) { // 외부 테이블 집계 newAggregations[agg.resultField] = calculateColumnAggregation( - allExternalRows, - agg.sourceField || "", - agg.type || "sum" + allExternalRows, + agg.sourceField || "", + agg.type || "sum", ); } else { // 기본 테이블 집계 (기존 값 유지) - newAggregations[agg.resultField] = card._aggregations[agg.resultField] || + newAggregations[agg.resultField] = + card._aggregations[agg.resultField] || calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum"); } } else if (sourceType === "formula" && agg.formula) { // 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용 let filteredExternalRows: any[]; - + if (agg.externalTableRefs && agg.externalTableRefs.length > 0) { // 특정 테이블만 참조 filteredExternalRows = []; @@ -757,14 +758,14 @@ export function RepeatScreenModalComponent({ // 모든 외부 테이블 데이터 사용 (기존 동작) filteredExternalRows = allExternalRows; } - + // 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산 newAggregations[agg.resultField] = evaluateFormulaWithContext( agg.formula, card._representativeData, card._rows, filteredExternalRows, - newAggregations // 이전 집계 결과 참조 + newAggregations, // 이전 집계 결과 참조 ); } }); @@ -854,16 +855,16 @@ export function RepeatScreenModalComponent({ targetColumn: rowNumbering.targetColumn, numberingRuleId: rowNumbering.numberingRuleId, }); - + // 채번 API 호출 (allocate: 실제 시퀀스 증가) // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용) const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); const userInputCode = newRowData[rowNumbering.targetColumn] as string; const response = await allocateNumberingCode(rowNumbering.numberingRuleId, userInputCode, newRowData); - + if (response.success && response.data) { newRowData[rowNumbering.targetColumn] = response.data.generatedCode; - + console.log("[RepeatScreenModal] 자동 채번 완료:", { column: rowNumbering.targetColumn, generatedCode: response.data.generatedCode, @@ -888,12 +889,12 @@ export function RepeatScreenModalComponent({ ...prev, [key]: [...(prev[key] || []), newRowData], }; - + // 🆕 v3.5: 새 행 추가 시 집계 재계산 setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); - + return newData; }); }; @@ -902,7 +903,7 @@ export function RepeatScreenModalComponent({ const saveTableAreaData = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { const key = `${cardId}-${contentRowId}`; const rows = externalTableData[key] || []; - + console.log("[RepeatScreenModal] saveTableAreaData 시작:", { key, rowsCount: rows.length, @@ -910,7 +911,7 @@ export function RepeatScreenModalComponent({ tableDataSource: contentRow?.tableDataSource, tableCrud: contentRow?.tableCrud, }); - + if (!contentRow?.tableDataSource?.enabled) { console.warn("[RepeatScreenModal] 외부 테이블 데이터 소스가 설정되지 않음"); return { success: false, message: "데이터 소스가 설정되지 않았습니다." }; @@ -922,7 +923,7 @@ export function RepeatScreenModalComponent({ console.log("[RepeatScreenModal] 저장 대상:", { targetTable, dirtyRowsCount: dirtyRows.length, - dirtyRows: dirtyRows.map(r => ({ _isNew: r._isNew, _isDirty: r._isDirty, data: r })), + dirtyRows: dirtyRows.map((r) => ({ _isNew: r._isNew, _isDirty: r._isDirty, data: r })), }); if (dirtyRows.length === 0) { @@ -934,7 +935,7 @@ export function RepeatScreenModalComponent({ // 🆕 v3.6: editable한 컬럼 + 조인 키만 추출 (읽기 전용 컬럼은 제외) const allowedFields = new Set(); - + // tableColumns에서 editable: true인 필드만 추가 (읽기 전용 컬럼 제외) if (contentRow.tableColumns) { contentRow.tableColumns.forEach((col) => { @@ -945,20 +946,23 @@ export function RepeatScreenModalComponent({ } }); } - + // 조인 조건의 sourceKey 추가 (예: sales_order_id) - 이건 항상 필요 if (contentRow.tableDataSource?.joinConditions) { contentRow.tableDataSource.joinConditions.forEach((cond) => { if (cond.sourceKey) allowedFields.add(cond.sourceKey); }); } - + console.log("[RepeatScreenModal] 저장 허용 필드 (editable + 조인키):", Array.from(allowedFields)); - console.log("[RepeatScreenModal] tableColumns 정보:", contentRow.tableColumns?.map(c => ({ - field: c.field, - editable: c.editable, - inputType: c.inputType - }))); + console.log( + "[RepeatScreenModal] tableColumns 정보:", + contentRow.tableColumns?.map((c) => ({ + field: c.field, + editable: c.editable, + inputType: c.inputType, + })), + ); // 삭제할 행 (기존 데이터 중 _isDeleted가 true인 것) const deletedRows = dirtyRows.filter((row) => row._isDeleted && row._originalData?.id); @@ -971,25 +975,30 @@ export function RepeatScreenModalComponent({ // 🆕 v3.7: 삭제 처리 (배열 형태로 body에 전달) for (const row of deletedRows) { const deleteId = row._originalData.id; - console.log(`[RepeatScreenModal] DELETE 요청: /table-management/tables/${targetTable}/delete`, [{ id: deleteId }]); + console.log(`[RepeatScreenModal] DELETE 요청: /table-management/tables/${targetTable}/delete`, [ + { id: deleteId }, + ]); savePromises.push( - apiClient.request({ - method: "DELETE", - url: `/table-management/tables/${targetTable}/delete`, - data: [{ id: deleteId }], - }).then((res) => { - console.log("[RepeatScreenModal] DELETE 응답:", res.data); - return { type: "delete", id: deleteId }; - }).catch((err) => { - console.error("[RepeatScreenModal] DELETE 실패:", err.response?.data || err.message); - throw err; - }) + apiClient + .request({ + method: "DELETE", + url: `/table-management/tables/${targetTable}/delete`, + data: [{ id: deleteId }], + }) + .then((res) => { + console.log("[RepeatScreenModal] DELETE 응답:", res.data); + return { type: "delete", id: deleteId }; + }) + .catch((err) => { + console.error("[RepeatScreenModal] DELETE 실패:", err.response?.data || err.message); + throw err; + }), ); } for (const row of rowsToSave) { const { _rowId, _originalData, _isDirty, _isNew, _isDeleted, ...allData } = row; - + // 허용된 필드만 필터링 const dataToSave: Record = {}; for (const field of allowedFields) { @@ -1009,16 +1018,19 @@ export function RepeatScreenModalComponent({ // INSERT - /add 엔드포인트 사용 console.log(`[RepeatScreenModal] INSERT 요청: /table-management/tables/${targetTable}/add`, dataToSave); savePromises.push( - apiClient.post(`/table-management/tables/${targetTable}/add`, dataToSave).then((res) => { - console.log("[RepeatScreenModal] INSERT 응답:", res.data); - if (res.data?.data?.id) { - savedIds.push(res.data.data.id); - } - return res; - }).catch((err) => { - console.error("[RepeatScreenModal] INSERT 실패:", err.response?.data || err.message); - throw err; - }) + apiClient + .post(`/table-management/tables/${targetTable}/add`, dataToSave) + .then((res) => { + console.log("[RepeatScreenModal] INSERT 응답:", res.data); + if (res.data?.data?.id) { + savedIds.push(res.data.data.id); + } + return res; + }) + .catch((err) => { + console.error("[RepeatScreenModal] INSERT 실패:", err.response?.data || err.message); + throw err; + }), ); } else if (_originalData?.id) { // UPDATE - /edit 엔드포인트 사용 (originalData와 updatedData 형식) @@ -1028,14 +1040,17 @@ export function RepeatScreenModalComponent({ }; console.log(`[RepeatScreenModal] UPDATE 요청: /table-management/tables/${targetTable}/edit`, updatePayload); savePromises.push( - apiClient.put(`/table-management/tables/${targetTable}/edit`, updatePayload).then((res) => { - console.log("[RepeatScreenModal] UPDATE 응답:", res.data); - savedIds.push(_originalData.id); - return res; - }).catch((err) => { - console.error("[RepeatScreenModal] UPDATE 실패:", err.response?.data || err.message); - throw err; - }) + apiClient + .put(`/table-management/tables/${targetTable}/edit`, updatePayload) + .then((res) => { + console.log("[RepeatScreenModal] UPDATE 응답:", res.data); + savedIds.push(_originalData.id); + return res; + }) + .catch((err) => { + console.error("[RepeatScreenModal] UPDATE 실패:", err.response?.data || err.message); + throw err; + }), ); } } @@ -1055,7 +1070,15 @@ export function RepeatScreenModalComponent({ _isDirty: false, _isNew: false, _isEditing: false, // 🆕 v3.8: 수정 모드 해제 - _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined, _isDeleted: undefined, _isEditing: undefined }, + _originalData: { + ...row, + _rowId: undefined, + _originalData: undefined, + _isDirty: undefined, + _isNew: undefined, + _isDeleted: undefined, + _isEditing: undefined, + }, })); } return updated; @@ -1063,9 +1086,8 @@ export function RepeatScreenModalComponent({ const savedCount = rowsToSave.length; const deletedCount = deletedRows.length; - const message = deletedCount > 0 - ? `${savedCount}건 저장, ${deletedCount}건 삭제 완료` - : `${savedCount}건 저장 완료`; + const message = + deletedCount > 0 ? `${savedCount}건 저장, ${deletedCount}건 삭제 완료` : `${savedCount}건 저장 완료`; return { success: true, message, savedCount, deletedCount, savedIds }; } catch (error: any) { @@ -1081,7 +1103,7 @@ export function RepeatScreenModalComponent({ const result = await saveTableAreaData(cardId, contentRowId, contentRow); if (result.success) { console.log("[RepeatScreenModal] 테이블 영역 저장 성공:", result); - + // 🆕 v3.9: 집계 저장 설정이 있는 경우 연관 테이블 동기화 const card = groupedCardsData.find((c) => c._cardId === cardId); if (card && grouping?.aggregations) { @@ -1103,16 +1125,16 @@ export function RepeatScreenModalComponent({ for (const agg of grouping.aggregations) { const saveConfig = agg.saveConfig; - + // 저장 설정이 없거나 비활성화된 경우 스킵 if (!saveConfig?.enabled) continue; - + // 자동 저장이 아닌 경우, 레이아웃에 연결되어 있는지 확인 필요 // (현재는 자동 저장과 동일하게 처리 - 추후 레이아웃 연결 체크 추가 가능) - + // 집계 결과 값 가져오기 const aggregatedValue = card._aggregations[agg.resultField]; - + if (aggregatedValue === undefined) { console.warn(`[RepeatScreenModal] 집계 결과 없음: ${agg.resultField}`); continue; @@ -1120,7 +1142,7 @@ export function RepeatScreenModalComponent({ // 조인 키로 대상 레코드 식별 const sourceKeyValue = card._representativeData[saveConfig.joinKey.sourceField]; - + if (!sourceKeyValue) { console.warn(`[RepeatScreenModal] 조인 키 값 없음: ${saveConfig.joinKey.sourceField}`); continue; @@ -1137,22 +1159,25 @@ export function RepeatScreenModalComponent({ // UPDATE API 호출 const updatePayload = { originalData: { [saveConfig.joinKey.targetField]: sourceKeyValue }, - updatedData: { + updatedData: { [saveConfig.targetColumn]: aggregatedValue, [saveConfig.joinKey.targetField]: sourceKeyValue, }, }; savePromises.push( - apiClient.put(`/table-management/tables/${saveConfig.targetTable}/edit`, updatePayload) + apiClient + .put(`/table-management/tables/${saveConfig.targetTable}/edit`, updatePayload) .then((res) => { - console.log(`[RepeatScreenModal] 집계 저장 성공: ${agg.resultField} -> ${saveConfig.targetTable}.${saveConfig.targetColumn}`); + console.log( + `[RepeatScreenModal] 집계 저장 성공: ${agg.resultField} -> ${saveConfig.targetTable}.${saveConfig.targetColumn}`, + ); return res; }) .catch((err) => { console.error(`[RepeatScreenModal] 집계 저장 실패: ${agg.resultField}`, err.response?.data || err.message); throw err; - }) + }), ); } @@ -1167,7 +1192,12 @@ export function RepeatScreenModalComponent({ }; // 🆕 v3.1: 외부 테이블 행 삭제 요청 - const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + const handleDeleteExternalRowRequest = ( + cardId: string, + rowId: string, + contentRowId: string, + contentRow: CardContentRowConfig, + ) => { if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) { // 삭제 확인 팝업 표시 setPendingDeleteInfo({ cardId, rowId, contentRowId }); @@ -1196,7 +1226,7 @@ export function RepeatScreenModalComponent({ } console.log(`[RepeatScreenModal] DELETE API 호출: ${targetTable}, id=${targetRow._originalData.id}`); - + // 백엔드는 배열 형태의 데이터를 기대함 await apiClient.request({ method: "DELETE", @@ -1207,19 +1237,19 @@ export function RepeatScreenModalComponent({ console.log(`[RepeatScreenModal] DELETE 성공: ${targetTable}, id=${targetRow._originalData.id}`); // 성공 시 UI에서 완전히 제거 - setExternalTableData((prev) => { - const newData = { - ...prev, + setExternalTableData((prev) => { + const newData = { + ...prev, [key]: prev[key].filter((row) => row._rowId !== rowId), - }; - + }; + // 행 삭제 시 집계 재계산 - setTimeout(() => { - recalculateAggregationsWithExternalData(newData); - }, 0); - - return newData; - }); + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); } catch (error: any) { console.error(`[RepeatScreenModal] DELETE 실패:`, error.response?.data || error.message); // 에러 시에도 다이얼로그 닫기 @@ -1253,16 +1283,14 @@ export function RepeatScreenModalComponent({ const newData = { ...prev, [key]: (prev[key] || []).map((row) => - row._rowId === rowId - ? { ...row, _isDeleted: false, _isDirty: true } - : row + row._rowId === rowId ? { ...row, _isDeleted: false, _isDirty: true } : row, ), }; - + setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); - + return newData; }); }; @@ -1272,11 +1300,7 @@ export function RepeatScreenModalComponent({ const key = `${cardId}-${contentRowId}`; setExternalTableData((prev) => ({ ...prev, - [key]: (prev[key] || []).map((row) => - row._rowId === rowId - ? { ...row, _isEditing: true } - : row - ), + [key]: (prev[key] || []).map((row) => (row._rowId === rowId ? { ...row, _isEditing: true } : row)), })); }; @@ -1287,39 +1311,45 @@ export function RepeatScreenModalComponent({ ...prev, [key]: (prev[key] || []).map((row) => row._rowId === rowId - ? { - ...row._originalData, - _rowId: row._rowId, + ? { + ...row._originalData, + _rowId: row._rowId, _originalData: row._originalData, - _isEditing: false, + _isEditing: false, _isDirty: false, _isNew: false, _isDeleted: false, } - : row + : row, ), })); }; // 🆕 v3.1: 외부 테이블 행 데이터 변경 - const handleExternalRowDataChange = (cardId: string, contentRowId: string, rowId: string, field: string, value: any) => { + const handleExternalRowDataChange = ( + cardId: string, + contentRowId: string, + rowId: string, + field: string, + value: any, + ) => { const key = `${cardId}-${contentRowId}`; - + // 데이터 업데이트 setExternalTableData((prev) => { const newData = { ...prev, [key]: (prev[key] || []).map((row) => - row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row, ), }; - + // 🆕 v3.5: 데이터 변경 시 집계 실시간 재계산 // setTimeout으로 비동기 처리하여 상태 업데이트 후 재계산 setTimeout(() => { recalculateAggregationsWithExternalData(newData); }, 0); - + return newData; }); }; @@ -1372,18 +1402,18 @@ export function RepeatScreenModalComponent({ if (groupingConfig.aggregations) { groupingConfig.aggregations.forEach((agg) => { const sourceType = agg.sourceType || "column"; - + if (sourceType === "column") { // 컬럼 집계 (기본 테이블만 - 외부 테이블은 나중에 처리) const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - + if (!isExternalTable) { // 기본 테이블 집계 aggregations[agg.resultField] = calculateColumnAggregation( - rows, - agg.sourceField || "", - agg.type || "sum" + rows, + agg.sourceField || "", + agg.type || "sum", ); } else { // 외부 테이블 집계는 나중에 계산 (placeholder) @@ -1398,7 +1428,7 @@ export function RepeatScreenModalComponent({ representativeData, rows, [], // 외부 테이블 데이터 없음 - aggregations // 이전 집계 결과 참조 + aggregations, // 이전 집계 결과 참조 ); } else { aggregations[agg.resultField] = 0; @@ -1427,9 +1457,9 @@ export function RepeatScreenModalComponent({ // 집계 계산 (컬럼 집계용) const calculateColumnAggregation = ( - rows: any[], - sourceField: string, - type: "sum" | "count" | "avg" | "min" | "max" + rows: any[], + sourceField: string, + type: "sum" | "count" | "avg" | "min" | "max", ): number => { const values = rows.map((row) => Number(row[sourceField]) || 0); @@ -1455,7 +1485,7 @@ export function RepeatScreenModalComponent({ cardRows: any[], // 기본 테이블 행들 externalRows: any[], // 외부 테이블 행들 previousAggregations: Record, // 이전 집계 결과들 - representativeData: Record // 카드 대표 데이터 + representativeData: Record, // 카드 대표 데이터 ): number => { const sourceType = agg.sourceType || "column"; @@ -1463,26 +1493,16 @@ export function RepeatScreenModalComponent({ // 컬럼 집계 const sourceTable = agg.sourceTable || dataSource?.sourceTable; const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; - + // 외부 테이블인 경우 externalRows 사용, 아니면 cardRows 사용 const targetRows = isExternalTable ? externalRows : cardRows; - - return calculateColumnAggregation( - targetRows, - agg.sourceField || "", - agg.type || "sum" - ); + + return calculateColumnAggregation(targetRows, agg.sourceField || "", agg.type || "sum"); } else if (sourceType === "formula") { // 가상 집계 (연산식) if (!agg.formula) return 0; - - return evaluateFormulaWithContext( - agg.formula, - representativeData, - cardRows, - externalRows, - previousAggregations - ); + + return evaluateFormulaWithContext(agg.formula, representativeData, cardRows, externalRows, previousAggregations); } return 0; @@ -1491,7 +1511,7 @@ export function RepeatScreenModalComponent({ // 🆕 v3.1: 집계 표시값 계산 (formula, external 등 지원) const calculateAggregationDisplayValue = ( aggField: AggregationDisplayConfig, - card: GroupedCardData + card: GroupedCardData, ): number | string => { const sourceType = aggField.sourceType || "aggregation"; @@ -1526,7 +1546,7 @@ export function RepeatScreenModalComponent({ representativeData: Record, cardRows: any[], // 기본 테이블 행들 externalRows: any[], // 외부 테이블 행들 - previousAggregations: Record // 이전 집계 결과들 + previousAggregations: Record, // 이전 집계 결과들 ): number => { try { let expression = formula; @@ -1615,11 +1635,7 @@ export function RepeatScreenModalComponent({ }; // 레거시 호환: 기존 evaluateFormula 유지 - const evaluateFormula = ( - formula: string, - representativeData: Record, - rows?: any[] - ): number => { + const evaluateFormula = (formula: string, representativeData: Record, rows?: any[]): number => { return evaluateFormulaWithContext(formula, representativeData, rows || [], [], {}); }; @@ -1647,7 +1663,7 @@ export function RepeatScreenModalComponent({ } } } - + // 테이블 타입의 컬럼 처리 if (contentRow.type === "table" && contentRow.tableColumns) { for (const col of contentRow.tableColumns) { @@ -1680,7 +1696,7 @@ export function RepeatScreenModalComponent({ // Simple 모드: 카드 데이터 변경 const handleCardDataChange = (cardId: string, field: string, value: any) => { setCardsData((prev) => - prev.map((card) => (card._cardId === cardId ? { ...card, [field]: value, _isDirty: true } : card)) + prev.map((card) => (card._cardId === cardId ? { ...card, [field]: value, _isDirty: true } : card)), ); }; @@ -1691,7 +1707,7 @@ export function RepeatScreenModalComponent({ if (card._cardId !== cardId) return card; const updatedRows = card._rows.map((row) => - row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row, ); // 집계값 재계산 @@ -1707,7 +1723,7 @@ export function RepeatScreenModalComponent({ _rows: updatedRows, _aggregations: newAggregations, }; - }) + }), ); }; @@ -1763,7 +1779,7 @@ export function RepeatScreenModalComponent({ // key 형식: cardId-contentRowId const [cardId, contentRowId] = key.split("-").slice(0, 2); const contentRow = contentRows.find((r) => r.id === contentRowId || key.includes(r.id)); - + if (!contentRow?.tableDataSource?.enabled) continue; const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; @@ -1774,13 +1790,13 @@ export function RepeatScreenModalComponent({ if (_isNew) { // INSERT - savePromises.push( - apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {}) - ); + savePromises.push(apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {})); } else if (_originalData?.id) { // UPDATE savePromises.push( - apiClient.put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave).then(() => {}) + apiClient + .put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave) + .then(() => {}), ); } } @@ -1796,7 +1812,13 @@ export function RepeatScreenModalComponent({ ...row, _isDirty: false, _isNew: false, - _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined }, + _originalData: { + ...row, + _rowId: undefined, + _originalData: undefined, + _isDirty: undefined, + _isNew: undefined, + }, })); } return updated; @@ -1837,9 +1859,7 @@ export function RepeatScreenModalComponent({ // 각 조인 키별로 집계 계산 및 업데이트 for (const keyValue of joinKeyValues) { // 해당 조인 키에 해당하는 행들만 필터링 - const filteredRows = rows.filter( - (row) => row[syncSave.joinKey.sourceField] === keyValue - ); + const filteredRows = rows.filter((row) => row[syncSave.joinKey.sourceField] === keyValue); // 집계 계산 let aggregatedValue: number = 0; @@ -1866,12 +1886,15 @@ export function RepeatScreenModalComponent({ break; } - console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, { - joinKey: keyValue, - aggregationType: syncSave.aggregationType, - values, - aggregatedValue, - }); + console.log( + `[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, + { + joinKey: keyValue, + aggregationType: syncSave.aggregationType, + values, + aggregatedValue, + }, + ); // 대상 테이블 업데이트 syncPromises.push( @@ -1880,12 +1903,14 @@ export function RepeatScreenModalComponent({ [syncSave.targetColumn]: aggregatedValue, }) .then(() => { - console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`); + console.log( + `[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`, + ); }) .catch((err) => { console.error(`[SyncSave] 업데이트 실패:`, err); throw err; - }) + }), ); } } @@ -1930,7 +1955,7 @@ export function RepeatScreenModalComponent({ config: btn.customAction.config, componentId: component?.id, }, - }) + }), ); } break; @@ -2031,7 +2056,7 @@ export function RepeatScreenModalComponent({ prev.map((card) => ({ ...card, _rows: card._rows.map((row) => ({ ...row, _isDirty: false })), - })) + })), ); }; @@ -2048,7 +2073,7 @@ export function RepeatScreenModalComponent({ } else { await apiClient.post(`/table-management/tables/${tableName}/data`, dataToSave); } - }) + }), ); }); @@ -2066,9 +2091,7 @@ export function RepeatScreenModalComponent({ } // 🆕 v3.1: 외부 테이블 데이터 수정 여부 - const hasExternalDirty = Object.values(externalTableData).some((rows) => - rows.some((row) => row._isDirty) - ); + const hasExternalDirty = Object.values(externalTableData).some((rows) => rows.some((row) => row._isDirty)); return hasBaseDirty || hasExternalDirty; }, [cardMode, cardsData, groupedCardsData, externalTableData]); @@ -2086,25 +2109,25 @@ export function RepeatScreenModalComponent({ return (
-
+
{/* 아이콘 */} -
- +
+
{/* 제목 */} -
-
Repeat Screen Modal
-
반복 화면 모달
+
+
Repeat Screen Modal
+
반복 화면 모달
v3 자유 레이아웃
{/* 행 구성 정보 */} -
+
{contentRows.length > 0 ? ( <> {rowTypeCounts.header > 0 && ( @@ -2136,24 +2159,24 @@ export function RepeatScreenModalComponent({ {/* 통계 정보 */}
-
{contentRows.length}
-
행 (Rows)
+
{contentRows.length}
+
행 (Rows)
-
+
-
{grouping?.aggregations?.length || 0}
-
집계 설정
+
{grouping?.aggregations?.length || 0}
+
집계 설정
-
+
-
{dataSource?.sourceTable ? 1 : 0}
-
데이터 소스
+
{dataSource?.sourceTable ? 1 : 0}
+
데이터 소스
{/* 데이터 소스 정보 */} {dataSource?.sourceTable && ( -
+
소스 테이블: {dataSource.sourceTable} {dataSource.filterField && (필터: {dataSource.filterField})}
@@ -2161,20 +2184,20 @@ export function RepeatScreenModalComponent({ {/* 그룹핑 정보 */} {grouping?.enabled && ( -
+
그룹핑: {grouping.groupByField}
)} {/* 카드 제목 정보 */} {showCardTitle && cardTitle && ( -
+
카드 제목: {cardTitle}
)} {/* 설정 안내 */} -
+
오른쪽 패널에서 행을 추가하고 타입(헤더/집계/테이블/필드)을 선택하세요
@@ -2186,8 +2209,8 @@ export function RepeatScreenModalComponent({ if (isLoading) { return (
- - 데이터를 불러오는 중... + + 데이터를 불러오는 중...
); } @@ -2195,12 +2218,12 @@ export function RepeatScreenModalComponent({ // 오류 상태 if (loadError) { return ( -
-
+
+
데이터 로드 실패
-

{loadError}

+

{loadError}

); } @@ -2213,23 +2236,23 @@ export function RepeatScreenModalComponent({ if (useGrouping) { return (
-
+
{groupedCardsData.map((card, cardIndex) => ( r._isDirty) && "border-primary shadow-lg" + card._rows.some((r) => r._isDirty) && "border-primary shadow-lg", )} > {/* 카드 제목 (선택사항) */} {showCardTitle && ( - + {getCardTitle(card._representativeData, cardIndex)} {card._rows.some((r) => r._isDirty) && ( - + 수정됨 )} @@ -2243,10 +2266,10 @@ export function RepeatScreenModalComponent({
{contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( // 🆕 v3.1: 외부 테이블 데이터 소스 사용 -
+
{/* 테이블 헤더 영역: 제목 + 버튼들 */} {(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && ( -
+
{contentRow.tableTitle || ""}
{/* 추가 버튼 */} @@ -2255,7 +2278,7 @@ export function RepeatScreenModalComponent({ variant="outline" size="sm" onClick={() => handleAddExternalRow(card._cardId, contentRow.id, contentRow)} - className="h-7 text-xs gap-1" + className="h-7 gap-1 text-xs" > 추가 @@ -2269,15 +2292,17 @@ export function RepeatScreenModalComponent({ {/* 🆕 v3.13: hidden 컬럼 필터링 */} - {(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => ( - - {col.label} - - ))} + {(contentRow.tableColumns || []) + .filter((col) => !col.hidden) + .map((col) => ( + + {col.label} + + ))} {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && ( 작업 )} @@ -2288,8 +2313,11 @@ export function RepeatScreenModalComponent({ {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)} - className="text-center py-8 text-muted-foreground" + colSpan={ + (contentRow.tableColumns?.filter((col) => !col.hidden)?.length || 0) + + (contentRow.tableCrud?.allowDelete ? 1 : 0) + } + className="text-muted-foreground py-8 text-center" > 데이터가 없습니다. @@ -2299,64 +2327,82 @@ export function RepeatScreenModalComponent({ {/* 🆕 v3.13: hidden 컬럼 필터링 */} - {(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => ( - - {renderTableCell( - col, - row, - (value) => handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value), - row._isNew || row._isEditing // 신규 행이거나 수정 모드일 때만 편집 가능 - )} - - ))} + {(contentRow.tableColumns || []) + .filter((col) => !col.hidden) + .map((col) => ( + + {renderTableCell( + col, + row, + (value) => + handleExternalRowDataChange( + card._cardId, + contentRow.id, + row._rowId, + col.field, + value, + ), + row._isNew || row._isEditing, // 신규 행이거나 수정 모드일 때만 편집 가능 + )} + + ))} {(contentRow.tableCrud?.allowUpdate || contentRow.tableCrud?.allowDelete) && (
{/* 수정 버튼: 저장된 행(isNew가 아닌)이고 편집 모드가 아닐 때만 표시 */} - {contentRow.tableCrud?.allowUpdate && !row._isNew && !row._isEditing && !row._isDeleted && ( - - )} + {contentRow.tableCrud?.allowUpdate && + !row._isNew && + !row._isEditing && + !row._isDeleted && ( + + )} {/* 수정 취소 버튼: 편집 모드일 때만 표시 */} {row._isEditing && !row._isNew && ( )} {/* 삭제/복원 버튼 */} - {contentRow.tableCrud?.allowDelete && ( - row._isDeleted ? ( + {contentRow.tableCrud?.allowDelete && + (row._isDeleted ? ( - ) - )} + ))}
)} @@ -2392,16 +2444,16 @@ export function RepeatScreenModalComponent({ // 레거시: tableLayout 사용 <> {tableLayout?.headerRows && tableLayout.headerRows.length > 0 && ( -
+
{tableLayout.headerRows.map((row, rowIndex) => (
@@ -2416,7 +2468,7 @@ export function RepeatScreenModalComponent({ )} {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && ( -
+
@@ -2440,10 +2492,10 @@ export function RepeatScreenModalComponent({ className={cn("text-sm", col.align && `text-${col.align}`)} > {renderTableCell( - col, - row, + col, + row, (value) => handleRowDataChange(card._cardId, row._rowId, col.field, value), - row._isNew || row._isEditing + row._isNew || row._isEditing, )} ))} @@ -2465,11 +2517,11 @@ export function RepeatScreenModalComponent({
{footerConfig.buttons.map((btn) => ( @@ -2497,7 +2549,7 @@ export function RepeatScreenModalComponent({ {/* 데이터 없음 */} {groupedCardsData.length === 0 && !isLoading && ( -
표시할 데이터가 없습니다.
+
표시할 데이터가 없습니다.
)} {/* 🆕 v3.1: 삭제 확인 다이얼로그 */} @@ -2505,9 +2557,7 @@ export function RepeatScreenModalComponent({ 삭제 확인 - - 이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. - + 이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. 취소 @@ -2517,7 +2567,7 @@ export function RepeatScreenModalComponent({ handleDeleteExternalRow( pendingDeleteInfo.cardId, pendingDeleteInfo.rowId, - pendingDeleteInfo.contentRowId + pendingDeleteInfo.contentRowId, ); } }} @@ -2535,50 +2585,52 @@ export function RepeatScreenModalComponent({ // 단순 모드 렌더링 (그룹핑 없음) return (
-
+
{cardsData.map((card, cardIndex) => ( {/* 카드 제목 (선택사항) */} {showCardTitle && ( - + {getCardTitle(card, cardIndex)} - {card._isDirty && (수정됨)} + {card._isDirty && (수정됨)} )} {/* 🆕 v3: contentRows 기반 렌더링 */} - {useNewLayout ? ( - contentRows.map((contentRow, rowIndex) => ( -
- {renderSimpleContentRow(contentRow, card, (value, field) => - handleCardDataChange(card._cardId, field, value) - )} -
- )) - ) : ( - // 레거시: cardLayout 사용 - cardLayout.map((row, rowIndex) => ( -
- {row.columns.map((col, colIndex) => ( -
- {renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))} -
- ))} -
- )) - )} + {useNewLayout + ? contentRows.map((contentRow, rowIndex) => ( +
+ {renderSimpleContentRow(contentRow, card, (value, field) => + handleCardDataChange(card._cardId, field, value), + )} +
+ )) + : // 레거시: cardLayout 사용 + cardLayout.map((row, rowIndex) => ( +
+ {row.columns.map((col, colIndex) => ( +
+ {renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))} +
+ ))} +
+ ))}
))} @@ -2589,11 +2641,11 @@ export function RepeatScreenModalComponent({
{footerConfig.buttons.map((btn) => ( @@ -2621,7 +2673,7 @@ export function RepeatScreenModalComponent({ {/* 데이터 없음 */} {cardsData.length === 0 && !isLoading && ( -
표시할 데이터가 없습니다.
+
표시할 데이터가 없습니다.
)}
); @@ -2632,30 +2684,30 @@ function renderContentRow( contentRow: CardContentRowConfig, card: GroupedCardData, aggregations: AggregationConfig[], - onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void + onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void, ) { switch (contentRow.type) { case "header": case "fields": // contentRow에서 직접 columns 가져오기 (v3 구조) const headerColumns = contentRow.columns || []; - + if (headerColumns.length === 0) { return ( -
+
헤더 컬럼이 설정되지 않았습니다.
); } - + return (
@@ -2670,22 +2722,18 @@ function renderContentRow( case "aggregation": // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) const aggFields = contentRow.aggregationFields || []; - + if (aggFields.length === 0) { return ( -
+
집계 필드가 설정되지 않았습니다. (레이아웃 탭에서 집계 필드를 추가하세요)
); } - + return (
{aggFields.map((aggField, fieldIndex) => { // 집계 결과에서 값 가져오기 (aggregationResultField 사용) @@ -2694,16 +2742,16 @@ function renderContentRow(
-
{aggField.label || aggField.aggregationResultField}
+
{aggField.label || aggField.aggregationResultField}
{typeof value === "number" ? value.toLocaleString() : value || "-"} @@ -2717,21 +2765,19 @@ function renderContentRow( case "table": // contentRow에서 직접 tableColumns 가져오기 (v3 구조) const tableColumns = contentRow.tableColumns || []; - + if (tableColumns.length === 0) { return ( -
+
테이블 컬럼이 설정되지 않았습니다. (레이아웃 탭에서 테이블 컬럼을 추가하세요)
); } - + return ( -
+
{contentRow.tableTitle && ( -
- {contentRow.tableTitle} -
+
{contentRow.tableTitle}
)}
{contentRow.showTableHeader !== false && ( @@ -2758,10 +2804,10 @@ function renderContentRow( className={cn("text-sm", col.align && `text-${col.align}`)} > {renderTableCell( - col, - row, + col, + row, (value) => onRowDataChange(card._cardId, row._rowId, col.field, value), - row._isNew || row._isEditing + row._isNew || row._isEditing, )} ))} @@ -2781,7 +2827,7 @@ function renderContentRow( function renderSimpleContentRow( contentRow: CardContentRowConfig, card: CardData, - onChange: (value: any, field: string) => void + onChange: (value: any, field: string) => void, ) { switch (contentRow.type) { case "header": @@ -2790,10 +2836,10 @@ function renderSimpleContentRow(
@@ -2809,40 +2855,37 @@ function renderSimpleContentRow( // 단순 모드에서도 집계 표시 (단일 카드 기준) // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) const aggFields = contentRow.aggregationFields || []; - + if (aggFields.length === 0) { return ( -
+
집계 필드가 설정되지 않았습니다.
); } - + return (
{aggFields.map((aggField, fieldIndex) => { // 단순 모드에서는 카드 데이터에서 직접 값을 가져옴 (aggregationResultField 사용) - const value = card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField]; + const value = + card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField]; return (
-
{aggField.label || aggField.aggregationResultField}
+
{aggField.label || aggField.aggregationResultField}
{typeof value === "number" ? value.toLocaleString() : value || "-"} @@ -2857,21 +2900,19 @@ function renderSimpleContentRow( // 단순 모드에서도 테이블 표시 (단일 행) // contentRow에서 직접 tableColumns 가져오기 (v3 구조) const tableColumns = contentRow.tableColumns || []; - + if (tableColumns.length === 0) { return ( -
+
테이블 컬럼이 설정되지 않았습니다.
); } - + return ( -
+
{contentRow.tableTitle && ( -
- {contentRow.tableTitle} -
+
{contentRow.tableTitle}
)}
{contentRow.showTableHeader !== false && ( @@ -2912,11 +2953,7 @@ function renderSimpleContentRow( } // 단순 모드 테이블 셀 렌더링 -function renderSimpleTableCell( - col: TableColumnConfig, - card: CardData, - onChange: (value: any) => void -) { +function renderSimpleTableCell(col: TableColumnConfig, card: CardData, onChange: (value: any) => void) { const value = card[col.field] || card._originalData?.[col.field]; if (!col.editable) { @@ -2940,12 +2977,7 @@ function renderSimpleTableCell( ); case "date": return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> + onChange(e.target.value)} className="h-8 text-sm" /> ); case "select": return ( @@ -2964,12 +2996,7 @@ function renderSimpleTableCell( ); default: return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> + onChange(e.target.value)} className="h-8 text-sm" /> ); } } @@ -2986,11 +3013,7 @@ function getBackgroundClass(color: string): string { } // 헤더 컬럼 렌더링 (집계값 포함) -function renderHeaderColumn( - col: CardColumnConfig, - card: GroupedCardData, - aggregations: AggregationConfig[] -) { +function renderHeaderColumn(col: CardColumnConfig, card: GroupedCardData, aggregations: AggregationConfig[]) { let value: any; // 집계값 타입이면 집계 결과에서 가져옴 @@ -3000,16 +3023,16 @@ function renderHeaderColumn( return (
- +
{typeof value === "number" ? value.toLocaleString() : value || "-"} - {aggConfig && ({aggConfig.type})} + {aggConfig && ({aggConfig.type})}
); @@ -3020,13 +3043,9 @@ function renderHeaderColumn( return (
- +
{value || "-"}
@@ -3036,7 +3055,12 @@ function renderHeaderColumn( // 테이블 셀 렌더링 // 🆕 v3.8: isRowEditable 파라미터 추가 - 행이 편집 가능한 상태인지 (신규 행이거나 수정 모드) -function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void, isRowEditable?: boolean) { +function renderTableCell( + col: TableColumnConfig, + row: CardRowData, + onChange: (value: any) => void, + isRowEditable?: boolean, +) { const value = row[col.field]; // Badge 타입 @@ -3047,7 +3071,7 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va // 🆕 v3.8: 행 수준 편집 가능 여부 체크 // isRowEditable이 false이면 컬럼 설정과 관계없이 읽기 전용 - const canEdit = col.editable && (isRowEditable !== false); + const canEdit = col.editable && isRowEditable !== false; // 읽기 전용 if (!canEdit) { @@ -3056,7 +3080,11 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va } if (col.type === "date") { // ISO 8601 형식을 표시용으로 변환 - const displayDate = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : "-"; + const displayDate = value + ? typeof value === "string" && value.includes("T") + ? value.split("T")[0] + : value + : "-"; return {displayDate}; } return {value || "-"}; @@ -3065,33 +3093,20 @@ function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (va // 편집 가능 switch (col.type) { case "text": - return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> - ); + return onChange(e.target.value)} className="h-8 text-sm" />; case "number": return ( onChange(Number(e.target.value) || 0)} - className="h-8 text-sm text-right" + className="h-8 text-right text-sm" /> ); case "date": // ISO 8601 형식('2025-12-02T00:00:00.000Z')을 'YYYY-MM-DD' 형식으로 변환 - const dateValue = value ? (typeof value === 'string' && value.includes('T') ? value.split('T')[0] : value) : ""; - return ( - onChange(e.target.value)} - className="h-8 text-sm" - /> - ); + const dateValue = value ? (typeof value === "string" && value.includes("T") ? value.split("T")[0] : value) : ""; + return onChange(e.target.value)} className="h-8 text-sm" />; default: return {value || "-"}; } @@ -3110,7 +3125,7 @@ function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: a {isReadOnly && ( -
+
{value || "-"}
)} @@ -3139,7 +3154,7 @@ function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: a {col.type === "date" && ( onChange(e.target.value)} className="h-10 text-sm" /> @@ -3165,12 +3180,12 @@ function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: a value={value || ""} onChange={(e) => onChange(e.target.value)} placeholder={col.placeholder} - className="text-sm min-h-[80px]" + className="min-h-[80px] text-sm" /> )} {col.type === "component" && col.componentType && ( -
+
컴포넌트: {col.componentType} (개발 중)
)} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 0f5c851b..26acaf34 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -115,14 +115,14 @@ const CascadingSelectField: React.FC = ({ type="button" variant="ghost" size="sm" - className="absolute right-0 top-0 h-full px-2 hover:bg-transparent" + className="absolute top-0 right-0 h-full px-2 hover:bg-transparent" onClick={() => !isDisabled && setOpen(!open)} disabled={isDisabled} > {loading ? ( - + ) : ( - + )}
@@ -149,12 +149,7 @@ const CascadingSelectField: React.FC = ({ setOpen(false); }} > - + {option.label} ))} @@ -437,19 +432,19 @@ export function UniversalFormModalComponent({ } // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) - // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), + // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), // handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용 for (const [key, value] of Object.entries(formData)) { // 싱글/더블 언더스코어 모두 처리 if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) { // 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대) - const normalizedKey = key.startsWith("__tableSection_") - ? key.replace("__tableSection_", "_tableSection_") + const normalizedKey = key.startsWith("__tableSection_") + ? key.replace("__tableSection_", "_tableSection_") : key; event.detail.formData[normalizedKey] = value; console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`); } - + // 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용) if (key.startsWith("_originalTableSectionData_") && Array.isArray(value)) { event.detail.formData[key] = value; @@ -948,13 +943,17 @@ export function UniversalFormModalComponent({ // 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장) const originalTableSectionKey = `_originalTableSectionData_${section.id}`; newFormData[originalTableSectionKey] = JSON.parse(JSON.stringify(items)); - console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`); - + console.log( + `[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`, + ); + // 기존 originalGroupedData에도 추가 (하위 호환성) if (!groupedDataInitializedRef.current) { setOriginalGroupedData((prev) => { const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))]; - console.log(`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`); + console.log( + `[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`, + ); return newOriginal; }); } @@ -1639,12 +1638,12 @@ export function UniversalFormModalComponent({ /> ); } - + // 🆕 연쇄 드롭다운 처리 (selectOptions.type === "cascading" 방식) if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField) { const cascadingOpts = field.selectOptions.cascading; const parentValue = formData[cascadingOpts.parentField]; - + // selectOptions 기반 cascading config를 CascadingDropdownConfig 형태로 변환 const cascadingConfig: CascadingDropdownConfig = { enabled: true, @@ -2393,7 +2392,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [inputValue, setInputValue] = useState(value || ""); - + const allowCustomInput = optionConfig?.allowCustomInput || false; useEffect(() => { @@ -2433,14 +2432,14 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa type="button" variant="ghost" size="sm" - className="absolute right-0 top-0 h-full px-2 hover:bg-transparent" + className="absolute top-0 right-0 h-full px-2 hover:bg-transparent" onClick={() => !disabled && !loading && setOpen(!open)} disabled={disabled || loading} > {loading ? ( - + ) : ( - + )}
@@ -2463,12 +2462,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa setOpen(false); }} > - + {option.label} ))} diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index c656d8db..eba973e4 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -47,7 +47,7 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { const style = component.style || {}; const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; // labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김) - const effectiveLabel = labelDisplay === true ? (style.labelText || component.label) : undefined; + const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined; return ( setTimeout(resolve, 100)); - + // 🔧 디버그: beforeFormSave 이벤트 후 formData 확인 console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", { keys: Object.keys(context.formData || {}), @@ -1626,7 +1626,9 @@ export class ButtonActionExecutor { // saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... } const savedRecord = saveResult?.data?.data || saveResult?.data || {}; const actualFormData = savedRecord?.data || savedRecord; - const formData: Record = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}) as Record; + const formData: Record = ( + Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {} + ) as Record; console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord)); console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData)); console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun); @@ -2924,8 +2926,7 @@ export class ButtonActionExecutor { if (v2ListComponent) { dataSourceId = - v2ListComponent.componentConfig.dataSource?.table || - v2ListComponent.componentConfig.tableName; + v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.tableName; console.log("✨ V2List 자동 감지:", { componentId: v2ListComponent.id, tableName: dataSourceId, @@ -3061,7 +3062,7 @@ export class ButtonActionExecutor { // 🔧 수정: openModalWithData는 "신규 등록 + 연결 데이터 전달"용이므로 // editData가 아닌 splitPanelParentData로 전달해야 채번 등이 정상 작동함 const isPassDataMode = passSelectedData && selectedData.length > 0; - + // 🔧 isEditMode 옵션이 명시적으로 true인 경우에만 수정 모드로 처리 const useAsEditData = config.isEditMode === true;