refactor: 코드 정리 및 가독성 향상
- numberingRuleController.ts에서 API 엔드포인트의 코드 스타일을 일관되게 정리하여 가독성을 높였습니다. - 불필요한 줄바꿈을 제거하고, 코드 블록을 명확하게 정리하여 유지보수성을 개선했습니다. - tableManagementService.ts와 ButtonConfigPanel.tsx에서 코드 정리를 통해 일관성을 유지하고, 가독성을 향상시켰습니다. - 전반적으로 코드의 깔끔함을 유지하고, 향후 개발 시 이해하기 쉽게 개선했습니다.
This commit is contained in:
parent
73d05b991c
commit
e31bb970a2
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<string, any>
|
||||
): Promise<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -51,13 +51,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}) => {
|
||||
// 🔧 component가 없는 경우 방어 처리
|
||||
if (!component) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
컴포넌트 정보를 불러올 수 없습니다.
|
||||
</div>
|
||||
);
|
||||
return <div className="text-muted-foreground p-4 text-sm">컴포넌트 정보를 불러올 수 없습니다.</div>;
|
||||
}
|
||||
|
||||
|
||||
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
||||
const config = component.componentConfig || {};
|
||||
const currentAction = component.componentConfig?.action || {};
|
||||
|
|
@ -122,7 +118,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null);
|
||||
const [modalActionSourceColumns, setModalActionSourceColumns] = 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 [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
|
|
@ -353,7 +351,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
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<ButtonConfigPanelProps> = ({
|
|||
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<ButtonConfigPanelProps> = ({
|
|||
if (sourceTableName) break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setModalActionSourceTable(sourceTableName);
|
||||
|
||||
|
||||
// 2. 대상 화면의 테이블 조회
|
||||
let targetTableName: string | null = null;
|
||||
try {
|
||||
|
|
@ -405,9 +403,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
} catch (error) {
|
||||
console.error("대상 화면 정보 로드 실패:", error);
|
||||
}
|
||||
|
||||
|
||||
setModalActionTargetTable(targetTableName);
|
||||
|
||||
|
||||
// 3. 소스 테이블 컬럼 로드
|
||||
if (sourceTableName) {
|
||||
try {
|
||||
|
|
@ -416,7 +414,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
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<ButtonConfigPanelProps> = ({
|
|||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. 대상 테이블 컬럼 로드
|
||||
if (targetTableName) {
|
||||
try {
|
||||
|
|
@ -438,7 +436,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
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<ButtonConfigPanelProps> = ({
|
|||
console.error("대상 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 5. 기존 필드 매핑 로드 또는 자동 매핑 생성
|
||||
const existingMappings = config.action?.fieldMappings || [];
|
||||
if (existingMappings.length > 0) {
|
||||
|
|
@ -461,10 +459,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
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<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="transferData">데이터 전달</SelectItem>
|
||||
|
||||
|
||||
{/* 엑셀 관련 */}
|
||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
|
||||
|
||||
{/* 고급 기능 */}
|
||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
|
||||
|
||||
{/* 특수 기능 (필요 시 사용) */}
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||
|
||||
|
||||
{/* 이벤트 버스 */}
|
||||
<SelectItem value="event">이벤트 발송</SelectItem>
|
||||
|
||||
|
||||
{/* 복사 */}
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
|
||||
|
||||
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
|
||||
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
|
||||
<SelectItem value="openModalWithData">(deprecated) 데이터 전달 + 모달 열기</SelectItem>
|
||||
|
|
@ -985,10 +989,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}}
|
||||
/>
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
TableList/SplitPanel에서 선택된 데이터를 모달에 자동으로 전달합니다
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -996,11 +1000,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
{/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */}
|
||||
{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 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="font-medium">{modalActionSourceTable || "감지 중..."}</span>
|
||||
</div>
|
||||
|
|
@ -1012,171 +1016,210 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 테이블이 같으면 자동 매핑 안내 */}
|
||||
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && (
|
||||
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
|
||||
동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다.
|
||||
</div>
|
||||
)}
|
||||
{modalActionSourceTable &&
|
||||
modalActionTargetTable &&
|
||||
modalActionSourceTable === modalActionTargetTable && (
|
||||
<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 표시 */}
|
||||
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{modalActionSourceTable &&
|
||||
modalActionTargetTable &&
|
||||
modalActionSourceTable !== modalActionTargetTable && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter((_: any, i: number) => i !== index);
|
||||
const newMappings = [
|
||||
...(component.componentConfig?.action?.fieldMappings || []),
|
||||
{ sourceField: "", targetField: "" },
|
||||
];
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</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>
|
||||
|
|
@ -1185,9 +1228,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */}
|
||||
{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">
|
||||
<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>
|
||||
|
||||
{/* 🆕 블록 기반 제목 빌더 */}
|
||||
|
|
@ -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">
|
||||
<h4 className="text-foreground text-sm font-medium">이벤트 발송 설정</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다.
|
||||
다른 컴포넌트나 서비스에서 이 이벤트를 수신하여 처리할 수 있습니다.
|
||||
버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다. 다른 컴포넌트나 서비스에서 이 이벤트를 수신하여
|
||||
처리할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
|
|
@ -3597,11 +3641,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
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<ButtonConfigPanelProps> = ({
|
|||
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<ButtonConfigPanelProps> = ({
|
|||
|
||||
<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">
|
||||
<strong>동작 방식:</strong> 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다.
|
||||
생성 전 미리보기 확인 다이얼로그가 표시됩니다.
|
||||
<strong>동작 방식:</strong> 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다. 생성 전
|
||||
미리보기 확인 다이얼로그가 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function TabsWidget({
|
|||
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
||||
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
|
||||
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
||||
|
||||
|
||||
// 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트
|
||||
useEffect(() => {
|
||||
// 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택
|
||||
|
|
@ -92,7 +92,7 @@ export function TabsWidget({
|
|||
});
|
||||
}
|
||||
}, [tabs]); // tabs가 변경될 때마다 실행
|
||||
|
||||
|
||||
// screenId 기반 화면 로드 상태
|
||||
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-2 text-muted-foreground">화면을 불러오는 중...</span>
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2">화면을 불러오는 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 에러 발생
|
||||
if (screenErrors[tab.id]) {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 화면 레이아웃이 로드된 경우
|
||||
const loadedComponents = screenLayouts[tab.id];
|
||||
if (loadedComponents && loadedComponents.length > 0) {
|
||||
return renderScreenComponents(loadedComponents);
|
||||
}
|
||||
|
||||
|
||||
// 아직 로드되지 않은 경우
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식
|
||||
if (inlineComponents.length > 0) {
|
||||
return renderInlineComponents(tab, inlineComponents);
|
||||
}
|
||||
|
||||
|
||||
// 3. 둘 다 없는 경우
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto"
|
||||
style={{
|
||||
style={{
|
||||
minHeight: maxBottom + 20,
|
||||
minWidth: maxRight + 20,
|
||||
}}
|
||||
|
|
@ -295,17 +291,17 @@ export function TabsWidget({
|
|||
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||
const maxBottom = Math.max(
|
||||
...components.map((c) => (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 (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
minHeight: maxBottom + 20,
|
||||
minWidth: maxRight + 20,
|
||||
}}
|
||||
|
|
@ -319,7 +315,7 @@ export function TabsWidget({
|
|||
className={cn(
|
||||
"absolute",
|
||||
isDesignMode && "cursor-move",
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2"
|
||||
isDesignMode && isSelected && "ring-primary ring-2 ring-offset-2",
|
||||
)}
|
||||
style={{
|
||||
left: comp.position?.x || 0,
|
||||
|
|
@ -380,9 +376,7 @@ export function TabsWidget({
|
|||
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
||||
{tab.label}
|
||||
{tab.components && tab.components.length > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({tab.components.length})
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1 text-xs">({tab.components.length})</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
{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"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
||||
*
|
||||
* RepeaterTable 및 ItemSelectionModal 재사용
|
||||
*
|
||||
*
|
||||
* 데이터 전달 인터페이스:
|
||||
* - DataProvidable: 선택된 데이터 제공
|
||||
* - DataReceivable: 외부에서 데이터 수신
|
||||
|
|
@ -124,83 +124,91 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
// 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<UnifiedRepeaterProps> = ({
|
|||
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<UnifiedRepeaterProps> = ({
|
|||
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<UnifiedRepeaterProps> = ({
|
|||
|
||||
// 🆕 채번 API 호출 (비동기)
|
||||
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
|
||||
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
|
||||
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<string, any>): Promise<string> => {
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* 렌더링 모드:
|
||||
* - inline: 현재 테이블 컬럼 직접 입력
|
||||
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
||||
*
|
||||
*
|
||||
* RepeaterTable 및 ItemSelectionModal 재사용
|
||||
*/
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
|
||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||
|
||||
|
||||
// 소스 테이블 컬럼 라벨 매핑
|
||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||
|
||||
|
|
@ -72,10 +72,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
|
||||
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||
|
||||
|
||||
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
||||
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
||||
|
||||
|
||||
// 동적 데이터 소스 상태
|
||||
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
||||
|
||||
|
|
@ -88,10 +88,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 전역 리피터 등록
|
||||
// 🆕 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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
} 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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
// 기본: 마스터 레코드 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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
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<string, any> = {};
|
||||
columns.forEach((col: any) => {
|
||||
const name = col.columnName || col.column_name || col.name;
|
||||
|
|
@ -320,7 +323,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
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<string, string> = {};
|
||||
const categoryCols: string[] = [];
|
||||
|
||||
|
|
@ -364,13 +367,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
// 데이터 변경 핸들러
|
||||
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<V2RepeaterProps> = ({
|
|||
// 행 변경 핸들러
|
||||
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<V2RepeaterProps> = ({
|
|||
// 행 삭제 핸들러
|
||||
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<number>();
|
||||
selectedRows.forEach((i) => {
|
||||
if (i < index) newSelected.add(i);
|
||||
else if (i > index) newSelected.add(i - 1);
|
||||
});
|
||||
setSelectedRows(newSelected);
|
||||
|
||||
// 선택 상태 업데이트
|
||||
const newSelected = new Set<number>();
|
||||
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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
|
||||
// 🆕 채번 API 호출 (비동기)
|
||||
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
|
||||
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
|
||||
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<string, any>): Promise<string> => {
|
||||
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<V2RepeaterProps> = ({
|
|||
} 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<V2RepeaterProps> = ({
|
|||
} 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<V2RepeaterProps> = ({
|
|||
// 모달에서 항목 선택 - 비동기로 변경
|
||||
const handleSelectItems = useCallback(
|
||||
async (items: Record<string, unknown>[]) => {
|
||||
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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
// 🆕 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<V2RepeaterProps> = ({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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<V2RepeaterProps> = ({
|
|||
} 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<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
// 매핑 규칙 없으면 그대로 복사
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
|
|
@ -784,20 +796,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
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<V2RepeaterProps> = ({
|
|||
} else {
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
|
|
@ -816,7 +828,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
handleDataChange([...data, ...mappedData]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribeComponent = v2EventBus.subscribe(
|
||||
V2_EVENTS.COMPONENT_DATA_TRANSFER,
|
||||
|
|
@ -831,7 +843,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
} 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<V2RepeaterProps> = ({
|
|||
} 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<V2RepeaterProps> = (props) => {
|
||||
return (
|
||||
<V2ErrorBoundary
|
||||
componentId={props.parentId || "v2-repeater"}
|
||||
componentType="V2Repeater"
|
||||
fallbackStyle="compact"
|
||||
>
|
||||
<V2ErrorBoundary componentId={props.parentId || "v2-repeater"} componentType="V2Repeater" fallbackStyle="compact">
|
||||
<V2Repeater {...props} />
|
||||
</V2ErrorBoundary>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,13 +26,9 @@ export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConf
|
|||
* @param menuObjid 현재 메뉴의 objid (선택)
|
||||
* @returns 사용 가능한 채번 규칙 목록
|
||||
*/
|
||||
export async function getAvailableNumberingRules(
|
||||
menuObjid?: number
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
export async function getAvailableNumberingRules(menuObjid?: number): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
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<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
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(
|
||||
config: NumberingRuleConfig
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
export async function createNumberingRule(config: NumberingRuleConfig): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
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<NumberingRuleConfig>
|
||||
config: Partial<NumberingRuleConfig>,
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
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(
|
||||
ruleId: string,
|
||||
formData?: Record<string, unknown>
|
||||
formData?: Record<string, unknown>,
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
// 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<string, any>
|
||||
formData?: Record<string, any>,
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
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<ApiResponse<{ generatedCode: string }>> {
|
||||
export async function generateNumberingCode(ruleId: string): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
console.warn("generateNumberingCode는 deprecated. previewNumberingCode 사용 권장");
|
||||
return previewNumberingCode(ruleId);
|
||||
}
|
||||
|
|
@ -188,13 +177,9 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
|
|||
* numbering_rules 테이블 사용
|
||||
* @param menuObjid 메뉴 OBJID (선택) - 필터링용
|
||||
*/
|
||||
export async function getNumberingRulesFromTest(
|
||||
menuObjid?: number
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
export async function getNumberingRulesFromTest(menuObjid?: number): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
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<ApiResponse<NumberingRuleConfig>> {
|
||||
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<ApiResponse<NumberingRuleConfig>> {
|
||||
export async function saveNumberingRuleToTest(config: NumberingRuleConfig): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
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<ApiResponse<void>> {
|
||||
export async function deleteNumberingRuleFromTest(ruleId: string): Promise<ApiResponse<void>> {
|
||||
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<ApiResponse<NumberingRuleConfig>> {
|
||||
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<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
const response = await apiClient.get("/numbering-rules/test/rules-by-table-column", {
|
||||
|
|
@ -304,4 +285,3 @@ export async function getRulesByTableColumn(
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -115,14 +115,14 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
|||
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 ? (
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -149,12 +149,7 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
@ -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 ? (
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -2463,12 +2462,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<V2Input
|
||||
|
|
|
|||
|
|
@ -533,14 +533,14 @@ export class ButtonActionExecutor {
|
|||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
|
||||
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음
|
||||
|
||||
|
||||
// 🔧 디버그: beforeFormSave 이벤트 전 formData 확인
|
||||
console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", {
|
||||
keys: Object.keys(context.formData || {}),
|
||||
hasCompanyImage: "company_image" in (context.formData || {}),
|
||||
companyImageValue: context.formData?.company_image,
|
||||
});
|
||||
|
||||
|
||||
const beforeSaveEventDetail = {
|
||||
formData: context.formData,
|
||||
skipDefaultSave: false,
|
||||
|
|
@ -555,7 +555,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
|
||||
await new Promise((resolve) => 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<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] 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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue