refactor: 코드 정리 및 가독성 향상

- numberingRuleController.ts에서 API 엔드포인트의 코드 스타일을 일관되게 정리하여 가독성을 높였습니다.
- 불필요한 줄바꿈을 제거하고, 코드 블록을 명확하게 정리하여 유지보수성을 개선했습니다.
- tableManagementService.ts와 ButtonConfigPanel.tsx에서 코드 정리를 통해 일관성을 유지하고, 가독성을 향상시켰습니다.
- 전반적으로 코드의 깔끔함을 유지하고, 향후 개발 시 이해하기 쉽게 개선했습니다.
This commit is contained in:
kjs 2026-02-05 17:38:06 +09:00
parent 73d05b991c
commit e31bb970a2
11 changed files with 1570 additions and 1348 deletions

View File

@ -3,392 +3,545 @@
*/ */
import { Router, Response } from "express"; import { Router, Response } from "express";
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware"; import {
authenticateToken,
AuthenticatedRequest,
} from "../middleware/authMiddleware";
import { numberingRuleService } from "../services/numberingRuleService"; import { numberingRuleService } from "../services/numberingRuleService";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
const router = Router(); const router = Router();
// 규칙 목록 조회 (전체) // 규칙 목록 조회 (전체)
router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { router.get(
const companyCode = req.user!.companyCode; "/",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
try { try {
const rules = await numberingRuleService.getRuleList(companyCode); const rules = await numberingRuleService.getRuleList(companyCode);
return res.json({ success: true, data: rules }); return res.json({ success: true, data: rules });
} catch (error: any) { } catch (error: any) {
logger.error("규칙 목록 조회 실패", { error: error.message }); logger.error("규칙 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message }); return res.status(500).json({ success: false, error: error.message });
}
} }
}); );
// 메뉴별 사용 가능한 규칙 조회 // 메뉴별 사용 가능한 규칙 조회
router.get("/available/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { router.get(
const companyCode = req.user!.companyCode; "/available/:menuObjid?",
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; 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 { try {
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid); const rules = await numberingRuleService.getAvailableRulesForMenu(
companyCode,
menuObjid
);
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", { logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
companyCode, companyCode,
menuObjid, menuObjid,
rulesCount: rules.length rulesCount: rules.length,
}); });
return res.json({ success: true, data: rules }); return res.json({ success: true, data: rules });
} catch (error: any) { } catch (error: any) {
logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", { logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", {
error: error.message, error: error.message,
errorCode: error.code, errorCode: error.code,
errorStack: error.stack, errorStack: error.stack,
companyCode, companyCode,
menuObjid, menuObjid,
}); });
return res.status(500).json({ success: false, error: error.message }); return res.status(500).json({ success: false, error: error.message });
}
} }
}); );
// 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화) // 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
router.get("/available-for-screen", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { router.get(
const companyCode = req.user!.companyCode; "/available-for-screen",
const { tableName } = req.query; authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName } = req.query;
try { try {
// tableName 필수 검증 // tableName 필수 검증
if (!tableName || typeof tableName !== "string") { 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() === "") {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다", 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,
});
} }
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.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) => { router.put(
const companyCode = req.user!.companyCode; "/:ruleId",
const { ruleId } = req.params; authenticateToken,
const updates = req.body; 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 { try {
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode); const updatedRule = await numberingRuleService.updateRule(
logger.info("채번 규칙 수정 성공", { ruleId, companyCode }); ruleId,
return res.json({ success: true, data: updatedRule }); updates,
} catch (error: any) { companyCode
logger.error("채번 규칙 수정 실패", { );
ruleId, logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
companyCode, return res.json({ success: true, data: updatedRule });
error: error.message, } catch (error: any) {
stack: error.stack logger.error("채번 규칙 수정 실패", {
}); ruleId,
if (error.message.includes("찾을 수 없거나")) { companyCode,
return res.status(404).json({ success: false, error: error.message }); 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) => { router.delete(
const companyCode = req.user!.companyCode; "/:ruleId",
const { ruleId } = req.params; authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try { try {
await numberingRuleService.deleteRule(ruleId, companyCode); await numberingRuleService.deleteRule(ruleId, companyCode);
return res.json({ success: true, message: "규칙이 삭제되었습니다" }); return res.json({ success: true, message: "규칙이 삭제되었습니다" });
} catch (error: any) { } catch (error: any) {
if (error.message.includes("찾을 수 없거나")) { if (error.message.includes("찾을 수 없거나")) {
return res.status(404).json({ success: false, error: error.message }); 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) => { router.post(
const companyCode = req.user!.companyCode; "/:ruleId/preview",
const { ruleId } = req.params; authenticateToken,
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
try { try {
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData); const previewCode = await numberingRuleService.previewCode(
return res.json({ success: true, data: { generatedCode: previewCode } }); ruleId,
} catch (error: any) { companyCode,
logger.error("코드 미리보기 실패", { error: error.message }); formData
return res.status(500).json({ success: false, error: error.message }); );
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) => { router.post(
const companyCode = req.user!.companyCode; "/:ruleId/allocate",
const { ruleId } = req.params; authenticateToken,
const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드 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 { try {
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData, userInputCode); const allocatedCode = await numberingRuleService.allocateCode(
logger.info("코드 할당 성공", { ruleId, allocatedCode }); ruleId,
return res.json({ success: true, data: { generatedCode: allocatedCode } }); companyCode,
} catch (error: any) { formData,
logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message }); userInputCode
return res.status(500).json({ success: false, error: error.message }); );
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) // 코드 생성 (기존 호환성 유지, deprecated)
router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { router.post(
const companyCode = req.user!.companyCode; "/:ruleId/generate",
const { ruleId } = req.params; authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try { try {
const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode); const generatedCode = await numberingRuleService.generateCode(
return res.json({ success: true, data: { generatedCode } }); ruleId,
} catch (error: any) { companyCode
logger.error("코드 생성 실패", { error: error.message }); );
return res.status(500).json({ success: false, error: error.message }); 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) => { router.post(
const companyCode = req.user!.companyCode; "/:ruleId/reset",
const { ruleId } = req.params; authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try { try {
await numberingRuleService.resetSequence(ruleId, companyCode); await numberingRuleService.resetSequence(ruleId, companyCode);
return res.json({ success: true, message: "시퀀스가 초기화되었습니다" }); return res.json({ success: true, message: "시퀀스가 초기화되었습니다" });
} catch (error: any) { } catch (error: any) {
logger.error("시퀀스 초기화 실패", { error: error.message }); logger.error("시퀀스 초기화 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message }); return res.status(500).json({ success: false, error: error.message });
}
} }
}); );
// ==================== 테스트 테이블용 API ==================== // ==================== 테스트 테이블용 API ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회 // [테스트] 테스트 테이블에서 채번 규칙 목록 조회
router.get("/test/list/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { router.get(
const companyCode = req.user!.companyCode; "/test/list/:menuObjid?",
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; 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 { try {
const rules = await numberingRuleService.getRulesFromTest(companyCode, menuObjid); const rules = await numberingRuleService.getRulesFromTest(
logger.info("[테스트] 채번 규칙 목록 조회 성공", { companyCode, menuObjid, count: rules.length }); companyCode,
return res.json({ success: true, data: rules }); menuObjid
} catch (error: any) { );
logger.error("[테스트] 채번 규칙 목록 조회 실패", { error: error.message }); logger.info("[테스트] 채번 규칙 목록 조회 성공", {
return res.status(500).json({ success: false, error: error.message }); 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) => { router.get(
const companyCode = req.user!.companyCode; "/test/by-column/:tableName/:columnName",
const { tableName, columnName } = req.params; authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
try { try {
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, columnName); const rule = await numberingRuleService.getNumberingRuleByColumn(
return res.json({ success: true, data: rule }); companyCode,
} catch (error: any) { tableName,
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message }); columnName
return res.status(500).json({ success: false, error: error.message }); );
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) => { router.post(
const companyCode = req.user!.companyCode; "/test/save",
const userId = req.user!.userId; authenticateToken,
const ruleConfig = req.body; async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const ruleConfig = req.body;
logger.info("[테스트] 채번 규칙 저장 요청", { logger.info("[테스트] 채번 규칙 저장 요청", {
ruleId: ruleConfig.ruleId, ruleId: ruleConfig.ruleId,
ruleName: ruleConfig.ruleName, ruleName: ruleConfig.ruleName,
tableName: ruleConfig.tableName || "(미지정)", tableName: ruleConfig.tableName || "(미지정)",
columnName: ruleConfig.columnName || "(미지정)", columnName: ruleConfig.columnName || "(미지정)",
}); });
try { try {
// ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결) // ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결)
if (!ruleConfig.ruleName) { if (!ruleConfig.ruleName) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "ruleName is required" 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) => { router.delete(
const companyCode = req.user!.companyCode; "/test/:ruleId",
const { ruleId } = req.params; authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try { try {
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다" }); return res.json({
} catch (error: any) { success: true,
logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message }); message: "테스트 채번 규칙이 삭제되었습니다",
return res.status(500).json({ success: false, error: error.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) => { router.post(
const companyCode = req.user!.companyCode; "/test/:ruleId/preview",
const { ruleId } = req.params; authenticateToken,
const { formData } = req.body; async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body;
try { try {
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData); const previewCode = await numberingRuleService.previewCode(
return res.json({ success: true, data: { generatedCode: previewCode } }); ruleId,
} catch (error: any) { companyCode,
logger.error("[테스트] 코드 미리보기 실패", { error: error.message }); formData
return res.status(500).json({ success: false, error: error.message }); );
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 ==================== // ==================== 회사별 채번규칙 복제 API ====================
// 회사별 채번규칙 복제 // 회사별 채번규칙 복제
router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { router.post(
const userCompanyCode = req.user!.companyCode; "/copy-for-company",
const { sourceCompanyCode, targetCompanyCode } = req.body; authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const userCompanyCode = req.user!.companyCode;
const { sourceCompanyCode, targetCompanyCode } = req.body;
// 최고 관리자만 사용 가능 // 최고 관리자만 사용 가능
if (userCompanyCode !== "*") { if (userCompanyCode !== "*") {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: "최고 관리자만 사용할 수 있습니다" error: "최고 관리자만 사용할 수 있습니다",
}); });
} }
if (!sourceCompanyCode || !targetCompanyCode) { if (!sourceCompanyCode || !targetCompanyCode) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다" error: "sourceCompanyCode와 targetCompanyCode가 필요합니다",
}); });
} }
try { try {
const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode); const result = await numberingRuleService.copyRulesForCompany(
return res.json({ success: true, data: result }); sourceCompanyCode,
} catch (error: any) { targetCompanyCode
logger.error("회사별 채번규칙 복제 실패", { error: error.message }); );
return res.status(500).json({ success: false, error: error.message }); 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; export default router;

View File

@ -322,7 +322,9 @@ export class TableManagementService {
}); });
} else { } else {
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행 // menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"); logger.info(
"⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"
);
} }
} catch (mappingError: any) { } catch (mappingError: any) {
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", { logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
@ -488,7 +490,10 @@ export class TableManagementService {
// table_type_columns에 모든 설정 저장 (멀티테넌시 지원) // table_type_columns에 모든 설정 저장 (멀티테넌시 지원)
// detailSettings가 문자열이면 그대로, 객체면 JSON.stringify // detailSettings가 문자열이면 그대로, 객체면 JSON.stringify
let detailSettingsStr = settings.detailSettings; 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); detailSettingsStr = JSON.stringify(settings.detailSettings);
} }
@ -749,7 +754,8 @@ export class TableManagementService {
); );
// 웹 타입별 기본 상세 설정 생성 // 웹 타입별 기본 상세 설정 생성
const defaultDetailSettings = this.generateDefaultDetailSettings(finalWebType); const defaultDetailSettings =
this.generateDefaultDetailSettings(finalWebType);
// 사용자 정의 설정과 기본 설정 병합 // 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = { const finalDetailSettings = {
@ -768,7 +774,12 @@ export class TableManagementService {
input_type = EXCLUDED.input_type, input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings, detail_settings = EXCLUDED.detail_settings,
updated_date = NOW()`, updated_date = NOW()`,
[tableName, columnName, finalWebType, JSON.stringify(finalDetailSettings)] [
tableName,
columnName,
finalWebType,
JSON.stringify(finalDetailSettings),
]
); );
logger.info( logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}` `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}`
@ -1473,7 +1484,11 @@ export class TableManagementService {
columnInfo && columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime") (columnInfo.webType === "date" || columnInfo.webType === "datetime")
) { ) {
return this.buildDateRangeCondition(columnName, actualValue, paramIndex); return this.buildDateRangeCondition(
columnName,
actualValue,
paramIndex
);
} }
// 그 외 타입이면 다중선택(IN 조건)으로 처리 // 그 외 타입이면 다중선택(IN 조건)으로 처리

View File

@ -51,11 +51,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}) => { }) => {
// 🔧 component가 없는 경우 방어 처리 // 🔧 component가 없는 경우 방어 처리
if (!component) { if (!component) {
return ( return <div className="text-muted-foreground p-4 text-sm"> .</div>;
<div className="p-4 text-sm text-muted-foreground">
.
</div>
);
} }
// 🔧 component에서 직접 읽기 (useMemo 제거) // 🔧 component에서 직접 읽기 (useMemo 제거)
@ -122,7 +118,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null); const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null);
const [modalActionSourceColumns, setModalActionSourceColumns] = useState<Array<{ name: string; label: string }>>([]); const [modalActionSourceColumns, setModalActionSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalActionTargetColumns, setModalActionTargetColumns] = useState<Array<{ name: string; label: string }>>([]); const [modalActionTargetColumns, setModalActionTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<Array<{ sourceField: string; targetField: string }>>([]); const [modalActionFieldMappings, setModalActionFieldMappings] = useState<
Array<{ sourceField: string; targetField: string }>
>([]);
const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState<Record<number, boolean>>({}); const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState<Record<number, boolean>>({});
const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({}); const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({});
const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({}); const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({});
@ -463,8 +461,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}; };
loadModalActionMappingData(); loadModalActionMappingData();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]); }, [
config.action?.type,
config.action?.autoDetectDataSource,
config.action?.targetScreenId,
currentTableName,
allComponents,
]);
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용) // 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
useEffect(() => { useEffect(() => {
@ -985,10 +989,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}} }}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<Label htmlFor="auto-detect-data-source" className="text-sm cursor-pointer"> <Label htmlFor="auto-detect-data-source" className="cursor-pointer text-sm">
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
TableList/SplitPanel에서 TableList/SplitPanel에서
</p> </p>
</div> </div>
@ -996,11 +1000,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */} {/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */}
{component.componentConfig?.action?.autoDetectDataSource === true && ( {component.componentConfig?.action?.autoDetectDataSource === true && (
<div className="mt-4 space-y-3 rounded-lg border bg-background p-3"> <div className="bg-background mt-4 space-y-3 rounded-lg border p-3">
{/* 테이블 정보 표시 */} {/* 테이블 정보 표시 */}
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="h-3 w-3 text-muted-foreground" /> <Database className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">:</span> <span className="text-muted-foreground">:</span>
<span className="font-medium">{modalActionSourceTable || "감지 중..."}</span> <span className="font-medium">{modalActionSourceTable || "감지 중..."}</span>
</div> </div>
@ -1012,171 +1016,210 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
{/* 테이블이 같으면 자동 매핑 안내 */} {/* 테이블이 같으면 자동 매핑 안내 */}
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && ( {modalActionSourceTable &&
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400"> modalActionTargetTable &&
. . modalActionSourceTable === modalActionTargetTable && (
</div> <div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
)} . .
</div>
)}
{/* 테이블이 다르면 필드 매핑 UI 표시 */} {/* 테이블이 다르면 필드 매핑 UI 표시 */}
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && ( {modalActionSourceTable &&
<div className="space-y-2"> modalActionTargetTable &&
<div className="flex items-center justify-between"> modalActionSourceTable !== modalActionTargetTable && (
<Label className="text-xs font-medium"> </Label> <div className="space-y-2">
<Button <div className="flex items-center justify-between">
type="button" <Label className="text-xs font-medium"> </Label>
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => {
const newMappings = [...(component.componentConfig?.action?.fieldMappings || []), { sourceField: "", targetField: "" }];
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
<p className="text-xs text-muted-foreground">
. .
</p>
)}
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2">
{/* 소스 필드 선택 */}
<Popover
open={modalFieldMappingSourceOpen[index] || false}
onOpenChange={(open) => setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.sourceField
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
: "소스 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingSourceSearch[index] || ""}
onValueChange={(val) => setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionSourceColumns
.filter((col) =>
col.name.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
col.label.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
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 }));
}}
>
<Check
className={cn("mr-2 h-4 w-4", mapping.sourceField === col.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground"></span>
{/* 대상 필드 선택 */}
<Popover
open={modalFieldMappingTargetOpen[index] || false}
onOpenChange={(open) => setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.targetField
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
: "대상 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingTargetSearch[index] || ""}
onValueChange={(val) => setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionTargetColumns
.filter((col) =>
col.name.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
col.label.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
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 }));
}}
>
<Check
className={cn("mr-2 h-4 w-4", mapping.targetField === col.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 삭제 버튼 */}
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
size="sm" size="sm"
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10" className="h-6 text-xs"
onClick={() => { onClick={() => {
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter((_: any, i: number) => i !== index); const newMappings = [
...(component.componentConfig?.action?.fieldMappings || []),
{ sourceField: "", targetField: "" },
];
setModalActionFieldMappings(newMappings); setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings); onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}} }}
> >
<X className="h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
</Button> </Button>
</div> </div>
))}
</div> {(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
)} <p className="text-muted-foreground text-xs">
. .
</p>
)}
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2">
{/* 소스 필드 선택 */}
<Popover
open={modalFieldMappingSourceOpen[index] || false}
onOpenChange={(open) =>
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.sourceField
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label ||
mapping.sourceField
: "소스 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingSourceSearch[index] || ""}
onValueChange={(val) =>
setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))
}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionSourceColumns
.filter(
(col) =>
col.name
.toLowerCase()
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
col.label
.toLowerCase()
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()),
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
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 }));
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.sourceField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-muted-foreground text-[10px]">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<span className="text-muted-foreground text-xs"></span>
{/* 대상 필드 선택 */}
<Popover
open={modalFieldMappingTargetOpen[index] || false}
onOpenChange={(open) =>
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.targetField
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label ||
mapping.targetField
: "대상 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingTargetSearch[index] || ""}
onValueChange={(val) =>
setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))
}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionTargetColumns
.filter(
(col) =>
col.name
.toLowerCase()
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
col.label
.toLowerCase()
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()),
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
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 }));
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.targetField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-muted-foreground text-[10px]">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0"
onClick={() => {
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter(
(_: any, i: number) => i !== index,
);
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -1185,9 +1228,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */} {/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */}
{component.componentConfig?.action?.type === "openModalWithData" && ( {component.componentConfig?.action?.type === "openModalWithData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-amber-50 p-4 dark:bg-amber-950/20"> <div className="mt-4 space-y-4 rounded-lg border bg-amber-50 p-4 dark:bg-amber-950/20">
<h4 className="text-sm font-medium text-foreground"> + </h4> <h4 className="text-foreground text-sm font-medium"> + </h4>
<p className="text-xs text-amber-600 dark:text-amber-400"> <p className="text-xs text-amber-600 dark:text-amber-400">
"모달 열기" . "모달 열기" + "선택된 데이터 전달" . "모달 열기" . "모달 열기" + "선택된 데이터 전달"
.
</p> </p>
{/* 🆕 블록 기반 제목 빌더 */} {/* 🆕 블록 기반 제목 빌더 */}
@ -3546,8 +3590,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4"> <div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4> <h4 className="text-foreground text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
V2 . V2 .
. .
</p> </p>
<div> <div>
@ -3597,11 +3641,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
type="number" type="number"
className="h-8 text-xs" className="h-8 text-xs"
placeholder="3" placeholder="3"
value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3} value={
component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3
}
onChange={(e) => { onChange={(e) => {
onUpdateProperty( onUpdateProperty(
"componentConfig.action.eventConfig.eventPayload.config.scheduling.leadTimeDays", "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<ButtonConfigPanelProps> = ({
type="number" type="number"
className="h-8 text-xs" className="h-8 text-xs"
placeholder="100" placeholder="100"
value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.maxDailyCapacity || 100} value={
component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling
?.maxDailyCapacity || 100
}
onChange={(e) => { onChange={(e) => {
onUpdateProperty( onUpdateProperty(
"componentConfig.action.eventConfig.eventPayload.config.scheduling.maxDailyCapacity", "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<ButtonConfigPanelProps> = ({
<div className="rounded-md bg-blue-50 p-2 dark:bg-blue-950/20"> <div className="rounded-md bg-blue-50 p-2 dark:bg-blue-950/20">
<p className="text-xs text-blue-800 dark:text-blue-200"> <p className="text-xs text-blue-800 dark:text-blue-200">
<strong> :</strong> . <strong> :</strong> .
. .
</p> </p>
</div> </div>
</div> </div>

View File

@ -109,18 +109,23 @@ export function TabsWidget({
for (const tab of visibleTabs) { for (const tab of visibleTabs) {
const extTab = tab as ExtendedTabItem; const extTab = tab as ExtendedTabItem;
// screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드 // screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드
if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) { if (
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true })); extTab.screenId &&
!screenLayouts[tab.id] &&
!screenLoadingStates[tab.id] &&
(!extTab.components || extTab.components.length === 0)
) {
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
try { try {
const layoutData = await screenApi.getLayout(extTab.screenId); const layoutData = await screenApi.getLayout(extTab.screenId);
if (layoutData && layoutData.components) { if (layoutData && layoutData.components) {
setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components })); setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
} }
} catch (error) { } catch (error) {
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error); console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
} finally { } finally {
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false })); setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: false }));
} }
} }
} }
@ -180,11 +185,7 @@ export function TabsWidget({
const getTabsListClass = () => { const getTabsListClass = () => {
const baseClass = orientation === "vertical" ? "flex-col" : ""; const baseClass = orientation === "vertical" ? "flex-col" : "";
const variantClass = const variantClass =
variant === "pills" variant === "pills" ? "bg-muted p-1 rounded-lg" : variant === "underline" ? "border-b" : "bg-muted p-1";
? "bg-muted p-1 rounded-lg"
: variant === "underline"
? "border-b"
: "bg-muted p-1";
return `${baseClass} ${variantClass}`; return `${baseClass} ${variantClass}`;
}; };
@ -199,8 +200,8 @@ export function TabsWidget({
if (screenLoadingStates[tab.id]) { if (screenLoadingStates[tab.id]) {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
<span className="ml-2 text-muted-foreground"> ...</span> <span className="text-muted-foreground ml-2"> ...</span>
</div> </div>
); );
} }
@ -208,7 +209,7 @@ export function TabsWidget({
// 에러 발생 // 에러 발생
if (screenErrors[tab.id]) { if (screenErrors[tab.id]) {
return ( return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-destructive/50 bg-destructive/5"> <div className="border-destructive/50 bg-destructive/5 flex h-full w-full items-center justify-center rounded border-2 border-dashed">
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p> <p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
</div> </div>
); );
@ -223,7 +224,7 @@ export function TabsWidget({
// 아직 로드되지 않은 경우 // 아직 로드되지 않은 경우
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
</div> </div>
); );
} }
@ -246,17 +247,12 @@ export function TabsWidget({
// screenId로 로드한 화면 컴포넌트 렌더링 // screenId로 로드한 화면 컴포넌트 렌더링
const renderScreenComponents = (components: ComponentData[]) => { const renderScreenComponents = (components: ComponentData[]) => {
// InteractiveScreenViewerDynamic 동적 로드 // InteractiveScreenViewerDynamic 동적 로드
const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic; const InteractiveScreenViewerDynamic =
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max( const maxBottom = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 300);
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), const maxRight = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400);
300
);
const maxRight = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400
);
return ( return (
<div <div
@ -295,11 +291,11 @@ export function TabsWidget({
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max( const maxBottom = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
300 // 최소 높이 300, // 최소 높이
); );
const maxRight = Math.max( const maxRight = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400 // 최소 너비 400, // 최소 너비
); );
return ( return (
@ -319,7 +315,7 @@ export function TabsWidget({
className={cn( className={cn(
"absolute", "absolute",
isDesignMode && "cursor-move", isDesignMode && "cursor-move",
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2" isDesignMode && isSelected && "ring-primary ring-2 ring-offset-2",
)} )}
style={{ style={{
left: comp.position?.x || 0, left: comp.position?.x || 0,
@ -380,9 +376,7 @@ export function TabsWidget({
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8"> <TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
{tab.label} {tab.label}
{tab.components && tab.components.length > 0 && ( {tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground"> <span className="text-muted-foreground ml-1 text-xs">({tab.components.length})</span>
({tab.components.length})
</span>
)} )}
</TabsTrigger> </TabsTrigger>
{allowCloseable && ( {allowCloseable && (
@ -390,7 +384,7 @@ export function TabsWidget({
onClick={(e) => handleCloseTab(tab.id, e)} onClick={(e) => handleCloseTab(tab.id, e)}
variant="ghost" variant="ghost"
size="sm" 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"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>

View File

@ -124,83 +124,91 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// DataProvidable 인터페이스 구현 // DataProvidable 인터페이스 구현
// 다른 컴포넌트에서 이 리피터의 데이터를 가져갈 수 있게 함 // 다른 컴포넌트에서 이 리피터의 데이터를 가져갈 수 있게 함
// ============================================================ // ============================================================
const dataProvider: DataProvidable = useMemo(() => ({ const dataProvider: DataProvidable = useMemo(
componentId: parentId || config.fieldName || "unified-repeater", () => ({
componentType: "unified-repeater", componentId: parentId || config.fieldName || "unified-repeater",
componentType: "unified-repeater",
// 선택된 행 데이터 반환 // 선택된 행 데이터 반환
getSelectedData: () => { getSelectedData: () => {
return Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean); return Array.from(selectedRows)
}, .map((idx) => data[idx])
.filter(Boolean);
},
// 전체 데이터 반환 // 전체 데이터 반환
getAllData: () => { getAllData: () => {
return [...data]; return [...data];
}, },
// 선택 초기화 // 선택 초기화
clearSelection: () => { clearSelection: () => {
setSelectedRows(new Set()); setSelectedRows(new Set());
}, },
}), [parentId, config.fieldName, data, selectedRows]); }),
[parentId, config.fieldName, data, selectedRows],
);
// ============================================================ // ============================================================
// DataReceivable 인터페이스 구현 // DataReceivable 인터페이스 구현
// 외부에서 이 리피터로 데이터를 전달받을 수 있게 함 // 외부에서 이 리피터로 데이터를 전달받을 수 있게 함
// ============================================================ // ============================================================
const dataReceiver: DataReceivable = useMemo(() => ({ const dataReceiver: DataReceivable = useMemo(
componentId: parentId || config.fieldName || "unified-repeater", () => ({
componentType: "repeater", componentId: parentId || config.fieldName || "unified-repeater",
componentType: "repeater",
// 데이터 수신 (append, replace, merge 모드 지원) // 데이터 수신 (append, replace, merge 모드 지원)
receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => { receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => {
if (!incomingData || incomingData.length === 0) return; if (!incomingData || incomingData.length === 0) return;
// 매핑 규칙 적용 // 매핑 규칙 적용
const mappedData = incomingData.map((item, index) => { const mappedData = incomingData.map((item, index) => {
const newRow: any = { _id: `received_${Date.now()}_${index}` }; const newRow: any = { _id: `received_${Date.now()}_${index}` };
if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) { if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) {
receiverConfig.mappingRules.forEach((rule) => { receiverConfig.mappingRules.forEach((rule) => {
const sourceValue = item[rule.sourceField]; const sourceValue = item[rule.sourceField];
newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue; newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue;
}); });
} else { } else {
// 매핑 규칙 없으면 그대로 복사 // 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item); 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; // 현재 데이터 반환
}); getData: () => {
return [...data];
// 모드에 따라 데이터 처리 },
switch (receiverConfig.mode) { }),
case "replace": [parentId, config.fieldName, data, onDataChange],
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]);
// ============================================================ // ============================================================
// ScreenContext에 DataProvider/DataReceiver 등록 // ScreenContext에 DataProvider/DataReceiver 등록
@ -231,7 +239,9 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
componentId: parentId || config.fieldName || "unified-repeater", componentId: parentId || config.fieldName || "unified-repeater",
tableName: config.dataSource?.tableName || "", tableName: config.dataSource?.tableName || "",
data: data, 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; prevDataLengthRef.current = data.length;
} }
@ -701,19 +711,22 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// 🆕 채번 API 호출 (비동기) // 🆕 채번 API 호출 (비동기)
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => { const generateNumberingCode = useCallback(
try { async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
const result = await allocateNumberingCode(ruleId, userInputCode, formData); try {
if (result.success && result.data?.generatedCode) { const result = await allocateNumberingCode(ruleId, userInputCode, formData);
return result.data.generatedCode; 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 모드 또는 모달 열기) - 비동기로 변경 // 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
const handleAddRow = useCallback(async () => { const handleAddRow = useCallback(async () => {

View File

@ -88,9 +88,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 전역 리피터 등록 // 전역 리피터 등록
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블) // 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
useEffect(() => { useEffect(() => {
const targetTableName = config.useCustomTable && config.mainTableName const targetTableName =
? config.mainTableName config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
: config.dataSource?.tableName;
if (targetTableName) { if (targetTableName) {
if (!window.__v2RepeaterInstances) { if (!window.__v2RepeaterInstances) {
@ -110,9 +109,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
useEffect(() => { useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => { const handleSaveEvent = async (event: CustomEvent) => {
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
const tableName = config.useCustomTable && config.mainTableName const tableName =
? config.mainTableName config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
: config.dataSource?.tableName;
const eventParentId = event.detail?.parentId; const eventParentId = event.detail?.parentId;
const mainFormData = event.detail?.mainFormData; const mainFormData = event.detail?.mainFormData;
@ -179,11 +177,11 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} }
} else { } else {
// 기존 방식: 메인 폼 데이터 병합 // 기존 방식: 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
mergedData = { mergedData = {
...mainFormDataWithoutId, ...mainFormDataWithoutId,
...cleanRow, ...cleanRow,
}; };
} }
// 유효하지 않은 컬럼 제거 // 유효하지 않은 컬럼 제거
@ -196,7 +194,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
} }
} catch (error) { } catch (error) {
console.error("❌ V2Repeater 저장 실패:", error); console.error("❌ V2Repeater 저장 실패:", error);
throw error; throw error;
@ -207,14 +204,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const unsubscribe = v2EventBus.subscribe( const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.REPEATER_SAVE, V2_EVENTS.REPEATER_SAVE,
async (payload) => { async (payload) => {
const tableName = config.useCustomTable && config.mainTableName const tableName =
? config.mainTableName config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
: config.dataSource?.tableName;
if (payload.tableName === tableName) { if (payload.tableName === tableName) {
await handleSaveEvent({ detail: payload } as CustomEvent); 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<V2RepeaterProps> = ({
unsubscribe(); unsubscribe();
window.removeEventListener("repeaterSave" as any, handleSaveEvent); 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(() => { useEffect(() => {
@ -364,13 +367,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
calculated: true, calculated: true,
width: col.width === "auto" ? undefined : col.width, width: col.width === "auto" ? undefined : col.width,
}; };
} }
// 일반 입력 컬럼 // 일반 입력 컬럼
let type: "text" | "number" | "date" | "select" | "category" = "text"; let type: "text" | "number" | "date" | "select" | "category" = "text";
if (inputType === "number" || inputType === "decimal") type = "number"; if (inputType === "number" || inputType === "decimal") type = "number";
else if (inputType === "date" || inputType === "datetime") type = "date"; else if (inputType === "date" || inputType === "datetime") type = "date";
else if (inputType === "code") type = "select"; else if (inputType === "code") type = "select";
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
@ -384,18 +387,18 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} }
} }
return { return {
field: col.key, field: col.key,
label: col.title || colInfo?.displayName || col.key, label: col.title || colInfo?.displayName || col.key,
type, type,
editable: col.editable !== false, editable: col.editable !== false,
width: col.width === "auto" ? undefined : col.width, width: col.width === "auto" ? undefined : col.width,
required: false, required: false,
categoryRef, // 🆕 카테고리 참조 ID 전달 categoryRef, // 🆕 카테고리 참조 ID 전달
hidden: col.hidden, // 🆕 히든 처리 hidden: col.hidden, // 🆕 히든 처리
autoFill: col.autoFill, // 🆕 자동 입력 설정 autoFill: col.autoFill, // 🆕 자동 입력 설정
}; };
}); });
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
@ -451,25 +454,24 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 데이터 변경 핸들러 // 데이터 변경 핸들러
const handleDataChange = useCallback( const handleDataChange = useCallback(
(newData: any[]) => { (newData: any[]) => {
setData(newData); setData(newData);
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용) // 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
if (onDataChange) { if (onDataChange) {
const targetTable = config.useCustomTable && config.mainTableName const targetTable =
? config.mainTableName config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
: config.dataSource?.tableName;
if (targetTable) { if (targetTable) {
// 각 행에 _targetTable 추가 // 각 행에 _targetTable 추가
const dataWithTarget = newData.map(row => ({ const dataWithTarget = newData.map((row) => ({
...row, ...row,
_targetTable: targetTable, _targetTable: targetTable,
})); }));
onDataChange(dataWithTarget); onDataChange(dataWithTarget);
} else { } else {
onDataChange(newData); onDataChange(newData);
}
} }
}
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
setAutoWidthTrigger((prev) => prev + 1); setAutoWidthTrigger((prev) => prev + 1);
@ -480,26 +482,25 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 행 변경 핸들러 // 행 변경 핸들러
const handleRowChange = useCallback( const handleRowChange = useCallback(
(index: number, newRow: any) => { (index: number, newRow: any) => {
const newData = [...data]; const newData = [...data];
newData[index] = newRow; newData[index] = newRow;
setData(newData); setData(newData);
// 🆕 _targetTable 메타데이터 포함 // 🆕 _targetTable 메타데이터 포함
if (onDataChange) { if (onDataChange) {
const targetTable = config.useCustomTable && config.mainTableName const targetTable =
? config.mainTableName config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
: config.dataSource?.tableName;
if (targetTable) { if (targetTable) {
const dataWithTarget = newData.map(row => ({ const dataWithTarget = newData.map((row) => ({
...row, ...row,
_targetTable: targetTable, _targetTable: targetTable,
})); }));
onDataChange(dataWithTarget); onDataChange(dataWithTarget);
} else { } else {
onDataChange(newData); onDataChange(newData);
}
} }
}
}, },
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], [data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
); );
@ -507,16 +508,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 행 삭제 핸들러 // 행 삭제 핸들러
const handleRowDelete = useCallback( const handleRowDelete = useCallback(
(index: number) => { (index: number) => {
const newData = data.filter((_, i) => i !== index); const newData = data.filter((_, i) => i !== index);
handleDataChange(newData); // 🆕 handleDataChange 사용 handleDataChange(newData); // 🆕 handleDataChange 사용
// 선택 상태 업데이트 // 선택 상태 업데이트
const newSelected = new Set<number>(); const newSelected = new Set<number>();
selectedRows.forEach((i) => { selectedRows.forEach((i) => {
if (i < index) newSelected.add(i); if (i < index) newSelected.add(i);
else if (i > index) newSelected.add(i - 1); else if (i > index) newSelected.add(i - 1);
}); });
setSelectedRows(newSelected); setSelectedRows(newSelected);
}, },
[data, selectedRows, handleDataChange], [data, selectedRows, handleDataChange],
); );
@ -568,19 +569,22 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 🆕 채번 API 호출 (비동기) // 🆕 채번 API 호출 (비동기)
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가 // 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => { const generateNumberingCode = useCallback(
try { async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
const result = await allocateNumberingCode(ruleId, userInputCode, formData); try {
if (result.success && result.data?.generatedCode) { const result = await allocateNumberingCode(ruleId, userInputCode, formData);
return result.data.generatedCode; 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 모드 또는 모달 열기) - 비동기로 변경 // 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
const handleAddRow = useCallback(async () => { const handleAddRow = useCallback(async () => {
@ -599,7 +603,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} else if (autoValue !== undefined) { } else if (autoValue !== undefined) {
newRow[col.key] = autoValue; newRow[col.key] = autoValue;
} else { } else {
newRow[col.key] = ""; newRow[col.key] = "";
} }
} }
@ -611,22 +615,22 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 모달에서 항목 선택 - 비동기로 변경 // 모달에서 항목 선택 - 비동기로 변경
const handleSelectItems = useCallback( const handleSelectItems = useCallback(
async (items: Record<string, unknown>[]) => { async (items: Record<string, unknown>[]) => {
const fkColumn = config.dataSource?.foreignKey; const fkColumn = config.dataSource?.foreignKey;
const currentRowCount = data.length; const currentRowCount = data.length;
// 채번이 필요한 컬럼 찾기 // 채번이 필요한 컬럼 찾기
const numberingColumns = config.columns.filter( 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( const newRows = await Promise.all(
items.map(async (item, index) => { 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 사용) // FK 값 저장 (resolvedReferenceKey 사용)
if (fkColumn && item[resolvedReferenceKey]) { if (fkColumn && item[resolvedReferenceKey]) {
row[fkColumn] = item[resolvedReferenceKey]; row[fkColumn] = item[resolvedReferenceKey];
} }
// 모든 컬럼 처리 (순서대로) // 모든 컬럼 처리 (순서대로)
for (const col of config.columns) { for (const col of config.columns) {
@ -643,20 +647,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
row[col.key] = autoValue; row[col.key] = autoValue;
} else if (row[col.key] === undefined) { } 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); 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인 것만 필터링 // 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
@ -726,7 +738,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} as CustomEvent; } as CustomEvent;
await handleBeforeFormSave(fakeEvent); await handleBeforeFormSave(fakeEvent);
}, },
{ componentId: `v2-repeater-${config.dataSource?.tableName}` } { componentId: `v2-repeater-${config.dataSource?.tableName}` },
); );
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션) // 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
@ -831,7 +843,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} as CustomEvent; } as CustomEvent;
handleComponentDataTransfer(fakeEvent); handleComponentDataTransfer(fakeEvent);
}, },
{ componentId: `v2-repeater-${config.dataSource?.tableName}` } { componentId: `v2-repeater-${config.dataSource?.tableName}` },
); );
const unsubscribeSplitPanel = v2EventBus.subscribe( const unsubscribeSplitPanel = v2EventBus.subscribe(
@ -846,7 +858,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} as CustomEvent; } as CustomEvent;
handleSplitPanelDataTransfer(fakeEvent); handleSplitPanelDataTransfer(fakeEvent);
}, },
{ componentId: `v2-repeater-${config.dataSource?.tableName}` } { componentId: `v2-repeater-${config.dataSource?.tableName}` },
); );
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션) // 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
@ -928,11 +940,7 @@ V2Repeater.displayName = "V2Repeater";
// V2ErrorBoundary로 래핑된 안전한 버전 export // V2ErrorBoundary로 래핑된 안전한 버전 export
export const SafeV2Repeater: React.FC<V2RepeaterProps> = (props) => { export const SafeV2Repeater: React.FC<V2RepeaterProps> = (props) => {
return ( return (
<V2ErrorBoundary <V2ErrorBoundary componentId={props.parentId || "v2-repeater"} componentType="V2Repeater" fallbackStyle="compact">
componentId={props.parentId || "v2-repeater"}
componentType="V2Repeater"
fallbackStyle="compact"
>
<V2Repeater {...props} /> <V2Repeater {...props} />
</V2ErrorBoundary> </V2ErrorBoundary>
); );

View File

@ -26,13 +26,9 @@ export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConf
* @param menuObjid objid () * @param menuObjid objid ()
* @returns * @returns
*/ */
export async function getAvailableNumberingRules( export async function getAvailableNumberingRules(menuObjid?: number): Promise<ApiResponse<NumberingRuleConfig[]>> {
menuObjid?: number
): Promise<ApiResponse<NumberingRuleConfig[]>> {
try { try {
const url = menuObjid const url = menuObjid ? `/numbering-rules/available/${menuObjid}` : "/numbering-rules/available";
? `/numbering-rules/available/${menuObjid}`
: "/numbering-rules/available";
const response = await apiClient.get(url); const response = await apiClient.get(url);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
@ -46,7 +42,7 @@ export async function getAvailableNumberingRules(
* @returns * @returns
*/ */
export async function getAvailableNumberingRulesForScreen( export async function getAvailableNumberingRulesForScreen(
tableName: string tableName: string,
): Promise<ApiResponse<NumberingRuleConfig[]>> { ): Promise<ApiResponse<NumberingRuleConfig[]>> {
try { try {
const response = await apiClient.get("/numbering-rules/available-for-screen", { const response = await apiClient.get("/numbering-rules/available-for-screen", {
@ -70,9 +66,7 @@ export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<
} }
} }
export async function createNumberingRule( export async function createNumberingRule(config: NumberingRuleConfig): Promise<ApiResponse<NumberingRuleConfig>> {
config: NumberingRuleConfig
): Promise<ApiResponse<NumberingRuleConfig>> {
try { try {
const response = await apiClient.post("/numbering-rules", config); const response = await apiClient.post("/numbering-rules", config);
return response.data; return response.data;
@ -83,7 +77,7 @@ export async function createNumberingRule(
export async function updateNumberingRule( export async function updateNumberingRule(
ruleId: string, ruleId: string,
config: Partial<NumberingRuleConfig> config: Partial<NumberingRuleConfig>,
): Promise<ApiResponse<NumberingRuleConfig>> { ): Promise<ApiResponse<NumberingRuleConfig>> {
try { try {
const response = await apiClient.put(`/numbering-rules/${ruleId}`, config); const response = await apiClient.put(`/numbering-rules/${ruleId}`, config);
@ -110,7 +104,7 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
*/ */
export async function previewNumberingCode( export async function previewNumberingCode(
ruleId: string, ruleId: string,
formData?: Record<string, unknown> formData?: Record<string, unknown>,
): Promise<ApiResponse<{ generatedCode: string }>> { ): Promise<ApiResponse<{ generatedCode: string }>> {
// ruleId 유효성 검사 // ruleId 유효성 검사
if (!ruleId || ruleId === "undefined" || ruleId === "null") { if (!ruleId || ruleId === "undefined" || ruleId === "null") {
@ -128,10 +122,7 @@ export async function previewNumberingCode(
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { error?: string; message?: string } }; message?: string }; const err = error as { response?: { data?: { error?: string; message?: string } }; message?: string };
const errorMessage = const errorMessage =
err.response?.data?.error || err.response?.data?.error || err.response?.data?.message || err.message || "코드 미리보기 실패";
err.response?.data?.message ||
err.message ||
"코드 미리보기 실패";
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} }
} }
@ -146,7 +137,7 @@ export async function previewNumberingCode(
export async function allocateNumberingCode( export async function allocateNumberingCode(
ruleId: string, ruleId: string,
userInputCode?: string, userInputCode?: string,
formData?: Record<string, any> formData?: Record<string, any>,
): Promise<ApiResponse<{ generatedCode: string }>> { ): Promise<ApiResponse<{ generatedCode: string }>> {
try { try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, { const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, {
@ -162,9 +153,7 @@ export async function allocateNumberingCode(
/** /**
* @deprecated generateNumberingCode는 previewNumberingCode를 * @deprecated generateNumberingCode는 previewNumberingCode를
*/ */
export async function generateNumberingCode( export async function generateNumberingCode(ruleId: string): Promise<ApiResponse<{ generatedCode: string }>> {
ruleId: string
): Promise<ApiResponse<{ generatedCode: string }>> {
console.warn("generateNumberingCode는 deprecated. previewNumberingCode 사용 권장"); console.warn("generateNumberingCode는 deprecated. previewNumberingCode 사용 권장");
return previewNumberingCode(ruleId); return previewNumberingCode(ruleId);
} }
@ -188,13 +177,9 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
* numbering_rules * numbering_rules
* @param menuObjid OBJID () - * @param menuObjid OBJID () -
*/ */
export async function getNumberingRulesFromTest( export async function getNumberingRulesFromTest(menuObjid?: number): Promise<ApiResponse<NumberingRuleConfig[]>> {
menuObjid?: number
): Promise<ApiResponse<NumberingRuleConfig[]>> {
try { try {
const url = menuObjid const url = menuObjid ? `/numbering-rules/test/list/${menuObjid}` : "/numbering-rules/test/list";
? `/numbering-rules/test/list/${menuObjid}`
: "/numbering-rules/test/list";
const response = await apiClient.get(url); const response = await apiClient.get(url);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
@ -211,7 +196,7 @@ export async function getNumberingRulesFromTest(
*/ */
export async function getNumberingRuleByColumn( export async function getNumberingRuleByColumn(
tableName: string, tableName: string,
columnName: string columnName: string,
): Promise<ApiResponse<NumberingRuleConfig>> { ): Promise<ApiResponse<NumberingRuleConfig>> {
try { try {
const response = await apiClient.get("/numbering-rules/test/by-column", { const response = await apiClient.get("/numbering-rules/test/by-column", {
@ -230,9 +215,7 @@ export async function getNumberingRuleByColumn(
* [] * []
* numbering_rules * numbering_rules
*/ */
export async function saveNumberingRuleToTest( export async function saveNumberingRuleToTest(config: NumberingRuleConfig): Promise<ApiResponse<NumberingRuleConfig>> {
config: NumberingRuleConfig
): Promise<ApiResponse<NumberingRuleConfig>> {
try { try {
const response = await apiClient.post("/numbering-rules/test/save", config); const response = await apiClient.post("/numbering-rules/test/save", config);
return response.data; return response.data;
@ -248,9 +231,7 @@ export async function saveNumberingRuleToTest(
* [] * []
* numbering_rules * numbering_rules
*/ */
export async function deleteNumberingRuleFromTest( export async function deleteNumberingRuleFromTest(ruleId: string): Promise<ApiResponse<void>> {
ruleId: string
): Promise<ApiResponse<void>> {
try { try {
const response = await apiClient.delete(`/numbering-rules/test/${ruleId}`); const response = await apiClient.delete(`/numbering-rules/test/${ruleId}`);
return response.data; return response.data;
@ -270,7 +251,7 @@ export async function getNumberingRuleByColumnWithCategory(
tableName: string, tableName: string,
columnName: string, columnName: string,
categoryColumn?: string, categoryColumn?: string,
categoryValueId?: number categoryValueId?: number,
): Promise<ApiResponse<NumberingRuleConfig>> { ): Promise<ApiResponse<NumberingRuleConfig>> {
try { try {
const response = await apiClient.get("/numbering-rules/test/by-column-with-category", { const response = await apiClient.get("/numbering-rules/test/by-column-with-category", {
@ -290,7 +271,7 @@ export async function getNumberingRuleByColumnWithCategory(
*/ */
export async function getRulesByTableColumn( export async function getRulesByTableColumn(
tableName: string, tableName: string,
columnName: string columnName: string,
): Promise<ApiResponse<NumberingRuleConfig[]>> { ): Promise<ApiResponse<NumberingRuleConfig[]>> {
try { try {
const response = await apiClient.get("/numbering-rules/test/rules-by-table-column", { const response = await apiClient.get("/numbering-rules/test/rules-by-table-column", {
@ -304,4 +285,3 @@ export async function getRulesByTableColumn(
}; };
} }
} }

View File

@ -115,14 +115,14 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => !isDisabled && setOpen(!open)}
disabled={isDisabled} disabled={isDisabled}
> >
{loading ? ( {loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
) : ( ) : (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" /> <ChevronsUpDown className="text-muted-foreground h-4 w-4" />
)} )}
</Button> </Button>
</div> </div>
@ -149,12 +149,7 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
setOpen(false); setOpen(false);
}} }}
> >
<Check <Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label} {option.label}
</CommandItem> </CommandItem>
))} ))}
@ -948,13 +943,17 @@ export function UniversalFormModalComponent({
// 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장) // 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장)
const originalTableSectionKey = `_originalTableSectionData_${section.id}`; const originalTableSectionKey = `_originalTableSectionData_${section.id}`;
newFormData[originalTableSectionKey] = JSON.parse(JSON.stringify(items)); 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에도 추가 (하위 호환성) // 기존 originalGroupedData에도 추가 (하위 호환성)
if (!groupedDataInitializedRef.current) { if (!groupedDataInitializedRef.current) {
setOriginalGroupedData((prev) => { setOriginalGroupedData((prev) => {
const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))]; 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; return newOriginal;
}); });
} }
@ -2433,14 +2432,14 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => !disabled && !loading && setOpen(!open)}
disabled={disabled || loading} disabled={disabled || loading}
> >
{loading ? ( {loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
) : ( ) : (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" /> <ChevronsUpDown className="text-muted-foreground h-4 w-4" />
)} )}
</Button> </Button>
</div> </div>
@ -2463,12 +2462,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
setOpen(false); setOpen(false);
}} }}
> >
<Check <Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label} {option.label}
</CommandItem> </CommandItem>
))} ))}

View File

@ -47,7 +47,7 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
const style = component.style || {}; const style = component.style || {};
const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
// labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김) // labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김)
const effectiveLabel = labelDisplay === true ? (style.labelText || component.label) : undefined; const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined;
return ( return (
<V2Input <V2Input

View File

@ -1626,7 +1626,9 @@ export class ButtonActionExecutor {
// saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... } // saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... }
const savedRecord = saveResult?.data?.data || saveResult?.data || {}; const savedRecord = saveResult?.data?.data || saveResult?.data || {};
const actualFormData = savedRecord?.data || savedRecord; const actualFormData = savedRecord?.data || savedRecord;
const formData: Record<string, any> = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}) as Record<string, any>; const formData: Record<string, any> = (
Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {}
) as Record<string, any>;
console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord)); console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord));
console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData)); console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData));
console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun); console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun);
@ -2924,8 +2926,7 @@ export class ButtonActionExecutor {
if (v2ListComponent) { if (v2ListComponent) {
dataSourceId = dataSourceId =
v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.tableName;
v2ListComponent.componentConfig.tableName;
console.log("✨ V2List 자동 감지:", { console.log("✨ V2List 자동 감지:", {
componentId: v2ListComponent.id, componentId: v2ListComponent.id,
tableName: dataSourceId, tableName: dataSourceId,