Merge pull request 'feature/v2-unified-renewal' (#380) from feature/v2-unified-renewal into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/380
This commit is contained in:
kjs 2026-02-06 09:53:04 +09:00
commit 15d2a654bd
98 changed files with 12376 additions and 3608 deletions

View File

@ -1044,6 +1044,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@ -2371,6 +2372,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0", "generic-pool": "3.9.0",
@ -3474,6 +3476,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -3710,6 +3713,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@ -3927,6 +3931,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -4453,6 +4458,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@ -5663,6 +5669,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@ -7425,6 +7432,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@ -8394,7 +8402,6 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@ -9283,6 +9290,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.1", "pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1", "pg-pool": "^3.10.1",
@ -10133,7 +10141,6 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@ -10942,6 +10949,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@ -11047,6 +11055,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -793,8 +793,9 @@ export const previewFile = async (
return; return;
} }
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외) // 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 및 공개 접근 제외)
if (companyCode !== "*" && fileRecord.company_code !== companyCode) { // 공개 접근(req.user가 없는 경우)은 미리보기 허용 (이미지 표시용)
if (companyCode && companyCode !== "*" && fileRecord.company_code !== companyCode) {
console.warn("⚠️ 다른 회사 파일 접근 시도:", { console.warn("⚠️ 다른 회사 파일 접근 시도:", {
userId: req.user?.userId, userId: req.user?.userId,
userCompanyCode: companyCode, userCompanyCode: companyCode,
@ -1260,5 +1261,56 @@ export const setRepresentativeFile = async (
} }
}; };
/**
* (, )
*
*/
export const getFileInfo = async (req: Request, res: Response) => {
try {
const { objid } = req.params;
if (!objid) {
return res.status(400).json({
success: false,
message: "파일 ID가 필요합니다.",
});
}
// 파일 정보 조회
const fileRecord = await queryOne<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate, is_representative
FROM attach_file_info
WHERE objid = $1 AND status = 'ACTIVE'`,
[parseInt(objid)]
);
if (!fileRecord) {
return res.status(404).json({
success: false,
message: "파일을 찾을 수 없습니다.",
});
}
res.json({
success: true,
data: {
objid: fileRecord.objid.toString(),
realFileName: fileRecord.real_file_name,
fileSize: fileRecord.file_size,
fileExt: fileRecord.file_ext,
filePath: fileRecord.file_path,
regdate: fileRecord.regdate,
isRepresentative: fileRecord.is_representative,
},
});
} catch (error) {
console.error("파일 정보 조회 오류:", error);
res.status(500).json({
success: false,
message: "파일 정보 조회 중 오류가 발생했습니다.",
});
}
};
// Multer 미들웨어 export // Multer 미들웨어 export
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일

View File

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

View File

@ -46,11 +46,13 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
const countResult = await pool.query(countQuery, params); const countResult = await pool.query(countQuery, params);
const total = parseInt(countResult.rows[0].total); const total = parseInt(countResult.rows[0].total);
// 데이터 조회 (screens 배열 포함) // 데이터 조회 (screens 배열 포함) - 삭제된 화면(is_active = 'D') 제외
const dataQuery = ` const dataQuery = `
SELECT SELECT
sg.*, sg.*,
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, (SELECT COUNT(*) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id AND sd.is_active != 'D') as screen_count,
(SELECT json_agg( (SELECT json_agg(
json_build_object( json_build_object(
'id', sgs.id, 'id', sgs.id,
@ -64,6 +66,7 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
) FROM screen_group_screens sgs ) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id WHERE sgs.group_id = sg.id
AND sd.is_active != 'D'
) as screens ) as screens
FROM screen_groups sg FROM screen_groups sg
${whereClause} ${whereClause}
@ -111,6 +114,7 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) =
) FROM screen_group_screens sgs ) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id WHERE sgs.group_id = sg.id
AND sd.is_active != 'D'
) as screens ) as screens
FROM screen_groups sg FROM screen_groups sg
WHERE sg.id = $1 WHERE sg.id = $1
@ -1737,7 +1741,9 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
}); });
// 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용) // 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용)
// screen_layouts (v1)와 screen_layouts_v2 모두 조회
const rightPanelQuery = ` const rightPanelQuery = `
-- V1: screen_layouts에서
SELECT SELECT
sd.screen_id, sd.screen_id,
sd.screen_name, sd.screen_name,
@ -1750,6 +1756,23 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_id = ANY($1) WHERE sd.screen_id = ANY($1)
AND sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL AND sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL
UNION ALL
-- V2: screen_layouts_v2에서 (v2-split-panel-layout )
SELECT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
comp->'overrides'->>'type' as component_type,
comp->'overrides'->'rightPanel'->'relation' as right_panel_relation,
comp->'overrides'->'rightPanel'->>'tableName' as right_panel_table,
comp->'overrides'->'rightPanel'->'columns' as right_panel_columns
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp
WHERE sd.screen_id = ANY($1)
AND comp->'overrides'->'rightPanel'->'relation' IS NOT NULL
`; `;
const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]); const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]);
@ -2118,9 +2141,56 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
})) }))
}); });
// ============================================================
// 6. 전역 메인 테이블 목록 수집 (우선순위 적용용)
// ============================================================
// 메인 테이블 조건:
// 1. screen_definitions.table_name (컴포넌트 직접 연결)
// 2. v2-split-panel-layout의 rightPanel.tableName (WHERE 조건 대상)
//
// 이 목록에 있으면 서브 테이블로 분류되지 않음 (우선순위: 메인 > 서브)
const globalMainTablesQuery = `
-- 1. (screen_definitions.table_name)
SELECT DISTINCT table_name as main_table
FROM screen_definitions
WHERE screen_id = ANY($1)
AND table_name IS NOT NULL
UNION
-- 2. v2-split-panel-layout의 rightPanel.tableName (WHERE )
-- -
SELECT DISTINCT comp->'overrides'->'rightPanel'->>'tableName' as main_table
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp
WHERE sd.screen_id = ANY($1)
AND comp->'overrides'->'rightPanel'->>'tableName' IS NOT NULL
UNION
-- 3. v1 screen_layouts의 rightPanel.tableName (WHERE )
SELECT DISTINCT sl.properties->'componentConfig'->'rightPanel'->>'tableName' as main_table
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_id = ANY($1)
AND sl.properties->'componentConfig'->'rightPanel'->>'tableName' IS NOT NULL
`;
const globalMainTablesResult = await pool.query(globalMainTablesQuery, [screenIds]);
const globalMainTables = globalMainTablesResult.rows
.map((r: any) => r.main_table)
.filter((t: string) => t != null && t !== '');
logger.info("전역 메인 테이블 목록 수집 완료", {
count: globalMainTables.length,
tables: globalMainTables
});
res.json({ res.json({
success: true, success: true,
data: screenSubTables, data: screenSubTables,
globalMainTables: globalMainTables, // 메인 테이블로 분류되어야 하는 테이블 목록
}); });
} catch (error: any) { } catch (error: any) {
logger.error("화면 서브 테이블 정보 조회 실패:", error); logger.error("화면 서브 테이블 정보 조회 실패:", error);

View File

@ -2344,6 +2344,8 @@ export async function getTableEntityRelations(
* *
* table_type_columns에서 reference_table이 * table_type_columns에서 reference_table이
* FK . * FK .
*
* 우선순위: 현재 company_code > ('*')
*/ */
export async function getReferencedByTables( export async function getReferencedByTables(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -2351,9 +2353,11 @@ export async function getReferencedByTables(
): Promise<void> { ): Promise<void> {
try { try {
const { tableName } = req.params; const { tableName } = req.params;
// 현재 사용자의 회사 코드 (없으면 '*' 사용)
const userCompanyCode = req.user?.companyCode || "*";
logger.info( logger.info(
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===` `=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 (회사코드: ${userCompanyCode}) ===`
); );
if (!tableName) { if (!tableName) {
@ -2371,23 +2375,41 @@ export async function getReferencedByTables(
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회 // table_type_columns에서 reference_table이 현재 테이블인 레코드 조회
// input_type이 'entity'인 것만 조회 (실제 FK 관계) // input_type이 'entity'인 것만 조회 (실제 FK 관계)
// 우선순위: 현재 사용자의 company_code > 공통('*')
// ROW_NUMBER를 사용해서 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택
const sqlQuery = ` const sqlQuery = `
WITH ranked AS (
SELECT
ttc.table_name,
ttc.column_name,
ttc.column_label,
ttc.reference_table,
ttc.reference_column,
ttc.display_column,
ttc.company_code,
ROW_NUMBER() OVER (
PARTITION BY ttc.table_name, ttc.column_name
ORDER BY CASE WHEN ttc.company_code = $2 THEN 1 ELSE 2 END
) as rn
FROM table_type_columns ttc
WHERE ttc.reference_table = $1
AND ttc.input_type = 'entity'
AND ttc.company_code IN ($2, '*')
)
SELECT DISTINCT SELECT DISTINCT
ttc.table_name, table_name,
ttc.column_name, column_name,
ttc.column_label, column_label,
ttc.reference_table, reference_table,
ttc.reference_column, reference_column,
ttc.display_column, display_column,
ttc.table_name as table_label table_name as table_label
FROM table_type_columns ttc FROM ranked
WHERE ttc.reference_table = $1 WHERE rn = 1
AND ttc.input_type = 'entity' ORDER BY table_name, column_name
AND ttc.company_code = '*'
ORDER BY ttc.table_name, ttc.column_name
`; `;
const result = await query(sqlQuery, [tableName]); const result = await query(sqlQuery, [tableName, userCompanyCode]);
const referencedByTables = result.map((row: any) => ({ const referencedByTables = result.map((row: any) => ({
tableName: row.table_name, tableName: row.table_name,
@ -2400,7 +2422,7 @@ export async function getReferencedByTables(
})); }));
logger.info( logger.info(
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견` `테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})`
); );
const response: ApiResponse<any> = { const response: ApiResponse<any> = {

View File

@ -11,6 +11,7 @@ import {
generateTempToken, generateTempToken,
getFileByToken, getFileByToken,
setRepresentativeFile, setRepresentativeFile,
getFileInfo,
} from "../controllers/fileController"; } from "../controllers/fileController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -24,6 +25,20 @@ const router = Router();
*/ */
router.get("/public/:token", getFileByToken); router.get("/public/:token", getFileByToken);
/**
* @route GET /api/files/preview/:objid
* @desc ( ) -
* @access Public
*/
router.get("/preview/:objid", previewFile);
/**
* @route GET /api/files/info/:objid
* @desc (, ) -
* @access Public
*/
router.get("/info/:objid", getFileInfo);
// 모든 파일 API는 인증 필요 // 모든 파일 API는 인증 필요
router.use(authenticateToken); router.use(authenticateToken);
@ -64,12 +79,7 @@ router.get("/linked/:tableName/:recordId", getLinkedFiles);
*/ */
router.delete("/:objid", deleteFile); router.delete("/:objid", deleteFile);
/** // preview 라우트는 상단 공개 접근 구역으로 이동됨
* @route GET /api/files/preview/:objid
* @desc ( )
* @access Private
*/
router.get("/preview/:objid", previewFile);
/** /**
* @route GET /api/files/download/:objid * @route GET /api/files/download/:objid

View File

@ -845,6 +845,9 @@ export class NodeFlowExecutionService {
logger.info( logger.info(
`📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}` `📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}`
); );
// 🔍 디버깅: sourceData 내용 출력
logger.info(`📊 [테이블소스] sourceData 필드: ${JSON.stringify(Object.keys(context.sourceData[0]))}`);
logger.info(`📊 [테이블소스] sourceData.sabun: ${context.sourceData[0]?.sabun}`);
return context.sourceData; return context.sourceData;
} }

View File

@ -886,8 +886,9 @@ class NumberingRuleService {
.sort((a: any, b: any) => a.order - b.order) .sort((a: any, b: any) => a.order - b.order)
.map((part: any) => { .map((part: any) => {
if (part.generationMethod === "manual") { if (part.generationMethod === "manual") {
// 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력) // 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
return part.manualConfig?.placeholder || "____"; // placeholder 텍스트는 프론트엔드에서 별도로 표시
return "____";
} }
const autoConfig = part.autoConfig || {}; const autoConfig = part.autoConfig || {};
@ -1014,11 +1015,13 @@ class NumberingRuleService {
* @param ruleId ID * @param ruleId ID
* @param companyCode * @param companyCode
* @param formData ( ) * @param formData ( )
* @param userInputCode ( )
*/ */
async allocateCode( async allocateCode(
ruleId: string, ruleId: string,
companyCode: string, companyCode: string,
formData?: Record<string, any> formData?: Record<string, any>,
userInputCode?: string
): Promise<string> { ): Promise<string> {
const pool = getPool(); const pool = getPool();
const client = await pool.connect(); const client = await pool.connect();
@ -1029,11 +1032,107 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode); const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다"); if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
const manualParts = rule.parts.filter((p: any) => p.generationMethod === "manual");
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
// 프리뷰 코드를 생성해서 ____ 위치 파악
// 🔧 category 파트도 처리하여 올바른 템플릿 생성
const previewParts = rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length); // 순번 자리 표시
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART"; // 날짜 자리 표시
case "category": {
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
return "CATEGORY"; // 폴백
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) {
return "CATEGORY"; // 폴백
}
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find(
(m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
}
);
return mapping?.format || "CATEGORY";
}
default:
return "";
}
});
const separator = rule.separator || "";
const previewTemplate = previewParts.join(separator);
// 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
// prefix 이후 부분 추출
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
// suffix 이전까지가 수동 입력 값
if (suffix) {
// suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length;
if (manualEndIndex > 0) {
extractedManualValues.push(remainingCode.slice(0, manualEndIndex));
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedManualValues.push(remainingCode);
}
}
}
logger.info(`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`);
}
let manualPartIndex = 0;
const parts = rule.parts const parts = rule.parts
.sort((a: any, b: any) => a.order - b.order) .sort((a: any, b: any) => a.order - b.order)
.map((part: any) => { .map((part: any) => {
if (part.generationMethod === "manual") { if (part.generationMethod === "manual") {
return part.manualConfig?.value || ""; // 추출된 수동 입력 값 사용, 없으면 기본값 사용
const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || "";
manualPartIndex++;
return manualValue;
} }
const autoConfig = part.autoConfig || {}; const autoConfig = part.autoConfig || {};
@ -1096,6 +1195,68 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT"; return autoConfig.textValue || "TEXT";
} }
case "category": {
// 카테고리 기반 코드 생성 (allocateCode용)
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData });
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("allocateCode: 카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length
});
if (!selectedValue) {
logger.warn("allocateCode: 카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) });
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find(
(m: any) => {
// ID로 매칭
if (m.categoryValueId?.toString() === selectedValueStr) return true;
// 라벨로 매칭
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
}
);
if (mapping) {
logger.info("allocateCode: 카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel
});
return mapping.format || "";
}
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel
}))
});
return "";
}
default: default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType }); logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return ""; return "";

View File

@ -119,17 +119,14 @@ export class ScheduleService {
companyCode companyCode
); );
toCreate.push(...schedules); toCreate.push(...schedules);
totalQty += schedules.reduce( totalQty += schedules.reduce((sum, s) => sum + (s.plan_qty || 0), 0);
(sum, s) => sum + (s.plan_qty || 0),
0
);
} }
// 3. 기존 스케줄 조회 (삭제 대상) // 3. 기존 스케줄 조회 (삭제 대상)
// 그룹 키에서 리소스 ID만 추출 ("리소스ID|날짜" 형식에서 "리소스ID"만) // 그룹 키에서 리소스 ID만 추출 ("리소스ID|날짜" 형식에서 "리소스ID"만)
const resourceIds = [...new Set( const resourceIds = [
Object.keys(groupedData).map((key) => key.split("|")[0]) ...new Set(Object.keys(groupedData).map((key) => key.split("|")[0])),
)]; ];
const toDelete = await this.getExistingSchedules( const toDelete = await this.getExistingSchedules(
config.scheduleType, config.scheduleType,
resourceIds, resourceIds,
@ -369,7 +366,9 @@ export class ScheduleService {
let groupKey = resourceId; let groupKey = resourceId;
if (dueDateField && item[dueDateField]) { if (dueDateField && item[dueDateField]) {
// 날짜를 YYYY-MM-DD 형식으로 정규화 // 날짜를 YYYY-MM-DD 형식으로 정규화
const dueDate = new Date(item[dueDateField]).toISOString().split("T")[0]; const dueDate = new Date(item[dueDateField])
.toISOString()
.split("T")[0];
groupKey = `${resourceId}|${dueDate}`; groupKey = `${resourceId}|${dueDate}`;
} }
@ -403,8 +402,7 @@ export class ScheduleService {
// 그룹 키에서 리소스ID와 기준일 분리 // 그룹 키에서 리소스ID와 기준일 분리
const [resourceId, groupDueDate] = groupKey.split("|"); const [resourceId, groupDueDate] = groupKey.split("|");
const resourceName = const resourceName = items[0]?.[config.resource.nameField] || resourceId;
items[0]?.[config.resource.nameField] || resourceId;
// 총 수량 계산 // 총 수량 계산
const totalQty = items.reduce((sum, item) => { const totalQty = items.reduce((sum, item) => {
@ -469,7 +467,9 @@ export class ScheduleService {
plan_qty: totalQty, plan_qty: totalQty,
status: "PLANNED", status: "PLANNED",
source_table: config.source.tableName, source_table: config.source.tableName,
source_id: items.map((i) => i.id || i.order_no || i.sales_order_no).join(","), source_id: items
.map((i) => i.id || i.order_no || i.sales_order_no)
.join(","),
source_group_key: resourceId, source_group_key: resourceId,
metadata: { metadata: {
sourceCount: items.length, sourceCount: items.length,

View File

@ -731,6 +731,14 @@ export class ScreenManagementService {
WHERE screen_id = $1 AND is_active = 'Y'`, WHERE screen_id = $1 AND is_active = 'Y'`,
[screenId], [screenId],
); );
// 5. 화면 그룹 연결 삭제 (screen_group_screens)
await client.query(
`DELETE FROM screen_group_screens WHERE screen_id = $1`,
[screenId],
);
logger.info("화면 삭제 시 그룹 연결 해제", { screenId });
}); });
} }
@ -5110,18 +5118,6 @@ export class ScreenManagementService {
console.log( console.log(
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
); );
// 🐛 디버깅: finished_timeline의 fieldMapping 확인
const splitPanel = layout.layout_data?.components?.find((c: any) =>
c.url?.includes("v2-split-panel-layout")
);
const finishedTimeline = splitPanel?.overrides?.rightPanel?.components?.find(
(c: any) => c.id === "finished_timeline"
);
if (finishedTimeline) {
console.log("🐛 [Backend] finished_timeline fieldMapping:", JSON.stringify(finishedTimeline.componentConfig?.fieldMapping));
}
return layout.layout_data; return layout.layout_data;
} }
@ -5161,20 +5157,16 @@ export class ScreenManagementService {
...layoutData ...layoutData
}; };
// SUPER_ADMIN인 경우 화면 정의의 company_code로 저장 (로드와 일관성 유지)
const saveCompanyCode = companyCode === "*" ? existingScreen.company_code : companyCode;
console.log(`저장할 company_code: ${saveCompanyCode} (원본: ${companyCode}, 화면 정의: ${existingScreen.company_code})`);
// UPSERT (있으면 업데이트, 없으면 삽입) // UPSERT (있으면 업데이트, 없으면 삽입)
await query( await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW()) VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code) ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()`, DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[screenId, saveCompanyCode, JSON.stringify(dataToSave)], [screenId, companyCode, JSON.stringify(dataToSave)],
); );
console.log(`V2 레이아웃 저장 완료 (company_code: ${saveCompanyCode})`); console.log(`V2 레이아웃 저장 완료`);
} }
} }

View File

@ -322,7 +322,9 @@ export class TableManagementService {
}); });
} else { } else {
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행 // menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"); logger.info(
"⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"
);
} }
} catch (mappingError: any) { } catch (mappingError: any) {
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", { logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
@ -488,7 +490,10 @@ export class TableManagementService {
// table_type_columns에 모든 설정 저장 (멀티테넌시 지원) // table_type_columns에 모든 설정 저장 (멀티테넌시 지원)
// detailSettings가 문자열이면 그대로, 객체면 JSON.stringify // detailSettings가 문자열이면 그대로, 객체면 JSON.stringify
let detailSettingsStr = settings.detailSettings; let detailSettingsStr = settings.detailSettings;
if (typeof settings.detailSettings === "object" && settings.detailSettings !== null) { if (
typeof settings.detailSettings === "object" &&
settings.detailSettings !== null
) {
detailSettingsStr = JSON.stringify(settings.detailSettings); detailSettingsStr = JSON.stringify(settings.detailSettings);
} }
@ -749,7 +754,8 @@ export class TableManagementService {
); );
// 웹 타입별 기본 상세 설정 생성 // 웹 타입별 기본 상세 설정 생성
const defaultDetailSettings = this.generateDefaultDetailSettings(finalWebType); const defaultDetailSettings =
this.generateDefaultDetailSettings(finalWebType);
// 사용자 정의 설정과 기본 설정 병합 // 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = { const finalDetailSettings = {
@ -768,7 +774,12 @@ export class TableManagementService {
input_type = EXCLUDED.input_type, input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings, detail_settings = EXCLUDED.detail_settings,
updated_date = NOW()`, updated_date = NOW()`,
[tableName, columnName, finalWebType, JSON.stringify(finalDetailSettings)] [
tableName,
columnName,
finalWebType,
JSON.stringify(finalDetailSettings),
]
); );
logger.info( logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}` `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}`
@ -1461,6 +1472,44 @@ export class TableManagementService {
}); });
} }
// 🔧 파이프로 구분된 문자열 처리 (객체에서 추출한 actualValue도 처리)
if (typeof actualValue === "string" && actualValue.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
// 날짜 타입이면 날짜 범위로 처리
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(
columnName,
actualValue,
paramIndex
);
}
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = actualValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용 (객체에서 추출): ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음 // "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
if ( if (
actualValue === "__ALL__" || actualValue === "__ALL__" ||
@ -3369,14 +3418,16 @@ export class TableManagementService {
if (options.search) { if (options.search) {
for (const [key, value] of Object.entries(options.search)) { for (const [key, value] of Object.entries(options.search)) {
// 검색값 추출 (객체 형태일 수 있음) // 검색값 및 operator 추출 (객체 형태일 수 있음)
let searchValue = value; let searchValue = value;
let operator = "contains"; // 기본값: 부분 일치
if ( if (
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
"value" in value "value" in value
) { ) {
searchValue = value.value; searchValue = value.value;
operator = (value as any).operator || "contains";
} }
// 빈 값이면 스킵 // 빈 값이면 스킵
@ -3428,15 +3479,49 @@ export class TableManagementService {
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색 // 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`; const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
const alias = aliasMap.get(aliasKey); const alias = aliasMap.get(aliasKey);
whereConditions.push(
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'` // 🔧 파이프로 구분된 다중 선택값 처리
); if (safeValue.includes("|")) {
entitySearchColumns.push( const multiValues = safeValue
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` .split("|")
); .filter((v: string) => v.trim() !== "");
logger.info( if (multiValues.length > 0) {
`🎯 Entity 조인 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})` const inClause = multiValues
); .map((v: string) => `'${v}'`)
.join(", ");
whereConditions.push(
`${alias}.${joinConfig.displayColumn}::text IN (${inClause})`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 다중선택 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})`
);
}
} else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치
whereConditions.push(
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 정확히 일치 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
);
} else {
// 기본: 부분 일치 (ILIKE)
whereConditions.push(
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
);
}
} else if (key === "writer_dept_code") { } else if (key === "writer_dept_code") {
// writer_dept_code: user_info.dept_code에서 검색 // writer_dept_code: user_info.dept_code에서 검색
const userAliasKey = Array.from(aliasMap.keys()).find((k) => const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
@ -3473,10 +3558,33 @@ export class TableManagementService {
} }
} else { } else {
// 일반 컬럼인 경우: 메인 테이블에서 검색 // 일반 컬럼인 경우: 메인 테이블에서 검색
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`); // 🔧 파이프로 구분된 다중 선택값 처리
logger.info( if (safeValue.includes("|")) {
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'` const multiValues = safeValue
); .split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const inClause = multiValues
.map((v: string) => `'${v}'`)
.join(", ");
whereConditions.push(`main.${key}::text IN (${inClause})`);
logger.info(
`🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})`
);
}
} else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치
whereConditions.push(`main.${key}::text = '${safeValue}'`);
logger.info(
`🔍 정확히 일치 검색: ${key} → main.${key} = '${safeValue}'`
);
} else {
// 기본: 부분 일치 (ILIKE)
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
logger.info(
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
);
}
} }
} }
} }

View File

@ -12,6 +12,13 @@ services:
environment: environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api - NEXT_PUBLIC_API_URL=http://localhost:8080/api
- WATCHPACK_POLLING=true - WATCHPACK_POLLING=true
- NODE_OPTIONS=--max-old-space-size=4096
deploy:
resources:
limits:
memory: 6G
reservations:
memory: 2G
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules

View File

@ -9,6 +9,8 @@ services:
- "9771:3000" - "9771:3000"
environment: environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api - NEXT_PUBLIC_API_URL=http://localhost:8080/api
- NODE_OPTIONS=--max-old-space-size=8192
- NEXT_TELEMETRY_DISABLED=1
volumes: volumes:
- ../../frontend:/app - ../../frontend:/app
- /app/node_modules - /app/node_modules

View File

@ -0,0 +1,729 @@
# Flow 기반 반응형 레이아웃 설계서
> 작성일: 2026-01-30
> 목표: 진정한 반응형 구현 (PC/태블릿/모바일 전체 대응)
---
## 1. 핵심 결론
### 1.1 현재 방식 vs 반응형 표준
| 항목 | 현재 시스템 | 웹 표준 (2025) |
|------|-------------|----------------|
| 배치 방식 | `position: absolute` | **Flexbox / CSS Grid** |
| 좌표 | 픽셀 고정 (x, y) | **Flow 기반 (순서)** |
| 화면 축소 시 | 그대로 (잘림) | **자동 재배치** |
| 용도 | 툴팁, 오버레이 | **전체 레이아웃** |
> **결론**: `position: absolute`는 전체 레이아웃에 사용하면 안 됨 (웹 표준)
### 1.2 구현 방향
```
절대 좌표 (x, y 픽셀)
↓ 변환
Flow 기반 배치 (Flexbox + Grid)
↓ 결과
화면 크기에 따라 자동 재배치
```
---
## 2. 실제 화면 데이터 분석
### 2.1 분석 대상
```
총 레이아웃: 1,250개
총 컴포넌트: 5,236개
분석 샘플: 6개 화면 (23, 20, 18, 16, 18, 5개 컴포넌트)
```
### 2.2 화면 68 (수주 목록) - 가로 배치 패턴
```
y=88: [분리] [저장] [수정] [삭제] ← 같은 행에 버튼 4개
x=1277 x=1436 x=1594 x=1753
y=128: [────────── 테이블 ──────────]
x=8, width=1904
```
**변환 후**:
```html
<div class="flex flex-wrap justify-end gap-2"> <!-- Row 1 -->
<button>분리</button>
<button>저장</button>
<button>수정</button>
<button>삭제</button>
</div>
<div class="w-full"> <!-- Row 2 -->
<Table />
</div>
```
**반응형 동작**:
```
1920px: [분리] [저장] [수정] [삭제] ← 가로 배치
1280px: [분리] [저장] [수정] [삭제] ← 가로 배치 (공간 충분)
768px: [분리] [저장] ← 줄바꿈 발생
[수정] [삭제]
375px: [분리] ← 세로 배치
[저장]
[수정]
[삭제]
```
### 2.3 화면 119 (장치 관리) - 2열 폼 패턴
```
y=80: [장치 코드 ] [시리얼넘버 ]
x=136, w=256 x=408, w=256
y=160: [제조사 ]
x=136, w=528
y=240: [품번 ] [모델명 ]
x=136, w=256 x=408, w=256
y=320: [구매일 ] [상태 ]
y=400: [공급사 ] [구매 가격 ]
y=480: [계약 번호 ] [공급사 전화 ]
... (2열 반복)
y=840: [저장]
x=544
```
**변환 후**:
```html
<div class="grid grid-cols-2 gap-4"> <!-- 2열 그리드 -->
<Input label="장치 코드" />
<Input label="시리얼넘버" />
</div>
<div class="w-full"> <!-- 전체 너비 -->
<Input label="제조사" />
</div>
<div class="grid grid-cols-2 gap-4">
<Input label="품번" />
<Select label="모델명" />
</div>
<!-- ... 반복 ... -->
<div class="flex justify-center">
<Button>저장</Button>
</div>
```
**반응형 동작**:
```
1920px: [장치 코드] [시리얼넘버] ← 2열
1280px: [장치 코드] [시리얼넘버] ← 2열
768px: [장치 코드] ← 1열
[시리얼넘버]
375px: [장치 코드] ← 1열
[시리얼넘버]
```
### 2.4 화면 4103 (수주 등록) - 섹션 기반 패턴
```
y=20: [섹션: 옵션 설정 ]
y=35: [입력방식▼] [판매유형▼] [단가방식▼] [☑ 단가수정]
y=110: [섹션: 거래처 정보 ]
y=190: [거래처 * ] [담당자 ] [납품처 ] [납품장소 ]
y=260: [섹션: 추가된 품목 ]
y=360: [리피터 테이블 ]
y=570: [섹션: 무역 정보 ]
y=690: [인코텀즈▼] [결제조건▼] [통화▼ ]
y=740: [선적항 ] [도착항 ] [HS Code ]
y=890: [섹션: 추가 정보 ]
y=935: [메모 ]
y=1080: [저장]
```
**변환 후**:
```html
<Card title="옵션 설정">
<div class="flex flex-wrap gap-4">
<Select label="입력방식" />
<Select label="판매유형" />
<Select label="단가방식" />
<Checkbox label="단가수정 허용" />
</div>
</Card>
<Card title="거래처 정보">
<div class="grid grid-cols-4 gap-4"> <!-- 4열 그리드 -->
<Select label="거래처 *" />
<Input label="담당자" />
<Input label="납품처" />
<Input label="납품장소" class="col-span-2" />
</div>
</Card>
<!-- ... 섹션 반복 ... -->
<div class="flex justify-end">
<Button>저장</Button>
</div>
```
**반응형 동작**:
```
1920px: [입력방식] [판매유형] [단가방식] [단가수정] ← 4열
1280px: [입력방식] [판매유형] [단가방식] ← 3열
[단가수정]
768px: [입력방식] [판매유형] ← 2열
[단가방식] [단가수정]
375px: [입력방식] ← 1열
[판매유형]
[단가방식]
[단가수정]
```
---
## 3. 변환 규칙
### 3.1 Row 그룹화 알고리즘
```typescript
const ROW_THRESHOLD = 40; // px
function groupByRows(components: Component[]): Row[] {
// 1. y 좌표로 정렬
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
const rows: Row[] = [];
let currentRow: Component[] = [];
let currentY = -Infinity;
for (const comp of sorted) {
if (comp.position.y - currentY > ROW_THRESHOLD) {
// 새로운 Row 시작
if (currentRow.length > 0) {
rows.push({
y: currentY,
components: currentRow.sort((a, b) => a.position.x - b.position.x)
});
}
currentRow = [comp];
currentY = comp.position.y;
} else {
// 같은 Row에 추가
currentRow.push(comp);
}
}
// 마지막 Row 추가
if (currentRow.length > 0) {
rows.push({
y: currentY,
components: currentRow.sort((a, b) => a.position.x - b.position.x)
});
}
return rows;
}
```
### 3.2 화면 68 적용 예시
**입력**:
```json
[
{ "id": "comp_1899", "position": { "x": 1277, "y": 88 }, "text": "분리" },
{ "id": "comp_1898", "position": { "x": 1436, "y": 88 }, "text": "저장" },
{ "id": "comp_1897", "position": { "x": 1594, "y": 88 }, "text": "수정" },
{ "id": "comp_1896", "position": { "x": 1753, "y": 88 }, "text": "삭제" },
{ "id": "comp_1895", "position": { "x": 8, "y": 128 }, "type": "table" }
]
```
**변환 결과**:
```json
{
"rows": [
{
"y": 88,
"justify": "end",
"components": ["comp_1899", "comp_1898", "comp_1897", "comp_1896"]
},
{
"y": 128,
"justify": "start",
"components": ["comp_1895"]
}
]
}
```
### 3.3 정렬 방향 결정
```typescript
function determineJustify(row: Row, screenWidth: number): string {
const firstX = row.components[0].position.x;
const lastComp = row.components[row.components.length - 1];
const lastEnd = lastComp.position.x + lastComp.size.width;
// 왼쪽 여백 vs 오른쪽 여백 비교
const leftMargin = firstX;
const rightMargin = screenWidth - lastEnd;
if (leftMargin > rightMargin * 2) {
return "end"; // 오른쪽 정렬
} else if (rightMargin > leftMargin * 2) {
return "start"; // 왼쪽 정렬
} else {
return "center"; // 중앙 정렬
}
}
// 화면 68 버튼 그룹:
// leftMargin = 1277, rightMargin = 1920 - 1912 = 8
// → "end" (오른쪽 정렬)
```
---
## 4. 렌더링 구현
### 4.1 새로운 FlowLayout 컴포넌트
```tsx
// frontend/lib/registry/layouts/flow/FlowLayout.tsx
interface FlowLayoutProps {
layout: LayoutData;
renderer: DynamicComponentRenderer;
}
export function FlowLayout({ layout, renderer }: FlowLayoutProps) {
// 1. Row 그룹화
const rows = useMemo(() => {
return groupByRows(layout.components);
}, [layout.components]);
return (
<div className="flex flex-col gap-4 w-full">
{rows.map((row, index) => (
<FlowRow
key={index}
row={row}
renderer={renderer}
/>
))}
</div>
);
}
function FlowRow({ row, renderer }: { row: Row; renderer: any }) {
const justify = determineJustify(row, 1920);
const justifyClass = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
}[justify];
return (
<div className={`flex flex-wrap gap-2 ${justifyClass}`}>
{row.components.map((comp) => (
<div
key={comp.id}
style={{
minWidth: comp.size.width,
// width는 고정하지 않음 (flex로 자동 조정)
}}
>
{renderer.renderChild(comp)}
</div>
))}
</div>
);
}
```
### 4.2 기존 코드 수정 위치
**현재 (RealtimePreviewDynamic.tsx 라인 524-536)**:
```tsx
const baseStyle = {
left: `${adjustedPositionX}px`, // ❌ 절대 좌표
top: `${position.y}px`, // ❌ 절대 좌표
position: "absolute", // ❌ 절대 위치
};
```
**변경 후**:
```tsx
// FlowLayout 사용 시 position 관련 스타일 제거
const baseStyle = isFlowMode ? {
// position, left, top 없음
minWidth: size.width,
height: size.height,
} : {
left: `${adjustedPositionX}px`,
top: `${position.y}px`,
position: "absolute",
};
```
---
## 5. 가상 시뮬레이션
### 5.1 시나리오 1: 화면 68 (버튼 4개 + 테이블)
**렌더링 결과 (1920px)**:
```
┌────────────────────────────────────────────────────────────────────────┐
│ [분리] [저장] [수정] [삭제] │
│ flex-wrap, justify-end │
├────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 테이블 (w-full) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
✅ 정상: 버튼 오른쪽 정렬, 테이블 전체 너비
```
**렌더링 결과 (1280px)**:
```
┌─────────────────────────────────────────────┐
│ [분리] [저장] [수정] [삭제] │
│ flex-wrap, justify-end │
├─────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────┐ │
│ │ 테이블 (w-full) │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
✅ 정상: 버튼 크기 유지, 테이블 너비 조정
```
**렌더링 결과 (768px)**:
```
┌──────────────────────────┐
│ [분리] [저장] │
│ [수정] [삭제] │ ← 자동 줄바꿈!
├──────────────────────────┤
│ ┌──────────────────────┐ │
│ │ 테이블 (w-full) │ │
│ └──────────────────────┘ │
└──────────────────────────┘
✅ 정상: 버튼 줄바꿈, 테이블 너비 조정
```
**렌더링 결과 (375px)**:
```
┌─────────────┐
│ [분리] │
│ [저장] │
│ [수정] │
│ [삭제] │ ← 세로 배치
├─────────────┤
│ ┌─────────┐ │
│ │ 테이블 │ │ (가로 스크롤)
│ └─────────┘ │
└─────────────┘
✅ 정상: 버튼 세로 배치, 테이블 가로 스크롤
```
### 5.2 시나리오 2: 화면 119 (2열 폼)
**렌더링 결과 (1920px)**:
```
┌────────────────────────────────────────────────────────────────────────┐
│ [장치 코드 ] [시리얼넘버 ] │
│ grid-cols-2 │
├────────────────────────────────────────────────────────────────────────┤
│ [제조사 ] │
│ col-span-2 (전체 너비) │
├────────────────────────────────────────────────────────────────────────┤
│ [품번 ] [모델명▼ ] │
│ ... │
└────────────────────────────────────────────────────────────────────────┘
✅ 정상: 2열 그리드
```
**렌더링 결과 (768px)**:
```
┌──────────────────────────┐
│ [장치 코드 ] │
│ [시리얼넘버 ] │ ← 1열로 변경
├──────────────────────────┤
│ [제조사 ] │
├──────────────────────────┤
│ [품번 ] │
│ [모델명▼ ] │
│ ... │
└──────────────────────────┘
✅ 정상: 1열 그리드
```
### 5.3 시나리오 3: 분할 패널
**현재 SplitPanelLayout 동작**:
```
좌측 60% | 우측 40% ← 이미 퍼센트 기반
```
**변경 후 (768px 이하)**:
```
┌────────────────────┐
│ 좌측 100% │
├────────────────────┤
│ 우측 100% │
└────────────────────┘
← 세로 배치로 전환
```
**구현**:
```tsx
// SplitPanelLayoutComponent.tsx
const isMobile = useMediaQuery("(max-width: 768px)");
return (
<div className={isMobile ? "flex-col" : "flex-row"}>
<div className={isMobile ? "w-full" : "w-[60%]"}>
{/* 좌측 패널 */}
</div>
<div className={isMobile ? "w-full" : "w-[40%]"}>
{/* 우측 패널 */}
</div>
</div>
);
```
---
## 6. 엣지 케이스 검증
### 6.1 겹치는 컴포넌트
**현재 데이터 (화면 74)**:
```json
{ "id": "comp_2606", "position": { "x": 161, "y": 400 } }, // 분할 패널
{ "id": "comp_fkk75q08", "position": { "x": 161, "y": 400 } } // 라디오 버튼
```
**문제**: 같은 위치에 두 컴포넌트 → z-index로 겹쳐서 표시
**해결**:
- z-index가 높은 컴포넌트 우선
- 또는 parent-child 관계면 중첩 처리
```typescript
function resolveOverlaps(row: Row): Row {
// z-index로 정렬하여 높은 것만 표시
// 또는 parentId 확인하여 중첩 처리
}
```
### 6.2 조건부 표시 컴포넌트
**현재 데이터 (화면 4103)**:
```json
{
"id": "section-customer-info",
"conditionalConfig": {
"field": "input_method",
"value": "customer_first",
"action": "show"
}
}
```
**동작**: 조건에 따라 show/hide
**Flow 레이아웃에서**: 숨겨지면 공간도 사라짐 (flex 자동 조정)
✅ 문제없음
### 6.3 테이블 + 버튼 조합
**패턴**:
```
[버튼 그룹] ← flex-wrap, justify-end
[테이블] ← w-full
```
**테이블 가로 스크롤**:
- 테이블 내부는 가로 스크롤 지원
- 외부 컨테이너는 w-full
✅ 문제없음
### 6.4 섹션 카드 내부 컴포넌트
**현재**: 섹션 카드와 내부 컴포넌트가 별도로 저장됨
**변환 시**:
1. 섹션 카드의 y 범위 파악
2. 해당 y 범위 내 컴포넌트들을 섹션 자식으로 그룹화
3. 섹션 내부에서 다시 Row 그룹화
```typescript
function groupWithinSection(
section: Component,
allComponents: Component[]
): Component[] {
const sectionTop = section.position.y;
const sectionBottom = section.position.y + section.size.height;
return allComponents.filter(comp => {
return comp.id !== section.id &&
comp.position.y >= sectionTop &&
comp.position.y < sectionBottom;
});
}
```
---
## 7. 호환성 검증
### 7.1 기존 기능 호환
| 기능 | 호환 여부 | 설명 |
|------|----------|------|
| 디자인 모드 | ⚠️ 수정 필요 | 드래그 앤 드롭 로직 수정 |
| 미리보기 | ✅ 호환 | Flow 레이아웃으로 렌더링 |
| 조건부 표시 | ✅ 호환 | flex로 자동 조정 |
| 분할 패널 | ⚠️ 수정 필요 | 반응형 전환 로직 추가 |
| 테이블 | ✅ 호환 | w-full 적용 |
| 모달 | ✅ 호환 | 모달 내부도 Flow 적용 |
### 7.2 디자인 모드 수정
**현재**: 드래그하면 x, y 픽셀 저장
**변경 후**: 드래그하면 x, y 픽셀 저장 (동일) → 렌더링 시 변환
```
저장: 픽셀 좌표 (기존 유지)
렌더링: Flow 기반으로 변환
```
**장점**: DB 마이그레이션 불필요
---
## 8. 구현 계획
### Phase 1: 핵심 변환 로직 (1일)
1. `groupByRows()` 함수 구현
2. `determineJustify()` 함수 구현
3. `FlowLayout` 컴포넌트 생성
### Phase 2: 렌더링 적용 (1일)
1. `DynamicComponentRenderer`에 Flow 모드 추가
2. `RealtimePreviewDynamic` 수정
3. 기존 absolute 스타일 조건부 적용
### Phase 3: 특수 케이스 처리 (1일)
1. 섹션 카드 내부 그룹화
2. 겹치는 컴포넌트 처리
3. 분할 패널 반응형 전환
### Phase 4: 테스트 (1일)
1. 화면 68 (버튼 + 테이블) 테스트
2. 화면 119 (2열 폼) 테스트
3. 화면 4103 (복잡한 폼) 테스트
4. PC 1920px → 1280px 테스트
5. 태블릿 768px 테스트
6. 모바일 375px 테스트
---
## 9. 예상 이슈
### 9.1 디자이너 의도 손실
**문제**: 디자이너가 의도적으로 배치한 위치가 변경될 수 있음
**해결**:
- 기본 Flow 레이아웃 적용
- 필요시 `flexOrder` 속성으로 순서 조정 가능
- 또는 `fixedPosition: true` 옵션으로 절대 좌표 유지
### 9.2 복잡한 레이아웃
**문제**: 일부 화면은 자유 배치가 필요할 수 있음
**해결**:
- 화면별 `layoutMode` 설정
- `"flow"`: Flow 기반 (기본값)
- `"absolute"`: 기존 절대 좌표
### 9.3 성능
**문제**: 매 렌더링마다 Row 그룹화 계산
**해결**:
- `useMemo`로 캐싱
- 컴포넌트 목록 변경 시에만 재계산
---
## 10. 최종 체크리스트
### 구현 전
- [ ] 현재 동작하는 화면 스크린샷 (비교용)
- [ ] 테스트 화면 목록 확정 (68, 119, 4103)
### 구현 중
- [ ] `groupByRows()` 구현
- [ ] `determineJustify()` 구현
- [ ] `FlowLayout` 컴포넌트 생성
- [ ] `DynamicComponentRenderer` 수정
- [ ] `RealtimePreviewDynamic` 수정
### 테스트
- [ ] 1920px 테스트
- [ ] 1280px 테스트
- [ ] 768px 테스트
- [ ] 375px 테스트
- [ ] 디자인 모드 테스트
- [ ] 분할 패널 테스트
- [ ] 조건부 표시 테스트
---
## 11. 결론
### 11.1 구현 가능 여부
**✅ 가능**
- 기존 데이터 구조 유지 (DB 변경 없음)
- 렌더링 레벨에서만 변환
- 모든 화면 패턴 분석 완료
- 엣지 케이스 해결책 확보
### 11.2 핵심 변경 사항
```
Before: position: absolute + left/top 픽셀
After: Flexbox + flex-wrap + justify-*
```
### 11.3 예상 효과
| 화면 크기 | Before | After |
|-----------|--------|-------|
| 1920px | 정상 | 정상 |
| 1280px | 버튼 잘림 | **자동 조정** |
| 768px | 레이아웃 깨짐 | **자동 재배치** |
| 375px | 사용 불가 | **자동 세로 배치** |

View File

@ -0,0 +1,688 @@
# PC 반응형 구현 계획서
> 작성일: 2026-01-30
> 목표: PC 환경 (1280px ~ 1920px)에서 완벽한 반응형 구현
---
## 1. 목표 정의
### 1.1 범위
| 환경 | 화면 크기 | 우선순위 |
|------|-----------|----------|
| **PC (대형 모니터)** | 1920px | 기준 |
| **PC (노트북)** | 1280px ~ 1440px | **1순위** |
| 태블릿 | 768px ~ 1024px | 2순위 (추후) |
| 모바일 | < 768px | 3순위 (추후) |
### 1.2 목표 동작
```
1920px 화면에서 디자인
1280px 화면으로 축소
컴포넌트들이 비율에 맞게 재배치 (위치, 크기 모두)
레이아웃 깨지지 않음
```
### 1.3 성공 기준
- [ ] 1920px에서 디자인한 화면이 1280px에서 정상 표시
- [ ] 버튼이 화면 밖으로 나가지 않음
- [ ] 테이블이 화면 너비에 맞게 조정됨
- [ ] 분할 패널이 비율 유지하며 축소됨
---
## 2. 현재 시스템 분석
### 2.1 렌더링 흐름 (현재)
```
┌─────────────────────────────────────────────────────────────┐
│ 1. API 호출 │
│ screenApi.getLayoutV2(screenId) │
│ → screen_layouts_v2.layout_data (JSONB) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. 데이터 변환 │
│ convertV2ToLegacy(v2Response) │
│ → components 배열 (position, size 포함) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. 스케일 계산 (page.tsx 라인 395-460) │
│ const designWidth = layout.screenResolution.width || 1200│
│ const newScale = containerWidth / designWidth │
│ → 전체 화면을 scale()로 축소 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. 컴포넌트 렌더링 (RealtimePreviewDynamic.tsx 라인 524-536) │
│ left: `${position.x}px` ← 픽셀 고정 │
│ top: `${position.y}px` ← 픽셀 고정 │
│ position: absolute ← 절대 위치 │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 현재 방식의 문제점
**현재**: `transform: scale()` 방식
```tsx
// page.tsx 라인 515-520
<div style={{
width: `${screenWidth}px`, // 1920px 고정
height: `${screenHeight}px`, // 고정
transform: `scale(${scale})`, // 전체 축소
transformOrigin: "top left",
}}>
```
| 문제 | 설명 |
|------|------|
| **축소만 됨** | 레이아웃 재배치 없음 |
| **폰트 작아짐** | 전체 scale로 폰트도 축소 |
| **클릭 영역 오차** | scale 적용 시 클릭 위치 계산 오류 가능 |
| **진정한 반응형 아님** | 비율만 유지, 레이아웃 최적화 없음 |
### 2.3 position.x, position.y 사용 위치
| 파일 | 라인 | 용도 |
|------|------|------|
| `RealtimePreviewDynamic.tsx` | 524-526 | 컴포넌트 위치 스타일 |
| `AutoRegisteringComponentRenderer.ts` | 42-43 | 공통 컴포넌트 스타일 |
| `page.tsx` | 744-745 | 자식 컴포넌트 상대 위치 |
| `ScreenDesigner.tsx` | 2890-2894 | 드래그 앤 드롭 위치 |
| `ScreenModal.tsx` | 620-621 | 모달 내 오프셋 조정 |
---
## 3. 구현 방식: 퍼센트 기반 배치
### 3.1 핵심 아이디어
```
픽셀 좌표 (1920px 기준)
퍼센트로 변환
화면 크기에 관계없이 비율 유지
```
**예시**:
```
버튼 위치: x=1753px (1920px 기준)
퍼센트: 1753 / 1920 = 91.3%
1280px 화면: 1280 * 0.913 = 1168px
버튼이 화면 안에 정상 표시
```
### 3.2 변환 공식
```typescript
// 픽셀 → 퍼센트 변환
const DESIGN_WIDTH = 1920;
function toPercent(pixelX: number): string {
return `${(pixelX / DESIGN_WIDTH) * 100}%`;
}
// 사용
left: toPercent(position.x) // "91.3%"
width: toPercent(size.width) // "8.2%"
```
### 3.3 Y축 처리
Y축은 두 가지 옵션:
**옵션 A: Y축도 퍼센트 (권장)**
```typescript
const DESIGN_HEIGHT = 1080;
top: `${(position.y / DESIGN_HEIGHT) * 100}%`
```
**옵션 B: Y축은 픽셀 유지**
```typescript
top: `${position.y}px` // 세로는 스크롤로 해결
```
**결정: 옵션 B (Y축 픽셀 유지)**
- 이유: 세로 스크롤은 자연스러움
- 가로만 반응형이면 PC 환경에서 충분
---
## 4. 구현 상세
### 4.1 수정 파일 목록
| 파일 | 수정 내용 |
|------|-----------|
| `RealtimePreviewDynamic.tsx` | left, width를 퍼센트로 변경 |
| `AutoRegisteringComponentRenderer.ts` | left, width를 퍼센트로 변경 |
| `page.tsx` | scale 제거, 컨테이너 width: 100% |
### 4.2 RealtimePreviewDynamic.tsx 수정
**현재 (라인 524-530)**:
```tsx
const baseStyle = {
left: `${adjustedPositionX}px`,
top: `${position.y}px`,
width: displayWidth,
height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2,
};
```
**변경 후**:
```tsx
const DESIGN_WIDTH = 1920;
const baseStyle = {
left: `${(adjustedPositionX / DESIGN_WIDTH) * 100}%`, // 퍼센트
top: `${position.y}px`, // Y축은 픽셀 유지
width: `${(parseFloat(displayWidth) / DESIGN_WIDTH) * 100}%`, // 퍼센트
height: displayHeight, // 높이는 픽셀 유지
zIndex: component.type === "layout" ? 1 : position.z || 2,
};
```
### 4.3 AutoRegisteringComponentRenderer.ts 수정
**현재 (라인 40-48)**:
```tsx
const baseStyle: React.CSSProperties = {
position: "absolute",
left: `${component.position?.x || 0}px`,
top: `${component.position?.y || 0}px`,
width: `${component.size?.width || 200}px`,
height: `${component.size?.height || 36}px`,
zIndex: component.position?.z || 1,
};
```
**변경 후**:
```tsx
const DESIGN_WIDTH = 1920;
const baseStyle: React.CSSProperties = {
position: "absolute",
left: `${((component.position?.x || 0) / DESIGN_WIDTH) * 100}%`, // 퍼센트
top: `${component.position?.y || 0}px`, // Y축은 픽셀 유지
width: `${((component.size?.width || 200) / DESIGN_WIDTH) * 100}%`, // 퍼센트
height: `${component.size?.height || 36}px`, // 높이는 픽셀 유지
zIndex: component.position?.z || 1,
};
```
### 4.4 page.tsx 수정
**현재 (라인 515-528)**:
```tsx
<div
className="bg-background relative"
style={{
width: `${screenWidth}px`,
height: `${screenHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
position: "relative",
}}
>
```
**변경 후**:
```tsx
<div
className="bg-background relative"
style={{
width: "100%", // 전체 너비 사용
minHeight: `${screenHeight}px`, // 최소 높이
position: "relative",
// transform: scale 제거
}}
>
```
### 4.5 공통 상수 파일 생성
```typescript
// frontend/lib/constants/responsive.ts
export const RESPONSIVE_CONFIG = {
DESIGN_WIDTH: 1920,
DESIGN_HEIGHT: 1080,
MIN_WIDTH: 1280,
MAX_WIDTH: 1920,
} as const;
export function toPercentX(pixelX: number): string {
return `${(pixelX / RESPONSIVE_CONFIG.DESIGN_WIDTH) * 100}%`;
}
export function toPercentWidth(pixelWidth: number): string {
return `${(pixelWidth / RESPONSIVE_CONFIG.DESIGN_WIDTH) * 100}%`;
}
```
---
## 5. 가상 시뮬레이션
### 5.1 시뮬레이션 시나리오
**테스트 화면**: screen_id = 68 (수주 목록)
```json
{
"components": [
{
"id": "comp_1895",
"url": "v2-table-list",
"position": { "x": 8, "y": 128 },
"size": { "width": 1904, "height": 600 }
},
{
"id": "comp_1896",
"url": "v2-button-primary",
"position": { "x": 1753, "y": 88 },
"size": { "width": 158, "height": 40 }
},
{
"id": "comp_1897",
"url": "v2-button-primary",
"position": { "x": 1594, "y": 88 },
"size": { "width": 158, "height": 40 }
},
{
"id": "comp_1898",
"url": "v2-button-primary",
"position": { "x": 1436, "y": 88 },
"size": { "width": 158, "height": 40 }
}
]
}
```
### 5.2 현재 방식 시뮬레이션
**1920px 화면**:
```
┌────────────────────────────────────────────────────────────────────────┐
│ [분리] [저장] [수정] [삭제] │
│ 1277 1436 1594 1753 │
├────────────────────────────────────────────────────────────────────────┤
│ x=8 x=1904 │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 테이블 (width: 1904px) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
✅ 정상 표시
```
**1280px 화면 (현재 scale 방식)**:
```
┌─────────────────────────────────────────────┐
│ scale(0.67) 적용 │
│ ┌─────────────────────────────────────────┐ │
│ │ [분리][저][수][삭] │ │ ← 전체 축소, 폰트 작아짐
│ ├─────────────────────────────────────────┤ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ 테이블 (축소됨) │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ (여백 발생) │
└─────────────────────────────────────────────┘
⚠️ 작동하지만 폰트/여백 문제
```
### 5.3 퍼센트 방식 시뮬레이션
**변환 계산**:
```
테이블:
x: 8px → 8/1920 = 0.42%
width: 1904px → 1904/1920 = 99.17%
삭제 버튼:
x: 1753px → 1753/1920 = 91.30%
width: 158px → 158/1920 = 8.23%
수정 버튼:
x: 1594px → 1594/1920 = 83.02%
width: 158px → 158/1920 = 8.23%
저장 버튼:
x: 1436px → 1436/1920 = 74.79%
width: 158px → 158/1920 = 8.23%
분리 버튼:
x: 1277px → 1277/1920 = 66.51%
width: 158px → 158/1920 = 8.23%
```
**1920px 화면**:
```
┌────────────────────────────────────────────────────────────────────────┐
│ [분리] [저장] [수정] [삭제] │
│ 66.5% 74.8% 83.0% 91.3% │
├────────────────────────────────────────────────────────────────────────┤
│ 0.42% 99.6% │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 테이블 (width: 99.17%) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
✅ 정상 표시 (1920px와 동일)
```
**1280px 화면 (퍼센트 방식)**:
```
┌─────────────────────────────────────────────┐
│ [분리][저장][수정][삭제] │
│ 66.5% 74.8% 83.0% 91.3% │
│ = 851 957 1063 1169 │ ← 화면 안에 표시!
├─────────────────────────────────────────────┤
│ 0.42% 99.6% │
│ = 5px = 1275 │
│ ┌─────────────────────────────────────────┐ │
│ │ 테이블 (width: 99.17%) │ │ ← 화면 너비에 맞게 조정
│ │ = 1280 * 0.9917 = 1269px │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
✅ 비율 유지, 화면 안에 표시, 폰트 크기 유지
```
### 5.4 버튼 간격 검증
**1920px**:
```
분리: 1277px, 너비 158px → 끝: 1435px
저장: 1436px (간격: 1px)
수정: 1594px (간격: 1px)
삭제: 1753px (간격: 1px)
```
**1280px (퍼센트 변환 후)**:
```
분리: 1280 * 0.665 = 851px, 너비 1280 * 0.082 = 105px → 끝: 956px
저장: 1280 * 0.748 = 957px (간격: 1px) ✅
수정: 1280 * 0.830 = 1063px (간격: 1px) ✅
삭제: 1280 * 0.913 = 1169px (간격: 1px) ✅
```
**결론**: 버튼 간격 비율도 유지됨
---
## 6. 엣지 케이스 검증
### 6.1 분할 패널 (SplitPanelLayout)
**현재 동작**:
- 좌측 패널: 60% 너비
- 우측 패널: 40% 너비
- **이미 퍼센트 기반!**
**시뮬레이션**:
```
1920px: 좌측 1152px, 우측 768px
1280px: 좌측 768px, 우측 512px
✅ 자동으로 비율 유지됨
```
**분할 패널 내부 컴포넌트**:
- 문제: 내부 컴포넌트가 픽셀 고정이면 깨짐
- 해결: 분할 패널 내부도 퍼센트 적용 필요
### 6.2 테이블 컴포넌트 (TableList)
**현재**:
- 테이블 자체는 컨테이너 너비 100% 사용
- 컬럼 너비는 내부적으로 조정
**시뮬레이션**:
```
1920px: 테이블 컨테이너 width: 99.17% = 1904px
1280px: 테이블 컨테이너 width: 99.17% = 1269px
✅ 테이블이 자동으로 조정됨
```
### 6.3 자식 컴포넌트 상대 위치
**현재 코드 (page.tsx 라인 744-745)**:
```typescript
const relativeChildComponent = {
position: {
x: child.position.x - component.position.x,
y: child.position.y - component.position.y,
},
};
```
**문제**: 상대 좌표도 픽셀 기반
**해결**: 부모 기준 퍼센트로 변환
```typescript
const relativeChildComponent = {
position: {
// 부모 너비 기준 퍼센트
xPercent: ((child.position.x - component.position.x) / component.size.width) * 100,
y: child.position.y - component.position.y,
},
};
```
### 6.4 드래그 앤 드롭 (디자인 모드)
**ScreenDesigner.tsx**:
- 드롭 위치는 여전히 픽셀로 저장
- 렌더링 시에만 퍼센트로 변환
- **저장 방식 변경 없음!**
**시뮬레이션**:
```
1. 디자이너가 1920px 화면에서 버튼 드롭
2. position: { x: 1753, y: 88 } 저장 (픽셀)
3. 렌더링 시 91.3%로 변환
4. 1280px 화면에서도 정상 표시
✅ 디자인 모드 호환
```
### 6.5 모달 내 화면
**ScreenModal.tsx (라인 620-621)**:
```typescript
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
```
**문제**: 오프셋 계산이 픽셀 기반
**해결**: 모달 컨테이너도 퍼센트 기반으로 변경
```typescript
// 모달 컨테이너 너비 기준으로 퍼센트 계산
const modalWidth = containerRef.current?.clientWidth || DESIGN_WIDTH;
const xPercent = ((position.x - offsetX) / DESIGN_WIDTH) * 100;
```
---
## 7. 잠재적 문제 및 해결책
### 7.1 최소 너비 문제
**문제**: 버튼이 너무 작아질 수 있음
```
158px 버튼 → 1280px 화면에서 105px
→ 텍스트가 잘릴 수 있음
```
**해결**: min-width 설정
```css
min-width: 80px;
```
### 7.2 겹침 문제
**문제**: 화면이 작아지면 컴포넌트가 겹칠 수 있음
**시뮬레이션**:
```
1920px: 버튼 4개가 간격 1px로 배치
1280px: 버튼 4개가 간격 1px로 배치 (비율 유지)
✅ 겹치지 않음 (간격도 비율로 축소)
```
### 7.3 폰트 크기
**현재**: 폰트는 px 고정
**변경 후**: 폰트 크기 유지 (scale이 아니므로)
**결과**: 폰트 크기는 그대로, 레이아웃만 비율 조정
✅ 가독성 유지
### 7.4 height 처리
**결정**: height는 픽셀 유지
- 이유: 세로 스크롤은 자연스러움
- 세로 반응형은 불필요 (PC 환경)
---
## 8. 호환성 검증
### 8.1 기존 화면 호환
| 항목 | 호환 여부 | 이유 |
|------|----------|------|
| 일반 버튼 | ✅ | 퍼센트로 변환, 위치 유지 |
| 테이블 | ✅ | 컨테이너 비율 유지 |
| 분할 패널 | ✅ | 이미 퍼센트 기반 |
| 탭 레이아웃 | ✅ | 컨테이너 비율 유지 |
| 그리드 레이아웃 | ✅ | 내부는 기존 방식 |
| 인풋 필드 | ✅ | 컨테이너 비율 유지 |
### 8.2 디자인 모드 호환
| 항목 | 호환 여부 | 이유 |
|------|----------|------|
| 드래그 앤 드롭 | ✅ | 저장은 픽셀, 렌더링만 퍼센트 |
| 리사이즈 | ✅ | 저장은 픽셀, 렌더링만 퍼센트 |
| 그리드 스냅 | ✅ | 스냅은 픽셀 기준 유지 |
| 미리보기 | ✅ | 렌더링 동일 방식 |
### 8.3 API 호환
| 항목 | 호환 여부 | 이유 |
|------|----------|------|
| DB 저장 | ✅ | 구조 변경 없음 (픽셀 저장) |
| API 응답 | ✅ | 구조 변경 없음 |
| V2 변환 | ✅ | 변환 로직 변경 없음 |
---
## 9. 구현 순서
### Phase 1: 공통 유틸리티 생성 (30분)
```typescript
// frontend/lib/constants/responsive.ts
export const RESPONSIVE_CONFIG = {
DESIGN_WIDTH: 1920,
} as const;
export function toPercentX(pixelX: number): string {
return `${(pixelX / RESPONSIVE_CONFIG.DESIGN_WIDTH) * 100}%`;
}
export function toPercentWidth(pixelWidth: number): string {
return `${(pixelWidth / RESPONSIVE_CONFIG.DESIGN_WIDTH) * 100}%`;
}
```
### Phase 2: RealtimePreviewDynamic.tsx 수정 (1시간)
1. import 추가
2. baseStyle의 left, width를 퍼센트로 변경
3. 분할 패널 위 버튼 조정 로직도 퍼센트 적용
### Phase 3: AutoRegisteringComponentRenderer.ts 수정 (30분)
1. import 추가
2. getComponentStyle()의 left, width를 퍼센트로 변경
### Phase 4: page.tsx 수정 (1시간)
1. scale 로직 제거 또는 수정
2. 컨테이너 width: 100%로 변경
3. 자식 컴포넌트 상대 위치 계산 수정
### Phase 5: 테스트 (1시간)
1. 1920px 화면에서 기존 화면 정상 동작 확인
2. 1280px 화면으로 축소 테스트
3. 분할 패널 화면 테스트
4. 디자인 모드 테스트
---
## 10. 최종 체크리스트
### 구현 전
- [ ] 현재 동작하는 화면 스크린샷 캡처 (비교용)
- [ ] 테스트 화면 목록 선정
### 구현 중
- [ ] responsive.ts 생성
- [ ] RealtimePreviewDynamic.tsx 수정
- [ ] AutoRegisteringComponentRenderer.ts 수정
- [ ] page.tsx 수정
### 구현 후
- [ ] 1920px 화면 테스트
- [ ] 1440px 화면 테스트
- [ ] 1280px 화면 테스트
- [ ] 분할 패널 화면 테스트
- [ ] 디자인 모드 테스트
- [ ] 모달 내 화면 테스트
---
## 11. 예상 소요 시간
| 작업 | 시간 |
|------|------|
| 유틸리티 생성 | 30분 |
| RealtimePreviewDynamic.tsx | 1시간 |
| AutoRegisteringComponentRenderer.ts | 30분 |
| page.tsx | 1시간 |
| 테스트 | 1시간 |
| **합계** | **4시간** |
---
## 12. 결론
**퍼센트 기반 배치**가 PC 반응형의 가장 확실한 해결책입니다.
| 항목 | scale 방식 | 퍼센트 방식 |
|------|-----------|------------|
| 폰트 크기 | 축소됨 | **유지** |
| 레이아웃 비율 | 유지 | **유지** |
| 클릭 영역 | 오차 가능 | **정확** |
| 구현 복잡도 | 낮음 | **중간** |
| 진정한 반응형 | ❌ | **✅** |
**DB 변경 없이, 렌더링 로직만 수정**하여 완벽한 PC 반응형을 구현할 수 있습니다.

View File

@ -103,6 +103,162 @@
- 분할 패널 반응형 처리 - 분할 패널 반응형 처리
``` ```
### 2.5 레이아웃 시스템 구조
현재 시스템에는 두 가지 레벨의 레이아웃이 존재합니다:
#### 2.5.1 화면 레이아웃 (screen_layouts_v2)
화면 전체의 컴포넌트 배치를 담당합니다.
```json
// DB 구조
{
"version": "2.0",
"components": [
{ "id": "comp_1", "position": { "x": 100, "y": 50 }, ... },
{ "id": "comp_2", "position": { "x": 500, "y": 50 }, ... },
{ "id": "GridLayout_1", "position": { "x": 100, "y": 200 }, ... }
]
}
```
**현재**: absolute 포지션으로 컴포넌트 배치 → **반응형 불가**
#### 2.5.2 컴포넌트 레이아웃 (GridLayout, FlexboxLayout 등)
개별 레이아웃 컴포넌트 내부의 zone 배치를 담당합니다.
| 컴포넌트 | 위치 | 내부 구조 | CSS Grid 사용 |
|----------|------|-----------|---------------|
| `GridLayout` | `layouts/grid/` | zones 배열 | ✅ 이미 사용 |
| `FlexboxLayout` | `layouts/flexbox/` | zones 배열 | ❌ absolute |
| `SplitLayout` | `layouts/split/` | left/right | ❌ flex |
| `TabsLayout` | `layouts/` | tabs 배열 | ❌ 탭 구조 |
| `CardLayout` | `layouts/card-layout/` | zones 배열 | ❌ flex |
| `AccordionLayout` | `layouts/accordion/` | items 배열 | ❌ 아코디언 |
#### 2.5.3 구조 다이어그램
```
┌─────────────────────────────────────────────────────────────────┐
│ screen_layouts_v2 (화면 전체) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 현재: absolute 포지션 → 반응형 불가 │ │
│ │ 변경: ResponsiveGridLayout (CSS Grid) → 반응형 가능 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────────────────┐ │
│ │ v2-button │ │ v2-input │ │ GridLayout (컴포넌트) │ │
│ │ (shadcn) │ │ (shadcn) │ │ ┌─────────┬─────────────┐ │ │
│ └──────────┘ └──────────┘ │ │ zone1 │ zone2 │ │ │
│ │ │ (이미 │ (이미 │ │ │
│ │ │ CSS Grid│ CSS Grid) │ │ │
│ │ └─────────┴─────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.6 기존 레이아웃 컴포넌트 호환성
#### 2.6.1 GridLayout (기존 커스텀 그리드)
```tsx
// frontend/lib/registry/layouts/grid/GridLayout.tsx
// 이미 CSS Grid를 사용하고 있음!
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
gap: `${gridConfig.gap || 16}px`,
};
```
**호환성**: ✅ **완전 호환**
- GridLayout은 화면 내 하나의 컴포넌트로 취급됨
- ResponsiveGridLayout이 GridLayout의 **위치만** 관리
- GridLayout 내부는 기존 방식 그대로 동작
#### 2.6.2 FlexboxLayout
```tsx
// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx
// zone 내부에서 컴포넌트를 absolute로 배치
{zoneChildren.map((child) => (
<div style={{
position: "absolute",
left: child.position?.x || 0,
top: child.position?.y || 0,
}}>
{renderer.renderChild(child)}
</div>
))}
```
**호환성**: ✅ **호환** (내부는 기존 방식 유지)
- FlexboxLayout 컴포넌트 자체의 위치는 ResponsiveGridLayout이 관리
- 내부 zone의 컴포넌트 배치는 기존 absolute 방식 유지
#### 2.6.3 SplitPanelLayout (분할 패널)
**호환성**: ⚠️ **별도 수정 필요**
- 외부 위치: ResponsiveGridLayout이 관리 ✅
- 내부 반응형: 별도 수정 필요 (모바일에서 상하 분할)
#### 2.6.4 호환성 요약
| 컴포넌트 | 외부 배치 | 내부 동작 | 추가 수정 |
|----------|----------|----------|-----------|
| **v2-button, v2-input 등** | ✅ 반응형 | ✅ shadcn 그대로 | ❌ 불필요 |
| **GridLayout** | ✅ 반응형 | ✅ CSS Grid 그대로 | ❌ 불필요 |
| **FlexboxLayout** | ✅ 반응형 | ⚠️ absolute 유지 | ❌ 불필요 |
| **SplitPanelLayout** | ✅ 반응형 | ❌ 좌우 고정 | ⚠️ 내부 반응형 추가 |
| **TabsLayout** | ✅ 반응형 | ✅ 탭 그대로 | ❌ 불필요 |
### 2.7 동작 방식 비교
#### 변경 전
```
화면 로드
screen_layouts_v2에서 components 조회
각 컴포넌트를 position.x, position.y로 absolute 배치
GridLayout 컴포넌트도 absolute로 배치됨
GridLayout 내부는 CSS Grid로 zone 배치
결과: 화면 크기 변해도 모든 컴포넌트 위치 고정
```
#### 변경 후
```
화면 로드
screen_layouts_v2에서 components 조회
layoutMode === "grid" 확인
ResponsiveGridLayout으로 렌더링 (CSS Grid)
각 컴포넌트를 grid.col, grid.colSpan으로 배치
화면 크기 감지 (ResizeObserver)
breakpoint에 따라 responsive.sm/md/lg 적용
GridLayout 컴포넌트도 반응형으로 배치됨
GridLayout 내부는 기존 CSS Grid로 zone 배치 (변경 없음)
결과: 화면 크기에 따라 컴포넌트 재배치
```
--- ---
## 3. 기술 결정 ## 3. 기술 결정
@ -649,6 +805,10 @@ ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2;
- [ ] 태블릿 (768px, 1024px) 테스트 - [ ] 태블릿 (768px, 1024px) 테스트
- [ ] 모바일 (375px, 414px) 테스트 - [ ] 모바일 (375px, 414px) 테스트
- [ ] 분할 패널 화면 테스트 - [ ] 분할 패널 화면 테스트
- [ ] GridLayout 컴포넌트 포함 화면 테스트
- [ ] FlexboxLayout 컴포넌트 포함 화면 테스트
- [ ] TabsLayout 컴포넌트 포함 화면 테스트
- [ ] 중첩 레이아웃 (GridLayout 안에 컴포넌트) 테스트
--- ---
@ -659,6 +819,8 @@ ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2;
| 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 | | 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 |
| 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) | | 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) |
| 디자인 모드 혼란 | 낮음 | position/size 필드 유지 | | 디자인 모드 혼란 | 낮음 | position/size 필드 유지 |
| GridLayout 내부 깨짐 | 낮음 | 내부는 기존 방식 유지, 외부 배치만 변경 |
| 중첩 레이아웃 문제 | 낮음 | 각 레이아웃 컴포넌트는 독립적으로 동작 |
--- ---

View File

@ -0,0 +1,399 @@
# V2 마이그레이션 학습노트 (DDD1542 전용)
> **목적**: 마이그레이션 작업 전 완벽한 이해를 위한 개인 학습노트
> **작성일**: 2026-02-03
> **절대 규칙**: 모르면 물어보기, 추측 금지
---
## 1. 가장 중요한 핵심 (이전 신하가 실패한 이유)
### 1.1 "component" vs "v2-input" 차이
```
[잘못된 상태] [올바른 상태]
┌──────────────────┐ ┌──────────────────┐
│ component │ │ v2-input │
│ 업체코드 │ │ 업체코드 │
│ "자동 생성됩니다" │ │ "자동 생성됩니다" │
└──────────────────┘ └──────────────────┘
↑ ↑
테이블-컬럼 연결 없음 table_name + column_name 연결됨
```
**핵심**: 컬럼을 왼쪽 패널에서 **드래그**해야 올바른 연결이 생성됨
### 1.2 올바른 컴포넌트 생성 방법
```
[왼쪽 패널: 테이블 컬럼 목록]
운송업체 (8개)
├── 업체코드 [numbering] ─드래그→ 화면 캔버스 → v2-numbering-rule (또는 v2-input)
├── 업체명 [text] ─드래그→ 화면 캔버스 → v2-input
├── 유형 [category] ─드래그→ 화면 캔버스 → v2-select
├── 연락처 [text] ─드래그→ 화면 캔버스 → v2-input
└── ...
```
### 1.3 input_type → V2 컴포넌트 매핑
| table_type_columns.input_type | V2 컴포넌트 | 연동 테이블 |
|-------------------------------|-------------|-------------|
| text | v2-input | - |
| number | v2-input (type=number) | - |
| date | v2-date | - |
| category | v2-select | category_values |
| numbering | v2-numbering-rule 또는 v2-input | numbering_rules |
| entity | v2-entity-search | 엔티티 조인 |
---
## 2. V1 vs V2 구조 차이
### 2.1 테이블 구조
```
V1 (본서버: screen_layouts) V2 (개발서버: screen_layouts_v2)
──────────────────────────────────────────────────────────────────
- 컴포넌트별 1개 레코드 - 화면당 1개 레코드
- properties JSONB - layout_data JSONB
- component_type VARCHAR - url (컴포넌트 경로)
- menu_objid 기반 채번/카테고리 - table_name + column_name 기반
```
### 2.2 V2 layout_data 구조
```json
{
"version": "2.0",
"components": [
{
"id": "comp_xxx",
"url": "@/lib/registry/components/v2-table-list",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"displayOrder": 0,
"overrides": {
"tableName": "inspection_standard",
"columns": ["id", "name", "status"]
}
}
],
"updatedAt": "2026-02-03T12:00:00Z"
}
```
### 2.3 컴포넌트 URL 매핑
```typescript
const V1_TO_V2_URL_MAPPING = {
'table-list': '@/lib/registry/components/v2-table-list',
'button-primary': '@/lib/registry/components/v2-button-primary',
'text-input': '@/lib/registry/components/v2-input',
'select-basic': '@/lib/registry/components/v2-select',
'date-input': '@/lib/registry/components/v2-date',
'entity-search-input': '@/lib/registry/components/v2-entity-search',
'category-manager': '@/lib/registry/components/v2-category-manager',
'numbering-rule': '@/lib/registry/components/v2-numbering-rule',
'tabs-widget': '@/lib/registry/components/v2-tabs-widget',
'split-panel-layout': '@/lib/registry/components/v2-split-panel-layout',
};
```
---
## 3. 데이터 타입 관리 (V2)
### 3.1 핵심 테이블 관계
```
table_type_columns (컬럼 타입 정의)
├── input_type = 'category' → category_values (table_name + column_name)
├── input_type = 'numbering' → numbering_rules (detail_settings.numberingRuleId)
├── input_type = 'entity' → 엔티티 조인
└── input_type = 'text', 'number', 'date', etc.
```
### 3.2 category_values 조회 쿼리
```sql
-- 특정 테이블.컬럼의 카테고리 값 조회
SELECT value_id, value_code, value_label, parent_value_id, depth
FROM category_values
WHERE table_name = '테이블명'
AND column_name = '컬럼명'
AND company_code = 'COMPANY_7'
ORDER BY value_order;
```
### 3.3 numbering_rules 연결 방식
```json
// table_type_columns.detail_settings
{
"numberingRuleId": "rule-xxx"
}
// numbering_rules에서 해당 rule 조회
SELECT * FROM numbering_rules WHERE rule_id = 'rule-xxx';
```
---
## 4. V2 컴포넌트 목록 (23개)
### 4.1 입력 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| v2-input | 입력 | 텍스트, 숫자, 비밀번호, 이메일 |
| v2-select | 선택 | 드롭다운, 라디오, 체크박스 |
| v2-date | 날짜 | 날짜, 시간, 날짜범위 |
### 4.2 표시 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| v2-text-display | 텍스트 표시 | 라벨, 제목 |
| v2-card-display | 카드 디스플레이 | 카드 형태 데이터 |
| v2-aggregation-widget | 집계 위젯 | 합계, 평균, 개수 |
### 4.3 테이블/데이터 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| v2-table-list | 테이블 리스트 | 데이터 그리드 |
| v2-table-search-widget | 검색 필터 | 테이블 검색 |
| v2-pivot-grid | 피벗 그리드 | 다차원 분석 |
| v2-table-grouped | 그룹화 테이블 | 그룹별 접기/펼치기 |
### 4.4 레이아웃 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| v2-split-panel-layout | 분할 패널 | 마스터-디테일 |
| v2-tabs-widget | 탭 위젯 | 탭 전환 |
| v2-section-card | 섹션 카드 | 제목+테두리 그룹 |
| v2-section-paper | 섹션 페이퍼 | 배경색 그룹 |
| v2-divider-line | 구분선 | 영역 구분 |
| v2-repeat-container | 리피터 컨테이너 | 데이터 반복 |
| v2-unified-repeater | 통합 리피터 | 인라인/모달/버튼 |
### 4.5 액션/특수 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| v2-button-primary | 기본 버튼 | 저장, 삭제 등 |
| v2-numbering-rule | 채번 규칙 | 자동 코드 생성 |
| v2-category-manager | 카테고리 관리자 | 카테고리 관리 |
| v2-location-swap-selector | 위치 교환 | 위치 선택 |
| v2-rack-structure | 랙 구조 | 창고 랙 시각화 |
---
## 5. 화면 패턴 (5가지)
### 5.1 패턴 A: 기본 마스터 화면
```
사용 조건: 단일 테이블 CRUD
┌─────────────────────────────────────────────────┐
│ v2-table-search-widget │
├─────────────────────────────────────────────────┤
│ v2-table-list │
│ [신규] [삭제] v2-button-primary │
└─────────────────────────────────────────────────┘
```
### 5.2 패턴 B: 마스터-디테일 화면
```
사용 조건: 마스터 선택 → 디테일 표시
┌──────────────────┬──────────────────────────────┐
│ 마스터 리스트 │ 디테일 리스트 │
│ v2-table-list │ v2-table-list │
│ │ (relation: foreignKey) │
└──────────────────┴──────────────────────────────┘
v2-split-panel-layout
```
**필수 설정:**
```json
{
"leftPanel": { "tableName": "master_table" },
"rightPanel": {
"tableName": "detail_table",
"relation": { "type": "detail", "foreignKey": "master_id" }
},
"splitRatio": 30
}
```
### 5.3 패턴 C: 마스터-디테일 + 탭
```
┌──────────────────┬──────────────────────────────┐
│ 마스터 리스트 │ v2-tabs-widget │
│ v2-table-list │ ├─ 탭1: v2-table-list │
│ │ ├─ 탭2: v2-table-list │
│ │ └─ 탭3: 폼 컴포넌트들 │
└──────────────────┴──────────────────────────────┘
v2-split-panel-layout
```
---
## 6. 모달 처리 방식 변경
### 6.1 V1 (본서버)
```
화면 A (screen_id: 142) - 검사장비관리
└── 버튼 클릭 → 화면 B (screen_id: 143) - 검사장비 등록모달 (별도 screen_id)
```
### 6.2 V2 (개발서버)
```
화면 A (screen_id: 142) - 검사장비관리
└── layout_data.components[] 내에 v2-dialog-form 또는 overlay 포함
```
**핵심**: V2에서는 모달을 별도 화면이 아닌, 부모 화면의 컴포넌트로 통합
---
## 7. 마이그레이션 절차 (Step by Step)
### Step 1: 사전 분석
```sql
-- 본서버 화면 목록 확인
SELECT sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name,
COUNT(sl.layout_id) as component_count
FROM screen_definitions sd
LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_code LIKE 'COMPANY_7_%'
GROUP BY sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name;
-- 개발서버 V2 현황 확인
SELECT sd.screen_id, sd.screen_code, sd.screen_name,
sv2.layout_data IS NOT NULL as has_v2_layout
FROM screen_definitions sd
LEFT JOIN screen_layouts_v2 sv2 ON sd.screen_id = sv2.screen_id
WHERE sd.company_code = 'COMPANY_7';
```
### Step 2: table_type_columns 확인
```sql
-- 해당 테이블의 컬럼 타입 확인
SELECT column_name, column_label, input_type, detail_settings
FROM table_type_columns
WHERE table_name = '대상테이블명'
AND company_code = 'COMPANY_7';
```
### Step 3: V2 layout_data 생성
```json
{
"version": "2.0",
"components": [
{
"id": "생성된ID",
"url": "@/lib/registry/components/v2-컴포넌트타입",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"displayOrder": 0,
"overrides": {
"tableName": "테이블명",
"fieldName": "컬럼명"
}
}
],
"migratedFrom": "V1",
"migratedAt": "2026-02-03T00:00:00Z"
}
```
### Step 4: screen_layouts_v2 INSERT
```sql
INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3::jsonb, updated_at = NOW();
```
### Step 5: 검증
- [ ] 화면 렌더링 확인 (component가 아닌 v2-xxx로 표시되는지)
- [ ] 컴포넌트별 테이블-컬럼 연결 확인
- [ ] 카테고리 드롭다운 동작 확인
- [ ] 채번 규칙 동작 확인
- [ ] 저장/수정/삭제 테스트
---
## 8. 품질관리 메뉴 마이그레이션 현황
| 본서버 코드 | 화면명 | 테이블 | 상태 | 비고 |
|-------------|--------|--------|------|------|
| COMPANY_7_126 | 검사정보 관리 | inspection_standard | ✅ V2 존재 | 컴포넌트 검증 필요 |
| COMPANY_7_127 | 품목옵션 설정 | - | ✅ V2 존재 | v2-category-manager |
| COMPANY_7_138 | 카테고리 설정 | inspection_standard | ❌ 누락 | table_name 기반 |
| COMPANY_7_139 | 코드 설정 | inspection_standard | ❌ 누락 | table_name 기반 |
| COMPANY_7_142 | 검사장비 관리 | inspection_equipment_mng | ❌ 누락 | 모달 통합 |
| COMPANY_7_143 | 검사장비 등록모달 | inspection_equipment_mng | ❌ 누락 | → 142 통합 |
| COMPANY_7_144 | 불량기준 정보 | defect_standard_mng | ❌ 누락 | 모달 통합 |
| COMPANY_7_145 | 불량기준 등록모달 | defect_standard_mng | ❌ 누락 | → 144 통합 |
---
## 9. 관련 코드 파일 경로
| 항목 | 경로 |
|------|------|
| V2 컴포넌트 폴더 | `frontend/lib/registry/components/v2-xxx/` |
| 컴포넌트 등록 | `frontend/lib/registry/components/index.ts` |
| 카테고리 서비스 | `backend-node/src/services/categoryTreeService.ts` |
| 채번 서비스 | `backend-node/src/services/numberingRuleService.ts` |
| 엔티티 조인 API | `frontend/lib/api/entityJoin.ts` |
| 폼 호환성 훅 | `frontend/hooks/useFormCompatibility.ts` |
---
## 10. 절대 하지 말 것
1. ❌ **테이블-컬럼 연결 없이 컴포넌트 배치** → "component"로 표시됨
2. ❌ **menu_objid 기반 카테고리/채번 사용** → V2는 table_name + column_name 기반
3. ❌ **모달을 별도 screen_id로 생성** → V2는 부모 화면에 통합
4. ❌ **V1 컴포넌트 타입 사용** → 반드시 v2- 접두사 컴포넌트 사용
5. ❌ **company_code 필터링 누락** → 멀티테넌시 필수
---
## 11. 모르면 확인할 곳
1. **컴포넌트 구조**: `docs/V2_컴포넌트_분석_가이드.md`
2. **화면 개발 표준**: `docs/screen-implementation-guide/화면개발_표준_가이드.md`
3. **마이그레이션 절차**: `docs/DDD1542/본서버_개발서버_마이그레이션_상세가이드.md`
4. **탑실 디자인 명세**: `/Users/gbpark/Downloads/화면개발 8/`
5. **실제 코드**: 위 경로의 소스 파일들
---
## 12. 왕의 훈계
> **"항상 애매한 거는 md파일 보거나 물어볼 것. 코드에는 전부 정답이 있음. 만약 모른다면 너 잘못. 실수해도 너 잘못."**
---
## 변경 이력
| 날짜 | 작성자 | 내용 |
|------|--------|------|
| 2026-02-03 | DDD1542 | 초안 작성 (문서 4개 정독 후) |

View File

@ -0,0 +1,453 @@
# 본서버 → 개발서버 마이그레이션 가이드 (공용)
> **이 문서는 다음 AI 에이전트가 마이그레이션 작업을 이어받을 때 참고하는 핵심 가이드입니다.**
---
## 빠른 시작
### 마이그레이션 방향 (절대 잊지 말 것)
```
본서버 (Production) → 개발서버 (Development)
211.115.91.141:11134 39.117.244.52:11132
screen_layouts (V1) screen_layouts_v2 (V2)
```
**반대로 하면 안 됨!** 개발서버 완성 후 → 본서버로 배포 예정
### DB 접속 정보
```bash
# 본서버 (Production)
docker exec pms-backend-mac node -e '
const { Pool } = require("pg");
const pool = new Pool({
connectionString: "postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm?sslmode=disable",
ssl: false
});
// 쿼리 실행
'
# 개발서버 (Development)
docker exec pms-backend-mac node -e '
const { Pool } = require("pg");
const pool = new Pool({
connectionString: "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm?sslmode=disable",
ssl: false
});
// 쿼리 실행
'
```
---
## 절대 주의: 컴포넌트-컬럼 연결 (이전 실패 원인)
### "component" vs "v2-input" 구분
```
❌ 잘못된 상태 ✅ 올바른 상태
┌──────────────────┐ ┌──────────────────┐
│ component │ │ v2-input │
│ 업체코드 │ │ 업체코드 │
└──────────────────┘ └──────────────────┘
↑ ↑
overrides.type 없음 overrides.type = "v2-input"
```
**핵심 원인**: 컴포넌트를 그냥 배치하면 "component"로 표시됨. 반드시 왼쪽 패널에서 테이블 컬럼을 **드래그**해야 올바른 v2-xxx 컴포넌트가 생성됨.
### 🔥 핵심 발견: overrides.type 필수 (2026-02-04 발견)
**"component"로 표시되는 근본 원인:**
| 항목 | 드래그로 배치 | 마이그레이션 (잘못된) |
|------|---------------|----------------------|
| `overrides.type` | **"v2-input"** ✅ | **없음** ❌ |
| `overrides.webType` | "text" 등 | 없음 |
| `overrides.tableName` | "carrier_mng" 등 | 없음 |
**프론트엔드가 컴포넌트 타입을 인식하는 방법:**
1. `overrides.type` 확인 → 있으면 해당 값 사용 (예: "v2-input")
2. 없으면 → 기본값 "component"로 폴백
**결론**: 마이그레이션 시 `overrides.type` 필드를 반드시 설정해야 함!
### input_type → V2 컴포넌트 자동 매핑
| table_type_columns.input_type | 드래그 시 생성되는 V2 컴포넌트 |
|-------------------------------|-------------------------------|
| text | v2-input |
| number | v2-input (type=number) |
| date | v2-date |
| category | v2-select (category_values 연동) |
| numbering | v2-numbering-rule 또는 v2-input |
| entity | v2-entity-search |
**절대 규칙**: 컴포넌트가 "component"로 표시되면 연결 실패 상태. 반드시 "v2-xxx"로 표시되어야 함.
---
## 핵심 개념
### V1 vs V2 구조 차이
| 구분 | V1 (본서버) | V2 (개발서버) |
|------|-------------|---------------|
| 테이블 | screen_layouts | screen_layouts_v2 |
| 레코드 | 컴포넌트별 1개 | 화면당 1개 |
| 설정 저장 | properties JSONB | layout_data.components[].overrides |
| 채번/카테고리 | menu_objid 기반 | table_name + column_name 기반 |
| 컴포넌트 참조 | component_type 문자열 | url 경로 (@/lib/registry/...) |
### 데이터 타입 관리 (V2)
```
table_type_columns (input_type)
├── 'category' → category_values 테이블
├── 'numbering' → numbering_rules 테이블 (detail_settings.numberingRuleId)
├── 'entity' → 엔티티 검색
└── 'text', 'number', 'date', etc.
```
### 컴포넌트 URL 매핑
```typescript
const V1_TO_V2_MAPPING = {
'table-list': '@/lib/registry/components/v2-table-list',
'button-primary': '@/lib/registry/components/v2-button-primary',
'text-input': '@/lib/registry/components/v2-text-input',
'select-basic': '@/lib/registry/components/v2-select',
'date-input': '@/lib/registry/components/v2-date-input',
'entity-search-input': '@/lib/registry/components/v2-entity-search',
'category-manager': '@/lib/registry/components/v2-category-manager',
'numbering-rule': '@/lib/registry/components/v2-numbering-rule',
'tabs-widget': '@/lib/registry/components/v2-tabs-widget',
'textarea-basic': '@/lib/registry/components/v2-textarea',
};
```
### 모달 처리 방식 변경
- **V1**: 별도 화면(screen_id)으로 모달 관리
- **V2**: 부모 화면에 overlay/dialog 컴포넌트로 통합
---
## 마이그레이션 대상 메뉴 현황
### 품질관리 (우선순위 1)
| 본서버 코드 | 화면명 | 상태 | 비고 |
|-------------|--------|------|------|
| COMPANY_7_126 | 검사정보 관리 | ✅ V2 존재 | 컴포넌트 검증 필요 |
| COMPANY_7_127 | 품목옵션 설정 | ✅ V2 존재 | v2-category-manager 사용중 |
| COMPANY_7_138 | 카테고리 설정 | ❌ 누락 | table_name 기반으로 변경 |
| COMPANY_7_139 | 코드 설정 | ❌ 누락 | table_name 기반으로 변경 |
| COMPANY_7_142 | 검사장비 관리 | ❌ 누락 | 모달 통합 필요 |
| COMPANY_7_143 | 검사장비 등록모달 | ❌ 누락 | → 142에 통합 |
| COMPANY_7_144 | 불량기준 정보 | ❌ 누락 | 모달 통합 필요 |
| COMPANY_7_145 | 불량기준 등록모달 | ❌ 누락 | → 144에 통합 |
### 다음 마이그레이션 대상 (미정)
- [ ] 물류관리
- [ ] 생산관리
- [ ] 영업관리
- [ ] 기타 메뉴들
---
## 마이그레이션 작업 절차
### Step 1: 분석
```sql
-- 본서버 특정 메뉴 화면 목록 조회
SELECT
sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name,
COUNT(sl.layout_id) as component_count
FROM screen_definitions sd
LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_name LIKE '%[메뉴명]%'
AND sd.company_code = 'COMPANY_7'
GROUP BY sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name;
-- 개발서버 V2 현황 확인
SELECT
sd.screen_id, sd.screen_code, sd.screen_name,
sv2.layout_id IS NOT NULL as has_v2
FROM screen_definitions sd
LEFT JOIN screen_layouts_v2 sv2 ON sd.screen_id = sv2.screen_id
WHERE sd.company_code = 'COMPANY_7';
```
### Step 2: screen_definitions 동기화
본서버에만 있는 화면을 개발서버에 추가
### Step 3: V1 → V2 레이아웃 변환
```typescript
// layout_data 구조
{
"version": "2.0",
"components": [
{
"id": "comp_xxx",
"url": "@/lib/registry/components/v2-table-list",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"displayOrder": 0,
"overrides": {
"tableName": "테이블명",
"columns": ["컬럼1", "컬럼2"]
}
}
]
}
```
### Step 4: 카테고리 데이터 확인/생성
```sql
-- 테이블의 category 컬럼 확인
SELECT column_name, column_label
FROM table_type_columns
WHERE table_name = '[테이블명]'
AND input_type = 'category';
-- category_values 데이터 확인
SELECT value_id, value_code, value_label
FROM category_values
WHERE table_name = '[테이블명]'
AND column_name = '[컬럼명]'
AND company_code = 'COMPANY_7';
```
### Step 5: 채번 규칙 확인/생성
```sql
-- numbering 컬럼 확인
SELECT column_name, column_label, detail_settings
FROM table_type_columns
WHERE table_name = '[테이블명]'
AND input_type = 'numbering';
-- numbering_rules 데이터 확인
SELECT rule_id, rule_name, table_name, column_name
FROM numbering_rules
WHERE company_code = 'COMPANY_7';
```
### Step 6: 검증
- [ ] 화면 렌더링 확인
- [ ] 컴포넌트 동작 확인
- [ ] 저장/수정/삭제 테스트
- [ ] 카테고리 드롭다운 동작
- [ ] 채번 규칙 동작
---
## 핵심 테이블 스키마
### screen_layouts_v2
```sql
CREATE TABLE screen_layouts_v2 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL,
company_code VARCHAR(20) NOT NULL,
layout_data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(screen_id, company_code)
);
```
### category_values
```sql
-- 핵심 컬럼
value_id, table_name, column_name, value_code, value_label,
parent_value_id, depth, path, company_code
```
### numbering_rules + numbering_rule_parts
```sql
-- numbering_rules 핵심 컬럼
rule_id, rule_name, table_name, column_name, separator,
reset_period, current_sequence, company_code
-- numbering_rule_parts 핵심 컬럼
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code
```
### table_type_columns
```sql
-- 핵심 컬럼
table_name, column_name, input_type, column_label,
detail_settings, company_code
```
---
## 참고 문서
### 필수 읽기
1. **[본서버_개발서버_마이그레이션_상세가이드.md](./본서버_개발서버_마이그레이션_상세가이드.md)** - 상세 마이그레이션 절차
2. **[화면개발_표준_가이드.md](../screen-implementation-guide/화면개발_표준_가이드.md)** - V2 화면 개발 표준
3. **[SCREEN_DEVELOPMENT_STANDARD.md](../screen-implementation-guide/SCREEN_DEVELOPMENT_STANDARD.md)** - 영문 표준 가이드
### 코드 참조
| 파일 | 설명 |
|------|------|
| `backend-node/src/services/categoryTreeService.ts` | 카테고리 관리 서비스 |
| `backend-node/src/services/numberingRuleService.ts` | 채번 규칙 서비스 |
| `frontend/lib/registry/components/v2-category-manager/` | V2 카테고리 컴포넌트 |
| `frontend/lib/registry/components/v2-numbering-rule/` | V2 채번 컴포넌트 |
### 관련 문서
- `docs/V2_컴포넌트_분석_가이드.md`
- `docs/V2_컴포넌트_연동_가이드.md`
- `docs/DDD1542/COMPONENT_LAYOUT_V2_ARCHITECTURE.md`
- `docs/DDD1542/COMPONENT_MIGRATION_PLAN.md`
---
## 주의사항
### 절대 하지 말 것
1. **개발서버 → 본서버 마이그레이션** (반대 방향)
2. **본서버 데이터 직접 수정** (SELECT만 허용)
3. **company_code 누락** (멀티테넌시 필수)
4. **테이블-컬럼 연결 없이 컴포넌트 배치** ("component"로 표시되면 실패)
5. **menu_objid 기반 카테고리/채번 사용** (V2는 table_name + column_name 기반)
### 반드시 할 것
1. 마이그레이션 전 **개발서버 백업**
2. 컴포넌트 변환 시 **V2 컴포넌트만 사용** (v2- prefix)
3. 모달 화면은 **부모 화면에 통합**
4. 카테고리/채번은 **table_name + column_name 기반**
5. 컴포넌트 배치 후 **"v2-xxx"로 표시되는지 반드시 확인**
### 실패 사례 (이전 작업자)
**물류정보관리 → 운송업체 관리 마이그레이션 실패**
- **원인**: 컴포넌트를 직접 배치하여 "component"로 생성됨
- **증상**: 화면에 "component" 라벨 표시, 데이터 바인딩 실패
- **해결**: 왼쪽 패널에서 테이블 컬럼을 드래그하여 "v2-input" 등으로 생성
---
## 🔧 일괄 수정 SQL (overrides.type 누락 문제)
### 문제 진단 쿼리
```sql
-- overrides.type이 없는 컴포넌트 수 확인
SELECT
COUNT(DISTINCT sv2.screen_id) as affected_screens,
COUNT(*) as affected_components
FROM screen_layouts_v2 sv2,
jsonb_array_elements(sv2.layout_data->'components') as comp
WHERE (comp->>'url' LIKE '%/v2-input'
OR comp->>'url' LIKE '%/v2-select'
OR comp->>'url' LIKE '%/v2-date')
AND NOT (comp->'overrides' ? 'type');
```
### 일괄 수정 쿼리 (개발서버에서만!)
```sql
UPDATE screen_layouts_v2
SET layout_data = jsonb_set(
layout_data,
'{components}',
(
SELECT jsonb_agg(
CASE
WHEN comp->>'url' LIKE '%/v2-input' AND NOT (comp->'overrides' ? 'type')
THEN jsonb_set(comp, '{overrides,type}', '"v2-input"')
WHEN comp->>'url' LIKE '%/v2-select' AND NOT (comp->'overrides' ? 'type')
THEN jsonb_set(comp, '{overrides,type}', '"v2-select"')
WHEN comp->>'url' LIKE '%/v2-date' AND NOT (comp->'overrides' ? 'type')
THEN jsonb_set(comp, '{overrides,type}', '"v2-date"')
WHEN comp->>'url' LIKE '%/v2-textarea' AND NOT (comp->'overrides' ? 'type')
THEN jsonb_set(comp, '{overrides,type}', '"v2-textarea"')
ELSE comp
END
)
FROM jsonb_array_elements(layout_data->'components') comp
)
),
updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements(layout_data->'components') c
WHERE (c->>'url' LIKE '%/v2-input' OR c->>'url' LIKE '%/v2-select'
OR c->>'url' LIKE '%/v2-date' OR c->>'url' LIKE '%/v2-textarea')
AND NOT (c->'overrides' ? 'type')
);
```
### 2026-02-04 일괄 수정 실행 결과
| 항목 | 수량 |
|------|------|
| 수정된 화면 | 397개 |
| 수정된 컴포넌트 | 2,455개 |
| v2-input | 1,983개 |
| v2-select | 336개 |
| v2-date | 136개 |
---
## 마이그레이션 진행 로그
| 날짜 | 메뉴 | 담당 | 상태 | 비고 |
|------|------|------|------|------|
| 2026-02-03 | 품질관리 | DDD1542 | 분석 완료 | 마이그레이션 대기 |
| 2026-02-03 | 물류관리 (운송업체) | 이전 신하 | ❌ 실패 | component 연결 오류 |
| 2026-02-03 | 문서 학습 | DDD1542 | ✅ 완료 | 핵심 4개 문서 정독, 학습노트 작성 |
| **2026-02-04** | **overrides.type 원인 분석** | **AI** | **✅ 완료** | **핵심 원인 발견: overrides.type 누락** |
| **2026-02-04** | **전체 입력폼 일괄 수정** | **AI** | **✅ 완료** | **397개 화면, 2,455개 컴포넌트 수정** |
| | 물류관리 | - | 미시작 | |
| | 생산관리 | - | 미시작 | |
| | 영업관리 | - | 미시작 | |
---
## 다음 작업 요청 예시
다음 AI에게 요청할 때 이렇게 말하면 됩니다:
```
"본서버_개발서버_마이그레이션_가이드.md 읽고 품질관리 메뉴 마이그레이션 진행해줘"
"본서버_개발서버_마이그레이션_가이드.md 참고해서 물류관리 메뉴 분석해줘"
"본서버_개발서버_마이그레이션_상세가이드.md 보고 COMPANY_7_142 화면 V2로 변환해줘"
```
---
## 변경 이력
| 날짜 | 작성자 | 내용 |
|------|--------|------|
| 2026-02-03 | DDD1542 | 초안 작성 |
| 2026-02-03 | DDD1542 | 컴포넌트-컬럼 연결 주의사항 추가 (이전 실패 원인) |
| 2026-02-03 | DDD1542 | 개인 학습노트 작성 (V2_마이그레이션_학습노트_DDD1542.md) |
| **2026-02-04** | **AI** | **핵심 원인 발견: overrides.type 필드 누락 문제** |
| **2026-02-04** | **AI** | **일괄 수정 SQL 추가 및 397개 화면 수정 완료** |

View File

@ -0,0 +1,553 @@
# 본서버 → 개발서버 마이그레이션 가이드
## 개요
본 문서는 **본서버(Production)**의 `screen_layouts` (V1) 데이터를 **개발서버(Development)**의 `screen_layouts_v2` 시스템으로 마이그레이션하는 절차를 정의합니다.
### 마이그레이션 방향
```
본서버 (Production) 개발서버 (Development)
┌─────────────────────┐ ┌─────────────────────┐
│ screen_layouts (V1) │ → │ screen_layouts_v2 │
│ - 컴포넌트별 레코드 │ │ - 화면당 1개 레코드 │
│ - properties JSONB │ │ - layout_data JSONB │
└─────────────────────┘ └─────────────────────┘
```
### 최종 목표
개발서버에서 완성 후 **개발서버 → 본서버**로 배포
---
## 1. V1 vs V2 구조 차이
### 1.1 screen_layouts (V1) - 본서버
```sql
-- 컴포넌트별 1개 레코드
CREATE TABLE screen_layouts (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER,
component_type VARCHAR(50),
component_id VARCHAR(100),
properties JSONB, -- 모든 설정값 포함
...
);
```
**특징:**
- 화면당 N개 레코드 (컴포넌트 수만큼)
- `properties`에 모든 설정 저장 (defaults + overrides 구분 없음)
- `menu_objid` 기반 채번/카테고리 관리
### 1.2 screen_layouts_v2 - 개발서버
```sql
-- 화면당 1개 레코드
CREATE TABLE screen_layouts_v2 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL,
company_code VARCHAR(20) NOT NULL,
layout_data JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE(screen_id, company_code)
);
```
**layout_data 구조:**
```json
{
"version": "2.0",
"components": [
{
"id": "comp_xxx",
"url": "@/lib/registry/components/v2-table-list",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"displayOrder": 0,
"overrides": {
"tableName": "inspection_standard",
"columns": ["id", "name"]
}
}
],
"updatedAt": "2026-02-03T12:00:00Z"
}
```
**특징:**
- 화면당 1개 레코드
- `url` + `overrides` 방식 (Zod 스키마 defaults와 병합)
- `table_name + column_name` 기반 채번/카테고리 관리 (전역)
---
## 2. 데이터 타입 관리 구조 (V2)
### 2.1 핵심 테이블 관계
```
table_type_columns (컬럼 타입 정의)
├── input_type = 'category' → category_values
├── input_type = 'numbering' → numbering_rules
└── input_type = 'text', 'date', 'number', etc.
```
### 2.2 table_type_columns
각 테이블의 컬럼별 입력 타입을 정의합니다.
```sql
SELECT table_name, column_name, input_type, column_label
FROM table_type_columns
WHERE input_type IN ('category', 'numbering');
```
**주요 input_type:**
| input_type | 설명 | 연결 테이블 |
|------------|------|-------------|
| text | 텍스트 입력 | - |
| number | 숫자 입력 | - |
| date | 날짜 입력 | - |
| category | 카테고리 드롭다운 | category_values |
| numbering | 자동 채번 | numbering_rules |
| entity | 엔티티 검색 | - |
### 2.3 category_values (카테고리 관리)
```sql
-- 카테고리 값 조회
SELECT value_id, table_name, column_name, value_code, value_label,
parent_value_id, depth, company_code
FROM category_values
WHERE table_name = 'inspection_standard'
AND column_name = 'inspection_method'
AND company_code = 'COMPANY_7';
```
**V1 vs V2 차이:**
| 구분 | V1 | V2 |
|------|----|----|
| 키 | menu_objid | table_name + column_name |
| 범위 | 화면별 | 전역 (테이블.컬럼별) |
| 계층 | 단일 | 3단계 (대/중/소분류) |
### 2.4 numbering_rules (채번 규칙)
```sql
-- 채번 규칙 조회
SELECT rule_id, rule_name, table_name, column_name, separator,
reset_period, current_sequence, company_code
FROM numbering_rules
WHERE company_code = 'COMPANY_7';
```
**연결 방식:**
```
table_type_columns.detail_settings = '{"numberingRuleId": "rule-xxx"}'
numbering_rules.rule_id = "rule-xxx"
```
---
## 3. 컴포넌트 매핑
### 3.1 기본 컴포넌트 매핑
| V1 (본서버) | V2 (개발서버) | 비고 |
|-------------|---------------|------|
| table-list | v2-table-list | 테이블 목록 |
| button-primary | v2-button-primary | 버튼 |
| text-input | v2-text-input | 텍스트 입력 |
| select-basic | v2-select | 드롭다운 |
| date-input | v2-date-input | 날짜 입력 |
| entity-search-input | v2-entity-search | 엔티티 검색 |
| tabs-widget | v2-tabs-widget | 탭 |
### 3.2 특수 컴포넌트 매핑
| V1 (본서버) | V2 (개발서버) | 마이그레이션 방식 |
|-------------|---------------|-------------------|
| category-manager | v2-category-manager | table_name 기반으로 변경 |
| numbering-rule | v2-numbering-rule | table_name 기반으로 변경 |
| 모달 화면 | overlay 통합 | 부모 화면에 통합 |
### 3.3 모달 처리 방식 변경
**V1 (본서버):**
```
화면 A (screen_id: 142) - 검사장비관리
└── 버튼 클릭 → 화면 B (screen_id: 143) - 검사장비 등록모달
```
**V2 (개발서버):**
```
화면 A (screen_id: 142) - 검사장비관리
└── v2-dialog-form 컴포넌트로 모달 통합
```
---
## 4. 마이그레이션 절차
### 4.1 사전 분석
```sql
-- 1. 본서버 화면 목록 확인
SELECT sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name,
COUNT(sl.layout_id) as component_count
FROM screen_definitions sd
LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_code LIKE 'COMPANY_7_%'
AND sd.screen_name LIKE '%품질%'
GROUP BY sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name;
-- 2. 개발서버 V2 화면 현황 확인
SELECT sd.screen_id, sd.screen_code, sd.screen_name,
sv2.layout_data IS NOT NULL as has_v2_layout
FROM screen_definitions sd
LEFT JOIN screen_layouts_v2 sv2 ON sd.screen_id = sv2.screen_id
WHERE sd.company_code = 'COMPANY_7';
```
### 4.2 Step 1: screen_definitions 동기화
```sql
-- 본서버에만 있는 화면을 개발서버에 추가
INSERT INTO screen_definitions (screen_code, screen_name, table_name, company_code, ...)
SELECT screen_code, screen_name, table_name, company_code, ...
FROM [본서버].screen_definitions
WHERE screen_code NOT IN (SELECT screen_code FROM screen_definitions);
```
### 4.3 Step 2: V1 → V2 레이아웃 변환
```typescript
// 변환 로직 (pseudo-code)
async function convertV1toV2(screenId: number, companyCode: string) {
// 1. V1 레이아웃 조회
const v1Layouts = await getV1Layouts(screenId);
// 2. V2 형식으로 변환
const v2Layout = {
version: "2.0",
components: v1Layouts.map(v1 => ({
id: v1.component_id,
url: mapComponentUrl(v1.component_type),
position: { x: v1.position_x, y: v1.position_y },
size: { width: v1.width, height: v1.height },
displayOrder: v1.display_order,
overrides: extractOverrides(v1.properties)
})),
updatedAt: new Date().toISOString()
};
// 3. V2 테이블에 저장
await saveV2Layout(screenId, companyCode, v2Layout);
}
function mapComponentUrl(v1Type: string): string {
const mapping = {
'table-list': '@/lib/registry/components/v2-table-list',
'button-primary': '@/lib/registry/components/v2-button-primary',
'category-manager': '@/lib/registry/components/v2-category-manager',
'numbering-rule': '@/lib/registry/components/v2-numbering-rule',
// ... 기타 매핑
};
return mapping[v1Type] || `@/lib/registry/components/v2-${v1Type}`;
}
```
### 4.4 Step 3: 카테고리 데이터 마이그레이션
```sql
-- 본서버 카테고리 데이터 → 개발서버 category_values
INSERT INTO category_values (
table_name, column_name, value_code, value_label,
value_order, parent_value_id, depth, company_code
)
SELECT
-- V1 카테고리 데이터를 table_name + column_name 기반으로 변환
'inspection_standard' as table_name,
'inspection_method' as column_name,
value_code,
value_label,
sort_order,
NULL as parent_value_id,
1 as depth,
'COMPANY_7' as company_code
FROM [본서버_카테고리_데이터];
```
### 4.5 Step 4: 채번 규칙 마이그레이션
```sql
-- 본서버 채번 규칙 → 개발서버 numbering_rules
INSERT INTO numbering_rules (
rule_id, rule_name, table_name, column_name,
separator, reset_period, current_sequence, company_code
)
SELECT
rule_id,
rule_name,
'inspection_standard' as table_name,
'inspection_code' as column_name,
separator,
reset_period,
0 as current_sequence, -- 시퀀스 초기화
'COMPANY_7' as company_code
FROM [본서버_채번_규칙];
```
### 4.6 Step 5: table_type_columns 설정
```sql
-- 카테고리 컬럼 설정
UPDATE table_type_columns
SET input_type = 'category'
WHERE table_name = 'inspection_standard'
AND column_name = 'inspection_method'
AND company_code = 'COMPANY_7';
-- 채번 컬럼 설정
UPDATE table_type_columns
SET
input_type = 'numbering',
detail_settings = '{"numberingRuleId": "rule-xxx"}'
WHERE table_name = 'inspection_standard'
AND column_name = 'inspection_code'
AND company_code = 'COMPANY_7';
```
---
## 5. 품질관리 메뉴 마이그레이션 현황
### 5.1 화면 매핑 현황
| 본서버 코드 | 화면명 | 테이블 | 개발서버 상태 | 비고 |
|-------------|--------|--------|---------------|------|
| COMPANY_7_126 | 검사정보 관리 | inspection_standard | ✅ V2 존재 | 컴포넌트 수 확인 필요 |
| COMPANY_7_127 | 품목옵션 설정 | - | ✅ V2 존재 | v2-category-manager 사용중 |
| COMPANY_7_138 | 카테고리 설정 | inspection_standard | ❌ 누락 | V2: table_name 기반으로 변경 |
| COMPANY_7_139 | 코드 설정 | inspection_standard | ❌ 누락 | V2: table_name 기반으로 변경 |
| COMPANY_7_142 | 검사장비 관리 | inspection_equipment_mng | ❌ 누락 | 모달 통합 필요 |
| COMPANY_7_143 | 검사장비 등록모달 | inspection_equipment_mng | ❌ 누락 | COMPANY_7_142에 통합 |
| COMPANY_7_144 | 불량기준 정보 | defect_standard_mng | ❌ 누락 | 모달 통합 필요 |
| COMPANY_7_145 | 불량기준 등록모달 | defect_standard_mng | ❌ 누락 | COMPANY_7_144에 통합 |
### 5.2 카테고리/채번 컬럼 현황
**inspection_standard:**
| 컬럼 | input_type | 라벨 |
|------|------------|------|
| inspection_method | category | 검사방법 |
| unit | category | 단위 |
| apply_type | category | 적용구분 |
| inspection_type | category | 유형 |
**inspection_equipment_mng:**
| 컬럼 | input_type | 라벨 |
|------|------------|------|
| equipment_type | category | 장비유형 |
| installation_location | category | 설치장소 |
| equipment_status | category | 장비상태 |
**defect_standard_mng:**
| 컬럼 | input_type | 라벨 |
|------|------------|------|
| defect_type | category | 불량유형 |
| severity | category | 심각도 |
| inspection_type | category | 검사유형 |
---
## 6. 자동화 스크립트
### 6.1 마이그레이션 실행 스크립트
```typescript
// backend-node/src/scripts/migrateV1toV2.ts
import { getPool } from "../database/db";
interface MigrationResult {
screenCode: string;
success: boolean;
message: string;
componentCount?: number;
}
async function migrateScreenToV2(
screenCode: string,
companyCode: string
): Promise<MigrationResult> {
const pool = getPool();
try {
// 1. V1 레이아웃 조회 (본서버에서)
const v1Result = await pool.query(`
SELECT sl.*, sd.table_name, sd.screen_name
FROM screen_layouts sl
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
WHERE sd.screen_code = $1
ORDER BY sl.display_order
`, [screenCode]);
if (v1Result.rows.length === 0) {
return { screenCode, success: false, message: "V1 레이아웃 없음" };
}
// 2. V2 형식으로 변환
const components = v1Result.rows
.filter(row => row.component_type !== '_metadata')
.map(row => ({
id: row.component_id || `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
url: mapComponentUrl(row.component_type),
position: { x: row.position_x || 0, y: row.position_y || 0 },
size: { width: row.width || 100, height: row.height || 50 },
displayOrder: row.display_order || 0,
overrides: extractOverrides(row.properties, row.component_type)
}));
const layoutData = {
version: "2.0",
components,
migratedFrom: "V1",
migratedAt: new Date().toISOString()
};
// 3. 개발서버 V2 테이블에 저장
const screenId = v1Result.rows[0].screen_id;
await pool.query(`
INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data)
VALUES ($1, $2, $3)
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()
`, [screenId, companyCode, JSON.stringify(layoutData)]);
return {
screenCode,
success: true,
message: "마이그레이션 완료",
componentCount: components.length
};
} catch (error: any) {
return { screenCode, success: false, message: error.message };
}
}
function mapComponentUrl(v1Type: string): string {
const mapping: Record<string, string> = {
'table-list': '@/lib/registry/components/v2-table-list',
'button-primary': '@/lib/registry/components/v2-button-primary',
'text-input': '@/lib/registry/components/v2-text-input',
'select-basic': '@/lib/registry/components/v2-select',
'date-input': '@/lib/registry/components/v2-date-input',
'entity-search-input': '@/lib/registry/components/v2-entity-search',
'category-manager': '@/lib/registry/components/v2-category-manager',
'numbering-rule': '@/lib/registry/components/v2-numbering-rule',
'tabs-widget': '@/lib/registry/components/v2-tabs-widget',
'textarea-basic': '@/lib/registry/components/v2-textarea',
};
return mapping[v1Type] || `@/lib/registry/components/v2-${v1Type}`;
}
function extractOverrides(properties: any, componentType: string): Record<string, any> {
if (!properties) return {};
// V2 Zod 스키마 defaults와 비교하여 다른 값만 추출
// (실제 구현 시 각 컴포넌트의 defaultConfig와 비교)
const overrides: Record<string, any> = {};
// 필수 설정만 추출
if (properties.tableName) overrides.tableName = properties.tableName;
if (properties.columns) overrides.columns = properties.columns;
if (properties.label) overrides.label = properties.label;
if (properties.onClick) overrides.onClick = properties.onClick;
return overrides;
}
```
---
## 7. 검증 체크리스트
### 7.1 마이그레이션 전
- [ ] 본서버 화면 목록 확인
- [ ] 개발서버 기존 V2 데이터 백업
- [ ] 컴포넌트 매핑 테이블 검토
- [ ] 카테고리/채번 데이터 분석
### 7.2 마이그레이션 후
- [ ] screen_definitions 동기화 확인
- [ ] screen_layouts_v2 데이터 생성 확인
- [ ] 컴포넌트 렌더링 테스트
- [ ] 카테고리 드롭다운 동작 확인
- [ ] 채번 규칙 동작 확인
- [ ] 저장/수정/삭제 기능 테스트
### 7.3 모달 통합 확인
- [ ] 기존 모달 화면 → overlay 통합 완료
- [ ] 부모-자식 데이터 연동 확인
- [ ] 모달 열기/닫기 동작 확인
---
## 8. 롤백 계획
마이그레이션 실패 시 롤백 절차:
```sql
-- 1. V2 레이아웃 롤백
DELETE FROM screen_layouts_v2
WHERE screen_id IN (
SELECT screen_id FROM screen_definitions
WHERE screen_code LIKE 'COMPANY_7_%'
);
-- 2. 추가된 screen_definitions 롤백
DELETE FROM screen_definitions
WHERE screen_code IN ('신규_추가된_코드들')
AND company_code = 'COMPANY_7';
-- 3. category_values 롤백
DELETE FROM category_values
WHERE company_code = 'COMPANY_7'
AND created_at > '[마이그레이션_시작_시간]';
-- 4. numbering_rules 롤백
DELETE FROM numbering_rules
WHERE company_code = 'COMPANY_7'
AND created_at > '[마이그레이션_시작_시간]';
```
---
## 9. 참고 자료
### 관련 코드 파일
- **V2 Category Manager**: `frontend/lib/registry/components/v2-category-manager/`
- **V2 Numbering Rule**: `frontend/lib/registry/components/v2-numbering-rule/`
- **Category Service**: `backend-node/src/services/categoryTreeService.ts`
- **Numbering Service**: `backend-node/src/services/numberingRuleService.ts`
### 관련 문서
- [V2 컴포넌트 분석 가이드](../V2_컴포넌트_분석_가이드.md)
- [V2 컴포넌트 연동 가이드](../V2_컴포넌트_연동_가이드.md)
- [화면 개발 표준 가이드](../screen-implementation-guide/SCREEN_DEVELOPMENT_STANDARD.md)
- [컴포넌트 레이아웃 V2 아키텍처](./COMPONENT_LAYOUT_V2_ARCHITECTURE.md)
---
## 변경 이력
| 날짜 | 작성자 | 내용 |
|------|--------|------|
| 2026-02-03 | DDD1542 | 초안 작성 |

View File

@ -23,7 +23,8 @@
| 테이블명 | 용도 | 주요 컬럼 | | 테이블명 | 용도 | 주요 컬럼 |
|----------|------|----------| |----------|------|----------|
| `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` | | `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` |
| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 | `screen_id`, `properties` (JSONB - componentConfig 포함) | | `screen_layouts` | 화면 레이아웃/컴포넌트 정보 (Legacy) | `screen_id`, `properties` (JSONB - componentConfig 포함) |
| `screen_layouts_v2` | 화면 레이아웃/컴포넌트 정보 (V2) | `screen_id`, `layout_data` (JSONB - components 배열) |
| `screen_groups` | 화면 그룹 정보 | `group_id`, `group_code`, `group_name`, `parent_group_id` | | `screen_groups` | 화면 그룹 정보 | `group_id`, `group_code`, `group_name`, `parent_group_id` |
| `screen_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` | | `screen_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` |
@ -86,9 +87,17 @@ screen_groups (그룹)
│ │ │ │
│ └─── screen_definitions (화면) │ └─── screen_definitions (화면)
│ │ │ │
│ └─── screen_layouts (레이아웃/컴포넌트) │ ├─── screen_layouts (Legacy)
│ │ │
│ │ └─── properties.componentConfig
│ │ ├── fieldMappings
│ │ ├── parentDataMapping
│ │ ├── columns.mapping
│ │ └── rightPanel.relation
│ │
│ └─── screen_layouts_v2 (V2) ← 현재 표준
│ │ │ │
│ └─── properties.componentConfig │ └─── layout_data.components[].overrides
│ ├── fieldMappings │ ├── fieldMappings
│ ├── parentDataMapping │ ├── parentDataMapping
│ ├── columns.mapping │ ├── columns.mapping
@ -1120,9 +1129,12 @@ screenSubTables[screenId].subTables.push({
21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시) 21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시)
22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData) 22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData)
23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입) 23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입)
24. [ ] **선 교차점 이질감 해결** (계획 중) 24. [x] **screen_layouts_v2 지원 추가** (rightPanel.relation V2 UNION 쿼리) ✅ 2026-01-30
22. [ ] 범례 UI 추가 (선택사항) 25. [x] **테이블 분류 우선순위 시스템** (메인 > 서브 우선순위 적용) ✅ 2026-01-30
23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) 26. [x] **globalMainTables API 추가** (WHERE 조건 대상 테이블 목록 반환) ✅ 2026-01-30
27. [ ] **선 교차점 이질감 해결** (계획 중)
28. [ ] 범례 UI 추가 (선택사항)
29. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
--- ---
@ -1682,6 +1694,149 @@ frontend/
--- ---
## 테이블 분류 우선순위 시스템 (2026-01-30)
### 배경
마스터-디테일 관계의 디테일 테이블(예: `user_dept`)이 다른 곳에서 autocomplete 참조로도 사용되는 경우,
서브 테이블 영역에 잘못 배치되는 문제가 발생했습니다.
### 문제 상황
```
[user_info] - 화면 139의 디테일 → 메인 테이블 영역 (O)
[user_dept] - 화면 162의 디테일이지만 autocomplete 참조도 있음 → 서브 테이블 영역 (X)
```
**원인**: 테이블 분류 시 우선순위가 없어서 먼저 발견된 관계 타입으로 분류됨
### 해결책: 우선순위 기반 테이블 분류
#### 분류 규칙
| 우선순위 | 분류 | 조건 | 비고 |
|----------|------|------|------|
| **1순위** | 메인 테이블 | `screen_definitions.table_name` | 컴포넌트 직접 연결 |
| **1순위** | 메인 테이블 | `v2-split-panel-layout.rightPanel.tableName` | WHERE 조건 대상 |
| **2순위** | 서브 테이블 | 조인으로만 연결된 테이블 | autocomplete 등 참조 |
#### 핵심 규칙
> **메인 조건에 해당하면, 서브 조건이 있어도 무조건 메인으로 분류**
### 백엔드 변경 (`screenGroupController.ts`)
#### 1. screen_layouts_v2 지원 추가
`rightPanelQuery`에 V2 테이블 UNION 추가:
```sql
-- V1: screen_layouts에서 조회
SELECT ...
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL
UNION ALL
-- V2: screen_layouts_v2에서 조회 (v2-split-panel-layout 컴포넌트)
SELECT
sd.screen_id,
comp->'overrides'->>'type' as component_type,
comp->'overrides'->'rightPanel'->'relation' as right_panel_relation,
comp->'overrides'->'rightPanel'->>'tableName' as right_panel_table,
...
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp
WHERE comp->'overrides'->'rightPanel'->'relation' IS NOT NULL
```
#### 2. globalMainTables API 추가
`getScreenSubTables` 응답에 전역 메인 테이블 목록 추가:
```sql
-- 모든 화면의 메인 테이블 수집
SELECT DISTINCT table_name as main_table FROM screen_definitions WHERE screen_id = ANY($1)
UNION
SELECT DISTINCT comp->'overrides'->'rightPanel'->>'tableName' as main_table
FROM screen_layouts_v2 ...
```
**응답 구조:**
```typescript
res.json({
success: true,
data: screenSubTables,
globalMainTables: globalMainTables, // 메인 테이블 목록 추가
});
```
### 프론트엔드 변경 (`ScreenRelationFlow.tsx`)
#### 1. globalMainTables 상태 추가
```typescript
const [globalMainTables, setGlobalMainTables] = useState<Set<string>>(new Set());
```
#### 2. 우선순위 기반 테이블 분류
```typescript
// 1. globalMainTables를 mainTableSet에 먼저 추가 (우선순위 적용)
globalMainTables.forEach((tableName) => {
if (!mainTableSet.has(tableName)) {
mainTableSet.add(tableName);
filterTableSet.add(tableName); // 보라색 테두리
}
});
// 2. 서브 테이블 수집 (mainTableSet에 없는 것만)
screenSubData.subTables.forEach((subTable) => {
if (mainTableSet.has(subTable.tableName)) {
return; // 메인 테이블은 서브에서 제외
}
subTableSet.add(subTable.tableName);
});
```
### 시각적 결과
#### 변경 전
```
[화면 노드들]
[메인 테이블: dept_info, user_info] ← user_dept 없음
[서브 테이블: user_dept, customer_mng] ← user_dept가 잘못 배치됨
```
#### 변경 후
```
[화면 노드들]
[메인 테이블: dept_info, user_info, user_dept] ← user_dept 보라색 테두리
[서브 테이블: customer_mng] ← 조인 참조용 테이블만
```
### 관련 파일
| 파일 | 변경 내용 |
|------|----------|
| `backend-node/src/controllers/screenGroupController.ts` | screen_layouts_v2 UNION 추가, globalMainTables 반환 |
| `frontend/components/screen/ScreenRelationFlow.tsx` | globalMainTables 상태, 우선순위 분류 로직 |
| `frontend/components/screen/ScreenNode.tsx` | isFilterTable prop 및 보라색 테두리 스타일 |
---
## 화면 설정 모달 개선 (2026-01-12) ## 화면 설정 모달 개선 (2026-01-12)
### 개요 ### 개요
@ -1742,4 +1897,6 @@ npm install react-zoom-pan-pinch
- [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc) - [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc)
- [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.mdc) - [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.mdc)
- [관리자 페이지 스타일 가이드](.cursor/rules/admin-page-style-guide.mdc) - [관리자 페이지 스타일 가이드](.cursor/rules/admin-page-style-guide.mdc)
- [화면 복제 V2 마이그레이션 계획서](../SCREEN_COPY_V2_MIGRATION_PLAN.md) - screen_layouts_v2 복제 로직
- [V2 컴포넌트 마이그레이션 분석](../V2_COMPONENT_MIGRATION_ANALYSIS.md) - V2 아키텍처

View File

@ -467,9 +467,9 @@ V2 전환 롤백 (필요시):
- [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28 - [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28
- [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28 - [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28
- [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28 - [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28
- [ ] 단위 테스트 통과 - [x] 단위 테스트 통과 ✅ 2026-01-30
- [ ] 통합 테스트 통과 - [x] 통합 테스트 통과 ✅ 2026-01-30
- [ ] V2 전용 복제 동작 확인 - [x] V2 전용 복제 동작 확인 ✅ 2026-01-30
### 9.3 Phase 2 완료 조건 ### 9.3 Phase 2 완료 조건
@ -522,3 +522,4 @@ V2 전환 롤백 (필요시):
| 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude | | 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude |
| 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude | | 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude |
| 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude | | 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude |
| 2026-01-30 | **Phase 1 테스트 완료** - 단위/통합 테스트 통과 확인 | Claude |

View File

@ -0,0 +1,149 @@
# 저장 후 플로우 실행 시 폼 데이터 전달 오류 수정
## 오류 현상
사용자가 폼에서 데이터를 저장한 후, 연결된 노드 플로우(예: 비밀번호 자동 설정)가 실행될 때 `sabun` 값이 `undefined`로 전달되어 UPDATE 쿼리의 WHERE 조건이 작동하지 않는 문제.
### 증상
- 저장 버튼 클릭 시 INSERT는 정상 작동
- 저장 후 실행되는 노드 플로우에서 `user_password` UPDATE가 실패 (0건 업데이트)
- 콘솔 로그에서 `savedData.sabun: undefined` 출력
```
📦 [executeAfterSaveControl] savedData 필드: ['id', 'screenId', 'tableName', 'data', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']
📦 [executeAfterSaveControl] savedData.sabun: undefined
```
---
## 원인 분석
### API 응답 구조의 3단계 중첩
저장 API(`DynamicFormApi.saveFormData`)의 응답이 3단계로 중첩되어 있었음:
```typescript
// 1단계: Axios 응답
saveResult = {
data: { ... } // API 응답
}
// 2단계: API 응답 래핑 (ApiResponse 인터페이스)
saveResult.data = {
success: true,
data: { ... }, // 저장된 레코드
message: "저장 완료"
}
// 3단계: 저장된 레코드 (dynamic_form_data 테이블 구조)
saveResult.data.data = {
id: 123,
screenId: 106,
tableName: "user_info",
data: { sabun: "20260205-087", user_name: "TEST", ... }, // ← 실제 폼 데이터
createdAt: "2026-02-05T...",
updatedAt: "2026-02-05T...",
createdBy: "admin",
updatedBy: "admin"
}
// 4단계: 실제 폼 데이터 (우리가 필요한 데이터)
saveResult.data.data.data = {
sabun: "20260205-087",
user_name: "TEST",
user_id: "Kim1542",
...
}
```
### 기존 코드의 문제점
```typescript
// 기존 코드 (buttonActions.ts:1619-1621)
const savedData = saveResult?.data?.data || saveResult?.data || {};
const formData = savedData; // ← 2단계까지만 추출
// savedData = { id, screenId, tableName, data: {...}, createdAt, ... }
// savedData.sabun = undefined ← 문제 발생!
```
기존 코드는 2단계(`saveResult.data.data`)까지만 추출했기 때문에, `savedData`가 저장된 레코드 메타데이터를 가리키고 있었음. 실제 폼 데이터는 `savedData.data` 안에 있었음.
---
## 해결 방법
### 수정된 코드
```typescript
// 수정된 코드 (buttonActions.ts:1619-1628)
// 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출
// saveResult.data = API 응답 { success, data, message }
// saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... }
// saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... }
const savedRecord = saveResult?.data?.data || saveResult?.data || {};
const actualFormData = savedRecord?.data || savedRecord; // ← 3단계까지 추출
const formData = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {});
```
### 수정 핵심
1. `savedRecord`: 저장된 레코드 메타데이터 (`{ id, screenId, tableName, data, ... }`)
2. `actualFormData`: `savedRecord.data`가 있으면 그것을 사용, 없으면 `savedRecord` 자체 사용
3. 폴백: `actualFormData`가 비어있으면 `context.formData` 사용
---
## 수정된 파일
| 파일 | 수정 내용 |
|------|-----------|
| `frontend/lib/utils/buttonActions.ts` | 3단계 중첩 데이터 구조에서 실제 폼 데이터 추출 로직 수정 (라인 1619-1628) |
---
## 검증 결과
### 수정 전
```
📦 [executeAfterSaveControl] savedData 필드: ['id', 'screenId', 'tableName', 'data', ...]
📦 [executeAfterSaveControl] savedData.sabun: undefined
```
### 수정 후
```
📦 [executeAfterSaveControl] savedRecord 구조: ['id', 'screenId', 'tableName', 'data', ...]
📦 [executeAfterSaveControl] actualFormData 추출: ['sabun', 'user_id', 'user_password', ...]
📦 [executeAfterSaveControl] formData.sabun: 20260205-087
```
### DB 확인
```sql
SELECT sabun, user_name, user_password FROM user_info WHERE sabun = '20260205-087';
-- 결과: sabun: "20260205-087", user_name: "TEST", user_password: "1e538e2abdd9663437343212a4853591"
```
---
## 교훈
1. **API 응답 구조 확인**: API 응답이 여러 단계로 래핑될 수 있음. 프론트엔드에서 `apiClient`가 한 번, `ApiResponse` 인터페이스가 한 번, 그리고 실제 데이터 구조가 또 다른 레벨을 가질 수 있음.
2. **로그 추가의 중요성**: 중간 단계마다 로그를 찍어 데이터 구조를 확인하는 것이 디버깅에 필수적.
3. **폴백 처리**: 데이터 추출 시 여러 단계의 폴백을 두어 다양한 응답 구조에 대응.
---
## 관련 이슈
- 비밀번호 자동 설정 노드 플로우가 저장 후 실행되지 않는 문제
- 저장 후 연결된 UPDATE 플로우에서 WHERE 조건이 작동하지 않는 문제
---
## 작성 정보
- **작성일**: 2026-02-05
- **작성자**: AI Assistant
- **관련 화면**: 부서관리 > 사용자 등록 모달
- **관련 플로우**: flowId: 120 (부서관리 비밀번호 자동세팅)

View File

@ -0,0 +1,573 @@
{
"version": "2.0",
"screenResolution": {
"width": 1400,
"height": 900,
"name": "수주등록 모달",
"category": "modal"
},
"components": [
{
"id": "section-options",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 20, "z": 1 },
"size": { "width": 1360, "height": 80 },
"overrides": {
"componentConfig": {
"title": "",
"showHeader": false,
"padding": "md",
"borderStyle": "solid"
}
},
"displayOrder": 0
},
{
"id": "select-input-method",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 40, "y": 35, "z": 2 },
"size": { "width": 300, "height": 40 },
"overrides": {
"label": "입력 방식",
"columnName": "input_method",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "customer_first", "label": "거래처 우선" },
{ "value": "item_first", "label": "품목 우선" }
],
"placeholder": "입력 방식 선택"
},
"displayOrder": 1
},
{
"id": "select-sales-type",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 360, "y": 35, "z": 2 },
"size": { "width": 300, "height": 40 },
"overrides": {
"label": "판매 유형",
"columnName": "sales_type",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "domestic", "label": "국내 판매" },
{ "value": "overseas", "label": "해외 판매" }
],
"placeholder": "판매 유형 선택"
},
"displayOrder": 2
},
{
"id": "select-price-method",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 680, "y": 35, "z": 2 },
"size": { "width": 250, "height": 40 },
"overrides": {
"label": "단가 방식",
"columnName": "price_method",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "standard", "label": "기준 단가" },
{ "value": "contract", "label": "계약 단가" },
{ "value": "custom", "label": "개별 입력" }
],
"placeholder": "단가 방식"
},
"displayOrder": 3
},
{
"id": "checkbox-price-edit",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 950, "y": 35, "z": 2 },
"size": { "width": 150, "height": 40 },
"overrides": {
"label": "단가 수정 허용",
"columnName": "allow_price_edit",
"mode": "check",
"source": "static",
"options": [{ "value": "Y", "label": "허용" }]
},
"displayOrder": 4
},
{
"id": "section-customer-info",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 110, "z": 1 },
"size": { "width": 1360, "height": 120 },
"overrides": {
"componentConfig": {
"title": "거래처 정보",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 5
},
{
"id": "select-customer",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 40, "y": 155, "z": 3 },
"size": { "width": 320, "height": 40 },
"overrides": {
"label": "거래처 *",
"columnName": "partner_id",
"mode": "dropdown",
"source": "entity",
"entityTable": "customer_mng",
"entityValueColumn": "customer_code",
"entityLabelColumn": "customer_name",
"searchable": true,
"placeholder": "거래처명 입력하여 검색",
"required": true,
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 6
},
{
"id": "input-manager",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 380, "y": 155, "z": 3 },
"size": { "width": 240, "height": 40 },
"overrides": {
"label": "담당자",
"columnName": "manager_name",
"placeholder": "담당자",
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 7
},
{
"id": "input-delivery-partner",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 640, "y": 155, "z": 3 },
"size": { "width": 240, "height": 40 },
"overrides": {
"label": "납품처",
"columnName": "delivery_partner_id",
"placeholder": "납품처",
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 8
},
{
"id": "input-delivery-address",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 900, "y": 155, "z": 3 },
"size": { "width": 460, "height": 40 },
"overrides": {
"label": "납품장소",
"columnName": "delivery_address",
"placeholder": "납품장소",
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 9
},
{
"id": "section-item-first",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 110, "z": 1 },
"size": { "width": 1360, "height": 200 },
"overrides": {
"componentConfig": {
"title": "품목 및 거래처별 수주",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "item_first",
"action": "show"
}
},
"displayOrder": 10
},
{
"id": "section-items",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 240, "z": 1 },
"size": { "width": 1360, "height": 280 },
"overrides": {
"componentConfig": {
"title": "추가된 품목",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 11
},
{
"id": "btn-item-search",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1140, "y": 245, "z": 5 },
"size": { "width": 100, "height": 36 },
"overrides": {
"label": "품목 검색",
"action": {
"type": "openModal",
"modalType": "itemSelection"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 12
},
{
"id": "btn-shipping-plan",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1250, "y": 245, "z": 5 },
"size": { "width": 100, "height": 36 },
"overrides": {
"label": "출하계획",
"webTypeConfig": {
"variant": "destructive"
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 13
},
{
"id": "repeater-items",
"url": "@/lib/registry/components/v2-repeater",
"position": { "x": 40, "y": 290, "z": 3 },
"size": { "width": 1320, "height": 200 },
"overrides": {
"renderMode": "modal",
"dataSource": {
"tableName": "sales_order_detail",
"foreignKey": "order_no",
"referenceKey": "order_no"
},
"columns": [
{ "field": "part_code", "header": "품번", "width": 100 },
{ "field": "part_name", "header": "품명", "width": 150 },
{ "field": "spec", "header": "규격", "width": 100 },
{ "field": "unit", "header": "단위", "width": 80 },
{ "field": "qty", "header": "수량", "width": 100, "editable": true },
{
"field": "unit_price",
"header": "단가",
"width": 100,
"editable": true
},
{ "field": "amount", "header": "금액", "width": 100 },
{
"field": "due_date",
"header": "납기일",
"width": 120,
"editable": true
}
],
"modal": {
"sourceTable": "item_info",
"sourceColumns": [
"part_code",
"part_name",
"spec",
"material",
"unit_price"
],
"filterCondition": {}
},
"features": {
"showAddButton": false,
"showDeleteButton": true,
"inlineEdit": true
},
"conditionalConfig": {
"enabled": true,
"field": "input_method",
"operator": "=",
"value": "customer_first",
"action": "show"
}
},
"displayOrder": 14
},
{
"id": "section-trade-info",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 530, "z": 1 },
"size": { "width": 1360, "height": 150 },
"overrides": {
"componentConfig": {
"title": "무역 정보",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
},
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 15
},
{
"id": "select-incoterms",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 40, "y": 575, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "인코텀즈",
"columnName": "incoterms",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "FOB", "label": "FOB" },
{ "value": "CIF", "label": "CIF" },
{ "value": "EXW", "label": "EXW" },
{ "value": "DDP", "label": "DDP" }
],
"placeholder": "선택",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 16
},
{
"id": "select-payment-term",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 260, "y": 575, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "결제 조건",
"columnName": "payment_term",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "TT", "label": "T/T" },
{ "value": "LC", "label": "L/C" },
{ "value": "DA", "label": "D/A" },
{ "value": "DP", "label": "D/P" }
],
"placeholder": "선택",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 17
},
{
"id": "select-currency",
"url": "@/lib/registry/components/v2-select",
"position": { "x": 480, "y": 575, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "통화",
"columnName": "currency",
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "KRW", "label": "KRW (원)" },
{ "value": "USD", "label": "USD (달러)" },
{ "value": "EUR", "label": "EUR (유로)" },
{ "value": "JPY", "label": "JPY (엔)" },
{ "value": "CNY", "label": "CNY (위안)" }
],
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 18
},
{
"id": "input-port-loading",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 40, "y": 625, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "선적항",
"columnName": "port_of_loading",
"placeholder": "선적항",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 19
},
{
"id": "input-port-discharge",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 260, "y": 625, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "도착항",
"columnName": "port_of_discharge",
"placeholder": "도착항",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 20
},
{
"id": "input-hs-code",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 480, "y": 625, "z": 3 },
"size": { "width": 200, "height": 40 },
"overrides": {
"label": "HS Code",
"columnName": "hs_code",
"placeholder": "HS Code",
"conditionalConfig": {
"enabled": true,
"field": "sales_type",
"operator": "=",
"value": "overseas",
"action": "show"
}
},
"displayOrder": 21
},
{
"id": "section-additional",
"url": "@/lib/registry/components/v2-section-card",
"position": { "x": 20, "y": 690, "z": 1 },
"size": { "width": 1360, "height": 130 },
"overrides": {
"componentConfig": {
"title": "추가 정보",
"showHeader": true,
"padding": "md",
"borderStyle": "solid"
}
},
"displayOrder": 22
},
{
"id": "input-memo",
"url": "@/lib/registry/components/v2-input",
"position": { "x": 40, "y": 735, "z": 3 },
"size": { "width": 1320, "height": 70 },
"overrides": {
"label": "메모",
"columnName": "memo",
"type": "textarea",
"placeholder": "메모를 입력하세요"
},
"displayOrder": 23
},
{
"id": "btn-cancel",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1180, "y": 840, "z": 5 },
"size": { "width": 90, "height": 40 },
"overrides": {
"label": "취소",
"webTypeConfig": {
"variant": "outline"
},
"action": {
"type": "close"
}
},
"displayOrder": 24
},
{
"id": "btn-save",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1280, "y": 840, "z": 5 },
"size": { "width": 90, "height": 40 },
"overrides": {
"label": "저장",
"action": {
"type": "save"
}
},
"displayOrder": 25
}
],
"gridSettings": {
"columns": 12,
"gap": 16,
"padding": 20,
"snapToGrid": true,
"showGrid": false
}
}

View File

@ -161,7 +161,6 @@ function ScreenViewPage() {
// V2 레이아웃: Zod 기반 변환 (기본값 병합) // V2 레이아웃: Zod 기반 변환 (기본값 병합)
const convertedLayout = convertV2ToLegacy(v2Response); const convertedLayout = convertV2ToLegacy(v2Response);
if (convertedLayout) { if (convertedLayout) {
console.log("📦 V2 레이아웃 로드 (Zod 기반):", v2Response.components?.length || 0, "개 컴포넌트");
setLayout({ setLayout({
...convertedLayout, ...convertedLayout,
screenResolution: v2Response.screenResolution || convertedLayout.screenResolution, screenResolution: v2Response.screenResolution || convertedLayout.screenResolution,
@ -227,7 +226,6 @@ function ScreenViewPage() {
); );
if (hasTableWidget) { if (hasTableWidget) {
console.log("📋 테이블 위젯이 있어 자동 로드 건너뜀 (행 선택으로 데이터 로드)");
return; return;
} }
@ -238,7 +236,9 @@ function ScreenViewPage() {
compType?.includes("select") || compType?.includes("select") ||
compType?.includes("textarea") || compType?.includes("textarea") ||
compType?.includes("v2-input") || compType?.includes("v2-input") ||
compType?.includes("v2-select"); compType?.includes("v2-select") ||
compType?.includes("v2-media") ||
compType?.includes("file-upload"); // 🆕 레거시 파일 업로드 포함
const hasColumnName = !!(comp as any).columnName; const hasColumnName = !!(comp as any).columnName;
return isInputType && hasColumnName; return isInputType && hasColumnName;
}); });

View File

@ -13,6 +13,7 @@ import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -126,6 +127,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
const modalOpenedAtRef = React.useRef<number>(0); const modalOpenedAtRef = React.useRef<number>(0);
// 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너
useEffect(() => {
const handleNumberingValueChanged = (event: CustomEvent) => {
const { columnName, value } = event.detail;
if (columnName && modalState.isOpen) {
setFormData((prev) => ({
...prev,
[columnName]: value,
}));
}
};
window.addEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
return () => {
window.removeEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
};
}, [modalState.isOpen]);
// 전역 모달 이벤트 리스너 // 전역 모달 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleOpenModal = (event: CustomEvent) => { const handleOpenModal = (event: CustomEvent) => {
@ -139,6 +158,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
splitPanelParentData, splitPanelParentData,
selectedData: eventSelectedData, selectedData: eventSelectedData,
selectedIds, selectedIds,
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
} = event.detail; } = event.detail;
// 🆕 모달 열린 시간 기록 // 🆕 모달 열린 시간 기록
@ -162,7 +182,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} }
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
if (editData) { // 🔧 단, isCreateMode가 true이면 (복사 모드) originalData를 설정하지 않음 → 채번 생성 가능
if (editData && !isCreateMode) {
// 🆕 배열인 경우 두 가지 데이터를 설정: // 🆕 배열인 경우 두 가지 데이터를 설정:
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등) // 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등) // 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
@ -176,6 +197,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장 setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} }
} else if (editData && isCreateMode) {
// 🆕 복사 모드: formData만 설정하고 originalData는 null로 유지 (채번 생성 가능)
if (Array.isArray(editData)) {
const firstRecord = editData[0] || {};
setFormData(firstRecord);
setSelectedData(editData);
} else {
setFormData(editData);
setSelectedData([editData]);
}
setOriginalData(null); // 🔧 복사 모드에서는 originalData를 null로 설정
} else { } else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함 // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
@ -322,12 +354,27 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
try { try {
setLoading(true); setLoading(true);
// 화면 정보와 레이아웃 데이터 로딩 // 화면 정보와 레이아웃 데이터 로딩 (V2 API 사용으로 기본값 병합)
const [screenInfo, layoutData] = await Promise.all([ const [screenInfo, v2LayoutData] = await Promise.all([
screenApi.getScreen(screenId), screenApi.getScreen(screenId),
screenApi.getLayout(screenId), screenApi.getLayoutV2(screenId),
]); ]);
// V2 → Legacy 변환 (기본값 병합 포함)
let layoutData: any = null;
if (v2LayoutData && isValidV2Layout(v2LayoutData)) {
layoutData = convertV2ToLegacy(v2LayoutData);
if (layoutData) {
// screenResolution은 V2 레이아웃에서 직접 가져오기
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
}
}
// V2 레이아웃이 없으면 기존 API로 fallback
if (!layoutData) {
layoutData = await screenApi.getLayout(screenId);
}
// 🆕 URL 파라미터 확인 (수정 모드) // 🆕 URL 파라미터 확인 (수정 모드)
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -337,8 +384,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const groupByColumnsParam = urlParams.get("groupByColumns"); const groupByColumnsParam = urlParams.get("groupByColumns");
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명 const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn });
// 수정 모드이고 editId가 있으면 해당 레코드 조회 // 수정 모드이고 editId가 있으면 해당 레코드 조회
if (mode === "edit" && editId && tableName) { if (mode === "edit" && editId && tableName) {
try { try {
@ -363,14 +408,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용) // 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용)
if (primaryKeyColumn) { if (primaryKeyColumn) {
params.primaryKeyColumn = primaryKeyColumn; params.primaryKeyColumn = primaryKeyColumn;
console.log("✅ [ScreenModal] primaryKeyColumn을 params에 추가:", primaryKeyColumn);
} }
console.log("📡 [ScreenModal] 실제 API 요청:", {
url: `/data/${tableName}/${editId}`,
params,
});
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params }); const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
const response = apiResponse.data; const response = apiResponse.data;
@ -483,26 +522,34 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return { return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용 style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
needsScroll: false,
}; };
} }
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3) // 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
const footerHeight = 52; // 연속 등록 모드 체크박스 영역 const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
const dialogGap = 16; // DialogContent gap-4 const footerHeight = 44; // 연속 등록 모드 체크박스 영역
const extraPadding = 24; // 추가 여백 (안전 마진) const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이)
const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩)
const horizontalPadding = 16; // 좌우 패딩 최소화
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + extraPadding; const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding;
const maxAvailableHeight = window.innerHeight * 0.95;
// 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요
const needsScroll = totalHeight > maxAvailableHeight;
return { return {
className: "overflow-hidden p-0", className: "overflow-hidden p-0",
style: { style: {
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가 width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
maxHeight: `${maxAvailableHeight}px`,
maxWidth: "98vw", maxWidth: "98vw",
maxHeight: "95vh",
}, },
needsScroll,
}; };
}; };
@ -570,7 +617,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return ( return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}> <Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent <DialogContent
className={`${modalStyle.className} ${className || ""} max-w-none`} className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
{...(modalStyle.style && { style: modalStyle.style })} {...(modalStyle.style && { style: modalStyle.style })}
> >
<DialogHeader className="shrink-0 border-b px-4 py-3"> <DialogHeader className="shrink-0 border-b px-4 py-3">
@ -585,7 +632,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent"> <div
className={`flex-1 min-h-0 flex items-start justify-center pt-6 ${modalStyle.needsScroll ? 'overflow-auto' : 'overflow-visible'}`}
>
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -597,30 +646,137 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<ActiveTabProvider> <ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div <div
className="relative mx-auto bg-white" className="relative bg-white"
style={{ style={{
width: `${screenDimensions?.width || 800}px`, width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`, height: `${screenDimensions?.height || 600}px`,
transformOrigin: "center center", // 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
overflow: "visible",
}} }}
> >
{screenData.components.map((component) => { {(() => {
// 🆕 동적 y 좌표 조정을 위해 먼저 숨겨지는 컴포넌트들 파악
const isComponentHidden = (comp: any) => {
const cc = comp.componentConfig?.conditionalConfig || comp.conditionalConfig;
if (!cc?.enabled || !formData) return false;
const { field, operator, value, action } = cc;
const fieldValue = formData[field];
let conditionMet = false;
switch (operator) {
case "=":
case "==":
case "===":
conditionMet = fieldValue === value;
break;
case "!=":
case "!==":
conditionMet = fieldValue !== value;
break;
default:
conditionMet = fieldValue === value;
}
return (action === "show" && !conditionMet) || (action === "hide" && conditionMet);
};
// 표시되는 컴포넌트들의 y 범위 수집
const visibleRanges: { y: number; bottom: number }[] = [];
screenData.components.forEach((comp: any) => {
if (!isComponentHidden(comp)) {
const y = parseFloat(comp.position?.y?.toString() || "0");
const height = parseFloat(comp.size?.height?.toString() || "0");
visibleRanges.push({ y, bottom: y + height });
}
});
// 숨겨지는 컴포넌트의 "실제 빈 공간" 계산 (표시되는 컴포넌트와 겹치지 않는 영역)
const getActualGap = (hiddenY: number, hiddenBottom: number): number => {
// 숨겨지는 영역 중 표시되는 컴포넌트와 겹치는 부분을 제외
let gapStart = hiddenY;
let gapEnd = hiddenBottom;
for (const visible of visibleRanges) {
// 겹치는 영역 확인
if (visible.y < gapEnd && visible.bottom > gapStart) {
// 겹치는 부분을 제외
if (visible.y <= gapStart && visible.bottom >= gapEnd) {
// 완전히 덮힘 - 빈 공간 없음
return 0;
} else if (visible.y <= gapStart) {
// 위쪽이 덮힘
gapStart = visible.bottom;
} else if (visible.bottom >= gapEnd) {
// 아래쪽이 덮힘
gapEnd = visible.y;
}
}
}
return Math.max(0, gapEnd - gapStart);
};
// 숨겨지는 컴포넌트들의 실제 빈 공간 수집
const hiddenGaps: { bottom: number; gap: number }[] = [];
screenData.components.forEach((comp: any) => {
if (isComponentHidden(comp)) {
const y = parseFloat(comp.position?.y?.toString() || "0");
const height = parseFloat(comp.size?.height?.toString() || "0");
const bottom = y + height;
const gap = getActualGap(y, bottom);
if (gap > 0) {
hiddenGaps.push({ bottom, gap });
}
}
});
// bottom 기준으로 정렬 및 중복 제거 (같은 bottom은 가장 큰 gap만 유지)
const mergedGaps = new Map<number, number>();
hiddenGaps.forEach(({ bottom, gap }) => {
const existing = mergedGaps.get(bottom) || 0;
mergedGaps.set(bottom, Math.max(existing, gap));
});
const sortedGaps = Array.from(mergedGaps.entries())
.map(([bottom, gap]) => ({ bottom, gap }))
.sort((a, b) => a.bottom - b.bottom);
// 각 컴포넌트의 y 조정값 계산 함수
const getYOffset = (compY: number, compId?: string) => {
let offset = 0;
for (const { bottom, gap } of sortedGaps) {
// 컴포넌트가 숨겨진 영역 아래에 있으면 그 빈 공간만큼 위로 이동
if (compY > bottom) {
offset += gap;
}
}
return offset;
};
return screenData.components.map((component: any) => {
// 숨겨지는 컴포넌트는 렌더링 안함
if (isComponentHidden(component)) {
return null;
}
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0; const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0; const offsetY = screenDimensions?.offsetY || 0;
// 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동)
const compY = parseFloat(component.position?.y?.toString() || "0");
const yAdjustment = getYOffset(compY, component.id);
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = const adjustedComponent = {
offsetX === 0 && offsetY === 0 ...component,
? component position: {
: { ...component.position,
...component, x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
position: { y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
...component.position, },
x: parseFloat(component.position?.x?.toString() || "0") - offsetX, };
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
return ( return (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
@ -652,7 +808,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
companyCode={user?.companyCode} companyCode={user?.companyCode}
/> />
); );
})} });
})()}
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider> </ActiveTabProvider>

View File

@ -260,22 +260,24 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
toast.success(`규칙 ${newPart.order}가 추가되었습니다`); toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]); }, [currentRule, maxRules]);
const handleUpdatePart = useCallback((partId: string, updates: Partial<NumberingRulePart>) => { // partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용)
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
setCurrentRule((prev) => { setCurrentRule((prev) => {
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...prev,
parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)), parts: prev.parts.map((part) => (part.order === partOrder ? { ...part, ...updates } : part)),
}; };
}); });
}, []); }, []);
const handleDeletePart = useCallback((partId: string) => { // partOrder 기반으로 파트 삭제 (id가 null일 수 있으므로 order 사용)
const handleDeletePart = useCallback((partOrder: number) => {
setCurrentRule((prev) => { setCurrentRule((prev) => {
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...prev,
parts: prev.parts.filter((part) => part.id !== partId).map((part, index) => ({ ...part, order: index + 1 })), parts: prev.parts.filter((part) => part.order !== partOrder).map((part, index) => ({ ...part, order: index + 1 })),
}; };
}); });
@ -295,8 +297,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
setLoading(true); setLoading(true);
try { try {
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
// 파트별 기본 autoConfig 정의 // 파트별 기본 autoConfig 정의
const defaultAutoConfigs: Record<string, any> = { const defaultAutoConfigs: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 }, sequence: { sequenceLength: 3, startFrom: 1 },
@ -345,15 +345,30 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const response = await saveNumberingRuleToTest(ruleToSave); const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) { if (response.success && response.data) {
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
setSavedRules((prev) => { setSavedRules((prev) => {
if (existing) { const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? response.data! : r)); const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
console.log("🔍 [handleSave] setSavedRules:", {
ruleId: ruleToSave.ruleId,
existsInPrev,
prevCount: prev.length,
});
if (existsInPrev) {
// 기존 규칙 업데이트
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
} else { } else {
return [...prev, response.data!]; // 새 규칙 추가
return [...prev, savedData];
} }
}); });
setCurrentRule(response.data); setCurrentRule(currentData);
setSelectedRuleId(response.data.ruleId); setSelectedRuleId(response.data.ruleId);
await onSave?.(response.data); await onSave?.(response.data);
@ -366,11 +381,27 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentRule, savedRules, onSave, currentTableName]); }, [currentRule, onSave, currentTableName, menuObjid]);
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => { const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
console.log("🔍 [handleSelectRule] 규칙 선택:", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
partsCount: rule.parts?.length || 0,
parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
});
setSelectedRuleId(rule.ruleId); setSelectedRuleId(rule.ruleId);
setCurrentRule(rule); // 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
ruleId: ruleCopy.ruleId,
partsCount: ruleCopy.parts?.length || 0,
parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
});
setCurrentRule(ruleCopy);
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`); toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
}, []); }, []);
@ -595,12 +626,12 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{currentRule.parts.map((part) => ( {currentRule.parts.map((part, index) => (
<NumberingRuleCard <NumberingRuleCard
key={part.id} key={`part-${part.order}-${index}`}
part={part} part={part}
onUpdate={(updates) => handleUpdatePart(part.id, updates)} onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.id)} onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview} isPreview={isPreview}
/> />
))} ))}

View File

@ -704,7 +704,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
controlConfig, controlConfig,
}); });
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { // 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
const flowTiming = controlConfig?.dataflowTiming
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
if (controlConfig?.enableDataflowControl && flowTiming === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
// buttonActions의 executeAfterSaveControl 동적 import // buttonActions의 executeAfterSaveControl 동적 import
@ -762,41 +767,51 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
} }
} }
// 채번 규칙이 있는 필드에 대해 allocateCode 호출 // 채번 규칙이 있는 필드에 대해 allocateCode 호출 (🚀 병렬 처리로 최적화)
if (Object.keys(fieldsWithNumbering).length > 0) { if (Object.keys(fieldsWithNumbering).length > 0) {
console.log("🎯 [EditModal] 채번 규칙 할당 시작"); console.log("🎯 [EditModal] 채번 규칙 할당 시작, formData:", {
material: formData.material,
allKeys: Object.keys(formData),
});
const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
let hasAllocationFailure = false; // 🚀 Promise.all로 병렬 처리 (여러 채번 필드가 있을 경우 성능 향상)
const failedFields: string[] = []; const allocationPromises = Object.entries(fieldsWithNumbering).map(
async ([fieldName, ruleId]) => {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { const userInputCode = dataToSave[fieldName] as string;
try {
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) { try {
const newCode = allocateResult.data.generatedCode; const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]}${newCode}`);
dataToSave[fieldName] = newCode; if (allocateResult.success && allocateResult.data?.generatedCode) {
} else { return { fieldName, success: true, code: allocateResult.data.generatedCode };
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error); } else {
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") { console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
hasAllocationFailure = true; return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
failedFields.push(fieldName);
} }
} catch (allocateError) {
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
} }
} catch (allocateError) { }
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError); );
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
hasAllocationFailure = true; const allocationResults = await Promise.all(allocationPromises);
failedFields.push(fieldName);
} // 결과 처리
const failedFields: string[] = [];
for (const result of allocationResults) {
if (result.success && result.code) {
console.log(`✅ [EditModal] ${result.fieldName} 새 코드 할당: ${result.code}`);
dataToSave[result.fieldName] = result.code;
} else if (!result.hasExistingValue) {
failedFields.push(result.fieldName);
} }
} }
// 채번 규칙 할당 실패 시 저장 중단 // 채번 규칙 할당 실패 시 저장 중단
if (hasAllocationFailure) { if (failedFields.length > 0) {
const fieldNames = failedFields.join(", "); const fieldNames = failedFields.join(", ");
toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`); toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`);
console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`); console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`);
@ -863,7 +878,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig }); console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { // 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
const flowTimingInsert = controlConfig?.dataflowTiming
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
if (controlConfig?.enableDataflowControl && flowTimingInsert === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
@ -936,7 +956,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig }); console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { // 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
const flowTimingUpdate = controlConfig?.dataflowTiming
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
if (controlConfig?.enableDataflowControl && flowTimingUpdate === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");

View File

@ -43,7 +43,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client"; import { apiClient, getCurrentUser, UserInfo, getFullImageUrl } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup"; import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -2224,6 +2224,37 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함) // 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
// 🖼️ 이미지 타입 컬럼: 썸네일로 표시
const isImageColumn = actualWebType === "image" || actualWebType === "img";
if (isImageColumn && value) {
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
// 🔑 download 대신 preview 사용 (공개 접근 허용)
const isObjid = /^\d+$/.test(String(value));
const imageUrl = isObjid
? `/api/files/preview/${value}`
: getFullImageUrl(String(value));
return (
<div className="flex justify-center">
<img
src={imageUrl}
alt="이미지"
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
// 이미지 클릭 시 크게 보기 (새 탭에서 열기)
window.open(imageUrl, "_blank");
}}
onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘 표시
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
);
}
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리) // 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
if (isFileColumn && rowData) { if (isFileColumn && rowData) {
// 현재 행의 기본키 값 가져오기 // 현재 행의 기본키 값 가져오기

View File

@ -335,7 +335,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 동적 대화형 위젯 렌더링 // 동적 대화형 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => { const renderInteractiveWidget = (comp: ComponentData) => {
// 조건부 표시 평가 // 조건부 표시 평가 (기존 conditional 시스템)
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents); const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
// 조건에 따라 숨김 처리 // 조건에 따라 숨김 처리
@ -343,6 +343,35 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return null; return null;
} }
// 🆕 conditionalConfig 시스템 체크 (V2 레이아웃용)
const conditionalConfig = (comp as any).componentConfig?.conditionalConfig;
if (conditionalConfig?.enabled && formData) {
const { field, operator, value, action } = conditionalConfig;
const fieldValue = formData[field];
let conditionMet = false;
switch (operator) {
case "=":
case "==":
case "===":
conditionMet = fieldValue === value;
break;
case "!=":
case "!==":
conditionMet = fieldValue !== value;
break;
default:
conditionMet = fieldValue === value;
}
if (action === "show" && !conditionMet) {
return null;
}
if (action === "hide" && conditionMet) {
return null;
}
}
// 데이터 테이블 컴포넌트 처리 // 데이터 테이블 컴포넌트 처리
if (isDataTableComponent(comp)) { if (isDataTableComponent(comp)) {
return ( return (
@ -533,11 +562,31 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
try { try {
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {}; const masterFormData: Record<string, any> = {};
// 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함)
const mediaColumnNames = new Set(
allComponents
.filter((c: any) =>
c.componentType === "v2-media" ||
c.componentType === "file-upload" ||
c.url?.includes("v2-media") ||
c.url?.includes("file-upload")
)
.map((c: any) => c.columnName || c.componentConfig?.columnName)
.filter(Boolean)
);
Object.entries(formData).forEach(([key, value]) => { Object.entries(formData).forEach(([key, value]) => {
// 배열 데이터는 리피터 데이터이므로 제외
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
// 배열이 아닌 값은 그대로 저장
masterFormData[key] = value; masterFormData[key] = value;
} else if (mediaColumnNames.has(key)) {
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
masterFormData[key] = value.length > 0 ? value[0] : null;
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
} else { } else {
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`); console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
} }
@ -1018,22 +1067,35 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정 // TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget"; const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false &&
(style?.labelText || (component as any).label);
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
const componentStyle = { const componentStyle = {
position: "absolute" as const, position: "absolute" as const,
left: position?.x || 0, left: position?.x || 0,
top: position?.y || 0, top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
zIndex: position?.z || 1, zIndex: position?.z || 1,
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용 ...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위 width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
height: isTableSearchWidget ? "auto" : size?.height || 10, height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined, minHeight: isTableSearchWidget ? "48px" : undefined,
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
overflow: labelOffset > 0 ? "visible" : undefined,
}; };
return ( return (
<> <>
<div className="absolute" style={componentStyle}> <div className="absolute" style={componentStyle}>
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */} {/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
{/* 위젯 렌더링 */}
{renderInteractiveWidget(component)} {renderInteractiveWidget(component)}
</div> </div>

View File

@ -119,6 +119,9 @@ const WidgetRenderer: React.FC<{
tableDisplayData?: any[]; tableDisplayData?: any[];
[key: string]: any; [key: string]: any;
}> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => { }> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => {
// 🔧 무조건 로그 (렌더링 확인용)
console.log("📦 WidgetRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
// 위젯 컴포넌트가 아닌 경우 빈 div 반환 // 위젯 컴포넌트가 아닌 경우 빈 div 반환
if (!isWidgetComponent(component)) { if (!isWidgetComponent(component)) {
return <div className="text-xs text-gray-500"> </div>; return <div className="text-xs text-gray-500"> </div>;
@ -127,9 +130,6 @@ const WidgetRenderer: React.FC<{
const widget = component; const widget = component;
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget; const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
// 디버깅: 실제 widgetType 값 확인
// console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
// 사용자가 테두리를 설정했는지 확인 // 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
@ -246,8 +246,17 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
tableDisplayData, // 🆕 화면 표시 데이터 tableDisplayData, // 🆕 화면 표시 데이터
...restProps ...restProps
}) => { }) => {
// 🔧 무조건 로그 - 파일 반영 테스트용 (2024-TEST)
console.log("🔷🔷🔷 RealtimePreview 2024:", component.id);
const { user } = useAuth(); const { user } = useAuth();
const { type, id, position, size, style = {} } = component; const { type, id, position, size, style = {} } = component;
// 🔧 v2 컴포넌트 렌더링 추적
if (id?.includes("v2-")) {
console.log("🔷 RealtimePreview 렌더:", id, "type:", type, "labelDisplay:", style?.labelDisplay);
}
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0); const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
const [actualHeight, setActualHeight] = useState<number | null>(null); const [actualHeight, setActualHeight] = useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
@ -741,6 +750,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */} {/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
{type === "component" && {type === "component" &&
(() => { (() => {
console.log("📦 DynamicComponentRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer"); const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
return ( return (
<DynamicComponentRenderer <DynamicComponentRenderer

View File

@ -598,12 +598,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
(contentRef as any).current = node; (contentRef as any).current = node;
} }
}} }}
className={`${ className="h-full overflow-visible"
(component.type === "component" && (component as any).componentType === "flow-widget") ||
((component as any).componentType === "conditional-container" && !isDesignMode)
? "h-auto"
: "h-full"
} overflow-visible`}
style={{ width: "100%", maxWidth: "100%" }} style={{ width: "100%", maxWidth: "100%" }}
> >
<DynamicComponentRenderer <DynamicComponentRenderer
@ -649,9 +644,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
/> />
</div> </div>
{/* 선택된 컴포넌트 정보 표시 */} {/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
{isSelected && ( {isSelected && (
<div className="bg-primary text-primary-foreground absolute -top-7 left-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm"> <div className="bg-primary text-primary-foreground absolute -top-7 right-0 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm">
{type === "widget" && ( {type === "widget" && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{getWidgetIcon((component as WidgetComponent).widgetType)} {getWidgetIcon((component as WidgetComponent).widgetType)}
@ -690,7 +685,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
); );
}; };
// React.memo로 래핑하여 불필요한 리렌더링 방지 // 🔧 arePropsEqual 제거 - 기본 React.memo 사용 (디버깅용)
// component 객체가 새로 생성되면 자동으로 리렌더링됨
export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent); export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent);
// displayName 설정 (디버깅용) // displayName 설정 (디버깅용)

File diff suppressed because it is too large Load Diff

View File

@ -58,6 +58,7 @@ export interface TableNodeData {
label: string; label: string;
subLabel?: string; subLabel?: string;
isMain?: boolean; isMain?: boolean;
isFilterTable?: boolean; // 마스터-디테일의 디테일 테이블인지 (보라색 테두리)
isFocused?: boolean; // 포커스된 테이블인지 isFocused?: boolean; // 포커스된 테이블인지
isFaded?: boolean; // 흑백 처리할지 isFaded?: boolean; // 흑백 처리할지
columns?: Array<{ columns?: Array<{
@ -448,7 +449,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ========== // ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data; const { label, subLabel, isMain, isFilterTable, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data;
// 강조할 컬럼 세트 (영문 컬럼명 기준) // 강조할 컬럼 세트 (영문 컬럼명 기준)
const highlightSet = new Set(highlightedColumns || []); const highlightSet = new Set(highlightedColumns || []);
@ -574,16 +575,19 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
return ( return (
<div <div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${ className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
// 필터 관련 테이블 (마스터 또는 디테일): 보라색 // 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
(hasFilterRelation || isFilterSource) isFilterTable
? "border-2 border-violet-500 ring-2 ring-violet-500/20 shadow-lg bg-violet-50/50"
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: 진한 보라색
: (hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50" ? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 순수 포커스 (필터 관계 없음): 초록색 // 3. 순수 포커스 (필터 관계 없음): 초록색
: isFocused : isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card" ? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
// 흐리게 처리 // 4. 흐리게 처리
: isFaded : isFaded
? "border-gray-200 opacity-60 bg-card" ? "border-gray-200 opacity-60 bg-card"
// 기본 // 5. 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card" : "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
}`} }`}
style={{ style={{

View File

@ -147,6 +147,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 강제 새로고침용 키 (설정 저장 후 시각화 재로딩) // 강제 새로고침용 키 (설정 저장 후 시각화 재로딩)
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
// 화면 삭제/추가 시 노드 플로워 새로고침 (screen-list-refresh 이벤트 구독)
useEffect(() => {
const handleScreenListRefresh = () => {
// refreshKey 증가로 데이터 재로드 트리거
setRefreshKey(prev => prev + 1);
};
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
return () => {
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
};
}, []);
// 그룹 또는 화면이 변경될 때 포커스 초기화 // 그룹 또는 화면이 변경될 때 포커스 초기화
useEffect(() => { useEffect(() => {
setFocusedScreenId(null); setFocusedScreenId(null);
@ -171,6 +184,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 화면별 사용 컬럼 매핑 (화면 ID -> 테이블명 -> 사용 컬럼들) // 화면별 사용 컬럼 매핑 (화면 ID -> 테이블명 -> 사용 컬럼들)
const [screenUsedColumnsMap, setScreenUsedColumnsMap] = useState<Record<number, Record<string, string[]>>>({}); const [screenUsedColumnsMap, setScreenUsedColumnsMap] = useState<Record<number, Record<string, string[]>>>({});
// 전역 메인 테이블 목록 (우선순위: 메인 > 서브)
// 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음
const [globalMainTables, setGlobalMainTables] = useState<Set<string>>(new Set());
// 테이블 컬럼 정보 로드 // 테이블 컬럼 정보 로드
const loadTableColumns = useCallback( const loadTableColumns = useCallback(
async (tableName: string): Promise<ColumnTypeInfo[]> => { async (tableName: string): Promise<ColumnTypeInfo[]> => {
@ -266,24 +283,26 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
const flows = flowsRes.success ? flowsRes.data || [] : []; const flows = flowsRes.success ? flowsRes.data || [] : [];
const relations = relationsRes.success ? relationsRes.data || [] : []; const relations = relationsRes.success ? relationsRes.data || [] : [];
// 데이터 흐름에서 연결된 화면들 추가 // 데이터 흐름에서 연결된 화면들 추가 (개별 화면 모드에서만 - 그룹 모드에서는 그룹 내 화면만 표시)
flows.forEach((flow: any) => { if (!selectedGroup && screen) {
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) { flows.forEach((flow: any) => {
const exists = screenList.some((s) => s.screenId === flow.target_screen_id); if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
if (!exists) { const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
screenList.push({ if (!exists) {
screenId: flow.target_screen_id, screenList.push({
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`, screenId: flow.target_screen_id,
screenCode: "", screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
tableName: "", screenCode: "",
companyCode: screen.companyCode, tableName: "",
isActive: "Y", companyCode: screen.companyCode,
createdDate: new Date(), isActive: "Y",
updatedDate: new Date(), createdDate: new Date(),
} as ScreenDefinition); updatedDate: new Date(),
} as ScreenDefinition);
}
} }
} });
}); }
// 화면 레이아웃 요약 정보 로드 // 화면 레이아웃 요약 정보 로드
const screenIds = screenList.map((s) => s.screenId); const screenIds = screenList.map((s) => s.screenId);
@ -305,6 +324,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
subTablesData = subTablesRes.data as Record<number, ScreenSubTablesData>; subTablesData = subTablesRes.data as Record<number, ScreenSubTablesData>;
// 서브 테이블 데이터 저장 (조인 컬럼 정보 포함) // 서브 테이블 데이터 저장 (조인 컬럼 정보 포함)
setSubTablesDataMap(subTablesData); setSubTablesDataMap(subTablesData);
// 전역 메인 테이블 목록 저장 (우선순위 적용용)
// 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음
const globalMainTablesArr = (subTablesRes as any).globalMainTables as string[] | undefined;
if (globalMainTablesArr && Array.isArray(globalMainTablesArr)) {
setGlobalMainTables(new Set(globalMainTablesArr));
}
} }
} catch (e) { } catch (e) {
console.error("레이아웃 요약/서브 테이블 로드 실패:", e); console.error("레이아웃 요약/서브 테이블 로드 실패:", e);
@ -434,9 +460,27 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
if (rel.table_name) mainTableSet.add(rel.table_name); if (rel.table_name) mainTableSet.add(rel.table_name);
}); });
// 서브 테이블 수집 (componentConfig에서 추출된 테이블들) // ============================================================
// 서브 테이블은 메인 테이블과 다른 테이블들 // 테이블 분류 (우선순위: 메인 > 서브)
// 화면별 서브 테이블 매핑도 함께 구축 // ============================================================
// 메인 테이블 조건:
// 1. screen_definitions.table_name (컴포넌트 직접 연결) - 이미 mainTableSet에 추가됨
// 2. globalMainTables (WHERE 조건 대상, 마스터-디테일의 디테일 테이블)
//
// 서브 테이블 조건:
// - 조인(JOIN)으로만 연결된 테이블 (autocomplete 등에서 참조)
// - 단, mainTableSet에 있으면 제외 (우선순위: 메인 > 서브)
// 1. globalMainTables를 mainTableSet에 먼저 추가 (우선순위 적용)
const filterTableSet = new Set<string>(); // 마스터-디테일의 디테일 테이블들
globalMainTables.forEach((tableName) => {
if (!mainTableSet.has(tableName)) {
mainTableSet.add(tableName);
filterTableSet.add(tableName); // 필터 테이블로 분류 (보라색 테두리)
}
});
// 2. 서브 테이블 수집 (mainTableSet에 없는 것만)
const newScreenSubTableMap: Record<number, string[]> = {}; const newScreenSubTableMap: Record<number, string[]> = {};
Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => { Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => {
@ -444,11 +488,14 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
const subTableNames: string[] = []; const subTableNames: string[] = [];
screenSubData.subTables.forEach((subTable) => { screenSubData.subTables.forEach((subTable) => {
// 메인 테이블에 없는 것만 서브 테이블로 추가 // mainTableSet에 있으면 서브 테이블에서 제외 (우선순위: 메인 > 서브)
if (!mainTableSet.has(subTable.tableName)) { if (mainTableSet.has(subTable.tableName)) {
subTableSet.add(subTable.tableName); return;
subTableNames.push(subTable.tableName);
} }
// 조인으로만 연결된 테이블 → 서브 테이블
subTableSet.add(subTable.tableName);
subTableNames.push(subTable.tableName);
}); });
if (subTableNames.length > 0) { if (subTableNames.length > 0) {
@ -539,10 +586,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"), isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
})); }));
// 여러 화면이 같은 테이블 사용하면 "공통 메인 테이블", 아니면 "메인 테이블" // 테이블 분류에 따른 라벨 결정
const subLabel = linkedScreens.length > 1 // 1. 필터 테이블 (마스터-디테일의 디테일): "필터 대상 테이블"
? `메인 테이블 (${linkedScreens.length}개 화면)` // 2. 여러 화면이 같은 테이블 사용: "공통 메인 테이블 (N개 화면)"
: "메인 테이블"; // 3. 일반 메인 테이블: "메인 테이블"
const isFilterTable = filterTableSet.has(tableName);
let subLabel: string;
if (isFilterTable) {
subLabel = "필터 대상 테이블 (마스터-디테일)";
} else if (linkedScreens.length > 1) {
subLabel = `메인 테이블 (${linkedScreens.length}개 화면)`;
} else {
subLabel = "메인 테이블";
}
// 이 테이블을 참조하는 관계들 // 이 테이블을 참조하는 관계들
tableNodes.push({ tableNodes.push({
@ -552,7 +608,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
data: { data: {
label: tableName, label: tableName,
subLabel: subLabel, subLabel: subLabel,
isMain: true, // mainTableSet의 모든 테이블은 메인 isMain: !isFilterTable, // 필터 테이블은 isMain: false로 설정 (보라색 테두리 표시용)
isFilterTable: isFilterTable, // 필터 테이블 여부 표시
columns: formattedColumns, columns: formattedColumns,
// referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정 // referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정
}, },

View File

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

View File

@ -173,6 +173,8 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
onUpdateProperty("webTypeConfig.dataflowConfig", { onUpdateProperty("webTypeConfig.dataflowConfig", {
...dataflowConfig, ...dataflowConfig,
// 🔧 controlMode 설정 (플로우 제어가 있으면 "flow", 없으면 "none")
controlMode: firstValidControl ? "flow" : "none",
// 기존 형식 (하위 호환성) // 기존 형식 (하위 호환성)
selectedDiagramId: firstValidControl?.flowId || null, selectedDiagramId: firstValidControl?.flowId || null,
selectedRelationshipId: null, selectedRelationshipId: null,

View File

@ -80,7 +80,7 @@ export function ComponentsPanel({
"textarea-basic", "textarea-basic",
// V2 컴포넌트로 대체됨 // V2 컴포넌트로 대체됨
"image-widget", // → V2Media (image) "image-widget", // → V2Media (image)
"file-upload", // → V2Media (file) // "file-upload", // 🆕 레거시 컴포넌트 노출 (안정적인 파일 업로드)
"entity-search-input", // → V2Select (entity 모드) "entity-search-input", // → V2Select (entity 모드)
"autocomplete-search-input", // → V2Select (autocomplete 모드) "autocomplete-search-input", // → V2Select (autocomplete 모드)
// DataFlow 전용 (일반 화면에서 불필요) // DataFlow 전용 (일반 화면에서 불필요)

View File

@ -263,6 +263,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
definitionName: definition.name, definitionName: definition.name,
hasConfigPanel: !!definition.configPanel, hasConfigPanel: !!definition.configPanel,
currentConfig, currentConfig,
defaultSort: currentConfig?.defaultSort, // 🔍 defaultSort 확인
}); });
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지) // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
@ -822,8 +823,12 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <Input
value={selectedComponent.style?.labelText || selectedComponent.label || ""} value={selectedComponent.style?.labelText !== undefined ? selectedComponent.style.labelText : (selectedComponent.label || selectedComponent.componentConfig?.label || "")}
onChange={(e) => handleUpdate("style.labelText", e.target.value)} onChange={(e) => {
handleUpdate("style.labelText", e.target.value);
handleUpdate("label", e.target.value); // label도 함께 업데이트
}}
placeholder="라벨을 입력하세요 (비우면 라벨 없음)"
className="h-6 w-full px-2 py-0 text-xs" className="h-6 w-full px-2 py-0 text-xs"
/> />
</div> </div>
@ -857,8 +862,23 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
</div> </div>
<div className="flex items-center space-x-2 pt-5"> <div className="flex items-center space-x-2 pt-5">
<Checkbox <Checkbox
checked={selectedComponent.style?.labelDisplay !== false} checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)} onCheckedChange={(checked) => {
const boolValue = checked === true;
// 🔧 "필수"처럼 직접 경로로 업데이트! (style 객체 전체 덮어쓰기 방지)
handleUpdate("style.labelDisplay", boolValue);
handleUpdate("labelDisplay", boolValue);
// labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음)
if (boolValue && !selectedComponent.style?.labelText) {
const labelValue =
selectedComponent.label ||
selectedComponent.componentConfig?.label ||
"";
if (labelValue) {
handleUpdate("style.labelText", labelValue);
}
}
}}
className="h-4 w-4" className="h-4 w-4"
/> />
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
@ -868,9 +888,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
</Collapsible> </Collapsible>
)} )}
{/* 옵션 */} {/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{widget.required !== undefined && ( {(isInputField || widget.required !== undefined) && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
checked={widget.required === true || selectedComponent.componentConfig?.required === true} checked={widget.required === true || selectedComponent.componentConfig?.required === true}
@ -883,7 +903,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
</div> </div>
)} )}
{widget.readonly !== undefined && ( {(isInputField || widget.readonly !== undefined) && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true} checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
@ -896,7 +916,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
</div> </div>
)} )}
{/* 숨김 옵션 */} {/* 숨김 옵션 - 모든 컴포넌트에서 표시 */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true} checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
@ -1055,8 +1075,15 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용 allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보 currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => { onChange={(newConfig) => {
console.log("🔧 [V2PropertiesPanel] DynamicConfigPanel onChange:", {
componentId: selectedComponent.id,
newConfigKeys: Object.keys(newConfig),
defaultSort: newConfig.defaultSort,
newConfig,
});
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지 // 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
Object.entries(newConfig).forEach(([key, value]) => { Object.entries(newConfig).forEach(([key, value]) => {
console.log(` -> handleUpdate: componentConfig.${key} =`, value);
handleUpdate(`componentConfig.${key}`, value); handleUpdate(`componentConfig.${key}`, value);
}); });
}} }}

View File

@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, X } from "lucide-react"; import { Plus, X } from "lucide-react";
import { SelectTypeConfig } from "@/types/screen"; import { SelectTypeConfig } from "@/types/screen";
@ -22,6 +23,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
placeholder: "", placeholder: "",
allowClear: false, allowClear: false,
maxSelections: undefined, maxSelections: undefined,
defaultValue: "",
...config, ...config,
}; };
@ -32,6 +34,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
placeholder: safeConfig.placeholder, placeholder: safeConfig.placeholder,
allowClear: safeConfig.allowClear, allowClear: safeConfig.allowClear,
maxSelections: safeConfig.maxSelections?.toString() || "", maxSelections: safeConfig.maxSelections?.toString() || "",
defaultValue: safeConfig.defaultValue || "",
}); });
const [newOption, setNewOption] = useState({ label: "", value: "" }); const [newOption, setNewOption] = useState({ label: "", value: "" });
@ -53,6 +56,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
placeholder: safeConfig.placeholder, placeholder: safeConfig.placeholder,
allowClear: safeConfig.allowClear, allowClear: safeConfig.allowClear,
maxSelections: safeConfig.maxSelections?.toString() || "", maxSelections: safeConfig.maxSelections?.toString() || "",
defaultValue: safeConfig.defaultValue || "",
}); });
setLocalOptions( setLocalOptions(
@ -68,6 +72,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
safeConfig.placeholder, safeConfig.placeholder,
safeConfig.allowClear, safeConfig.allowClear,
safeConfig.maxSelections, safeConfig.maxSelections,
safeConfig.defaultValue,
JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지 JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지
]); ]);
@ -174,6 +179,30 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
/> />
</div> </div>
{/* 기본값 설정 */}
<div>
<Label htmlFor="defaultValue" className="text-sm font-medium">
</Label>
<Select
value={localValues.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="mt-1 h-8 w-full text-xs">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{localOptions.map((option, index) => (
<SelectItem key={`default-${option.value}-${index}`} value={option.value}>
{option.label} ({option.value})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
{/* 다중 선택 */} {/* 다중 선택 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="multiple" className="text-sm font-medium"> <Label htmlFor="multiple" className="text-sm font-medium">

View File

@ -66,6 +66,33 @@ export function TabsWidget({
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]); const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()])); const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
// 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트
useEffect(() => {
// 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택
const validTabs = (tabs as ExtendedTabItem[]).filter((tab) => !tab.disabled);
const firstValidTabId = validTabs[0]?.id;
if (firstValidTabId) {
// 선택된 탭이 없거나 유효하지 않으면 첫 번째 탭으로 설정
setSelectedTab((currentSelected) => {
if (!currentSelected || !validTabs.some((t) => t.id === currentSelected)) {
return firstValidTabId;
}
return currentSelected;
});
// 첫 번째 탭이 mountedTabs에 없으면 추가
setMountedTabs((prev) => {
const newSet = new Set(prev);
// 첫 번째 탭 추가
if (firstValidTabId && !newSet.has(firstValidTabId)) {
newSet.add(firstValidTabId);
}
return newSet;
});
}
}, [tabs]); // tabs가 변경될 때마다 실행
// screenId 기반 화면 로드 상태 // screenId 기반 화면 로드 상태
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({}); const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({}); const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
@ -82,18 +109,23 @@ export function TabsWidget({
for (const tab of visibleTabs) { for (const tab of visibleTabs) {
const extTab = tab as ExtendedTabItem; const extTab = tab as ExtendedTabItem;
// screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드 // screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드
if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) { if (
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true })); extTab.screenId &&
!screenLayouts[tab.id] &&
!screenLoadingStates[tab.id] &&
(!extTab.components || extTab.components.length === 0)
) {
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
try { try {
const layoutData = await screenApi.getLayout(extTab.screenId); const layoutData = await screenApi.getLayout(extTab.screenId);
if (layoutData && layoutData.components) { if (layoutData && layoutData.components) {
setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components })); setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
} }
} catch (error) { } catch (error) {
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error); console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
} finally { } finally {
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false })); setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: false }));
} }
} }
} }
@ -153,11 +185,7 @@ export function TabsWidget({
const getTabsListClass = () => { const getTabsListClass = () => {
const baseClass = orientation === "vertical" ? "flex-col" : ""; const baseClass = orientation === "vertical" ? "flex-col" : "";
const variantClass = const variantClass =
variant === "pills" variant === "pills" ? "bg-muted p-1 rounded-lg" : variant === "underline" ? "border-b" : "bg-muted p-1";
? "bg-muted p-1 rounded-lg"
: variant === "underline"
? "border-b"
: "bg-muted p-1";
return `${baseClass} ${variantClass}`; return `${baseClass} ${variantClass}`;
}; };
@ -172,8 +200,8 @@ export function TabsWidget({
if (screenLoadingStates[tab.id]) { if (screenLoadingStates[tab.id]) {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
<span className="ml-2 text-muted-foreground"> ...</span> <span className="text-muted-foreground ml-2"> ...</span>
</div> </div>
); );
} }
@ -181,7 +209,7 @@ export function TabsWidget({
// 에러 발생 // 에러 발생
if (screenErrors[tab.id]) { if (screenErrors[tab.id]) {
return ( return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-destructive/50 bg-destructive/5"> <div className="border-destructive/50 bg-destructive/5 flex h-full w-full items-center justify-center rounded border-2 border-dashed">
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p> <p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
</div> </div>
); );
@ -196,7 +224,7 @@ export function TabsWidget({
// 아직 로드되지 않은 경우 // 아직 로드되지 않은 경우
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
</div> </div>
); );
} }
@ -219,17 +247,12 @@ export function TabsWidget({
// screenId로 로드한 화면 컴포넌트 렌더링 // screenId로 로드한 화면 컴포넌트 렌더링
const renderScreenComponents = (components: ComponentData[]) => { const renderScreenComponents = (components: ComponentData[]) => {
// InteractiveScreenViewerDynamic 동적 로드 // InteractiveScreenViewerDynamic 동적 로드
const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic; const InteractiveScreenViewerDynamic =
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max( const maxBottom = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 300);
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), const maxRight = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400);
300
);
const maxRight = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400
);
return ( return (
<div <div
@ -268,11 +291,11 @@ export function TabsWidget({
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max( const maxBottom = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
300 // 최소 높이 300, // 최소 높이
); );
const maxRight = Math.max( const maxRight = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400 // 최소 너비 400, // 최소 너비
); );
return ( return (
@ -292,7 +315,7 @@ export function TabsWidget({
className={cn( className={cn(
"absolute", "absolute",
isDesignMode && "cursor-move", isDesignMode && "cursor-move",
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2" isDesignMode && isSelected && "ring-primary ring-2 ring-offset-2",
)} )}
style={{ style={{
left: comp.position?.x || 0, left: comp.position?.x || 0,
@ -353,9 +376,7 @@ export function TabsWidget({
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8"> <TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
{tab.label} {tab.label}
{tab.components && tab.components.length > 0 && ( {tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground"> <span className="text-muted-foreground ml-1 text-xs">({tab.components.length})</span>
({tab.components.length})
</span>
)} )}
</TabsTrigger> </TabsTrigger>
{allowCloseable && ( {allowCloseable && (
@ -363,7 +384,7 @@ export function TabsWidget({
onClick={(e) => handleCloseTab(tab.id, e)} onClick={(e) => handleCloseTab(tab.id, e)}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-destructive/10" className="hover:bg-destructive/10 absolute top-1/2 right-1 h-5 w-5 -translate-y-1/2 p-0"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>

View File

@ -9,7 +9,9 @@ import { WidgetComponent } from "@/types/screen";
import { toast } from "sonner"; import { toast } from "sonner";
import { apiClient, getFullImageUrl } from "@/lib/api/client"; import { apiClient, getFullImageUrl } from "@/lib/api/client";
export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: number; height?: number }; style?: React.CSSProperties }> = ({ export const ImageWidget: React.FC<
WebTypeComponentProps & { size?: { width?: number; height?: number }; style?: React.CSSProperties }
> = ({
component, component,
value, value,
onChange, onChange,
@ -134,27 +136,23 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
{imageUrl ? ( {imageUrl ? (
// 이미지 표시 모드 // 이미지 표시 모드
<div <div
className="group relative flex-1 w-full overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm transition-all hover:shadow-md" className="group relative w-full flex-1 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm transition-all hover:shadow-md"
style={filteredStyle} style={filteredStyle}
> >
<img <img
src={imageUrl} src={imageUrl}
alt="업로드된 이미지" alt="업로드된 이미지"
className="h-full w-full object-contain" className="h-full w-full object-contain"
onError={(e) => { onError={(e) => {
e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E"; e.currentTarget.src =
}} "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
/> }}
/>
{/* 호버 시 제거 버튼 */} {/* 호버 시 제거 버튼 */}
{!readonly && !isDesignMode && ( {!readonly && !isDesignMode && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Button <Button size="sm" variant="destructive" onClick={handleRemove} className="gap-2">
size="sm"
variant="destructive"
onClick={handleRemove}
className="gap-2"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@ -164,7 +162,7 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
) : ( ) : (
// 업로드 영역 // 업로드 영역
<div <div
className={`group relative flex flex-1 w-full flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${ className={`group relative flex w-full flex-1 flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
isDesignMode isDesignMode
? "cursor-default border-gray-200 bg-gray-50" ? "cursor-default border-gray-200 bg-gray-50"
: "cursor-pointer border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 hover:shadow-md" : "cursor-pointer border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 hover:shadow-md"
@ -199,9 +197,7 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
/> />
{/* 필수 필드 경고 */} {/* 필수 필드 경고 */}
{required && !imageUrl && ( {required && !imageUrl && <div className="text-xs text-red-500">* </div>}
<div className="text-xs text-red-500">* </div>
)}
</div> </div>
); );
}; };

View File

@ -22,18 +22,25 @@ function SelectTrigger({
className, className,
size = "xs", size = "xs",
children, children,
style,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "xs" | "sm" | "default"; size?: "xs" | "sm" | "default";
}) { }) {
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height;
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={hasCustomHeight ? undefined : size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// 커스텀 높이일 때 기본 패딩 적용
hasCustomHeight && "px-2 py-1",
className, className,
)} )}
style={style}
{...props} {...props}
> >
{children} {children}

View File

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

View File

@ -311,31 +311,39 @@ export const V2Biz = forwardRef<HTMLDivElement, V2BizProps>(
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
return ( return (
<div <div
ref={ref} ref={ref}
id={id} id={id}
className="flex flex-col" className="relative"
style={{ style={{
width: componentWidth, width: componentWidth,
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && ( {showLabel && (
<Label <Label
htmlFor={id} htmlFor={id}
style={{ style={{
fontSize: style?.labelFontSize, position: "absolute",
color: style?.labelColor, top: `-${estimatedLabelHeight}px`,
fontWeight: style?.labelFontWeight, left: 0,
marginBottom: style?.labelMarginBottom, fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}} }}
className="text-sm font-medium flex-shrink-0" className="text-sm font-medium whitespace-nowrap"
> >
{label} {label}
</Label> </Label>
)} )}
<div className="flex-1 min-h-0"> <div className="h-full w-full">
{renderBiz()} {renderBiz()}
</div> </div>
</div> </div>

View File

@ -75,10 +75,11 @@ const SingleDatePicker = forwardRef<
disabled?: boolean; disabled?: boolean;
readonly?: boolean; readonly?: boolean;
className?: string; className?: string;
placeholder?: string;
} }
>( >(
( (
{ value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className }, { value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className, placeholder = "날짜 선택" },
ref, ref,
) => { ) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -87,6 +88,16 @@ const SingleDatePicker = forwardRef<
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
// 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로)
const displayText = useMemo(() => {
if (!value) return "";
// Date 객체로 변환 후 포맷팅
if (date && isValid(date)) {
return formatDate(date, dateFormat);
}
return value;
}, [value, date, dateFormat]);
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedDate: Date | undefined) => { (selectedDate: Date | undefined) => {
if (selectedDate) { if (selectedDate) {
@ -115,13 +126,13 @@ const SingleDatePicker = forwardRef<
variant="outline" variant="outline"
disabled={disabled || readonly} disabled={disabled || readonly}
className={cn( className={cn(
"h-10 w-full justify-start text-left font-normal", "h-full w-full justify-start text-left font-normal",
!value && "text-muted-foreground", !displayText && "text-muted-foreground",
className, className,
)} )}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
{value || "날짜 선택"} {displayText || placeholder}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
@ -211,14 +222,14 @@ const RangeDatePicker = forwardRef<
); );
return ( return (
<div ref={ref} className={cn("flex items-center gap-2", className)}> <div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
{/* 시작 날짜 */} {/* 시작 날짜 */}
<Popover open={openStart} onOpenChange={setOpenStart}> <Popover open={openStart} onOpenChange={setOpenStart}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
disabled={disabled || readonly} disabled={disabled || readonly}
className={cn("h-10 flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")} className={cn("h-full flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{value[0] || "시작일"} {value[0] || "시작일"}
@ -248,7 +259,7 @@ const RangeDatePicker = forwardRef<
<Button <Button
variant="outline" variant="outline"
disabled={disabled || readonly} disabled={disabled || readonly}
className={cn("h-10 flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")} className={cn("h-full flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{value[1] || "종료일"} {value[1] || "종료일"}
@ -290,7 +301,7 @@ const TimePicker = forwardRef<
} }
>(({ value, onChange, disabled, readonly, className }, ref) => { >(({ value, onChange, disabled, readonly, className }, ref) => {
return ( return (
<div className={cn("relative", className)}> <div className={cn("relative h-full", className)}>
<Clock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <Clock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
ref={ref} ref={ref}
@ -299,7 +310,7 @@ const TimePicker = forwardRef<
onChange={(e) => onChange?.(e.target.value)} onChange={(e) => onChange?.(e.target.value)}
disabled={disabled} disabled={disabled}
readOnly={readonly} readOnly={readonly}
className="h-10 pl-10" className="h-full pl-10"
/> />
</div> </div>
); );
@ -346,8 +357,8 @@ const DateTimePicker = forwardRef<
); );
return ( return (
<div ref={ref} className={cn("flex gap-2", className)}> <div ref={ref} className={cn("flex gap-2 h-full", className)}>
<div className="flex-1"> <div className="flex-1 h-full">
<SingleDatePicker <SingleDatePicker
value={datePart} value={datePart}
onChange={handleDateChange} onChange={handleDateChange}
@ -358,7 +369,7 @@ const DateTimePicker = forwardRef<
readonly={readonly} readonly={readonly}
/> />
</div> </div>
<div className="w-32"> <div className="w-1/3 min-w-[100px] h-full">
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} /> <TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
</div> </div>
</div> </div>
@ -409,6 +420,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
maxDate={config.maxDate} maxDate={config.maxDate}
disabled={isDisabled} disabled={isDisabled}
readonly={readonly} readonly={readonly}
placeholder={config.placeholder}
/> />
); );
@ -444,6 +456,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
showToday={config.showToday} showToday={config.showToday}
disabled={isDisabled} disabled={isDisabled}
readonly={readonly} readonly={readonly}
placeholder={config.placeholder}
/> />
); );
} }
@ -453,32 +466,42 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
return ( return (
<div <div
ref={ref} ref={ref}
id={id} id={id}
className="flex flex-col" className="relative"
style={{ style={{
width: componentWidth, width: componentWidth,
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && ( {showLabel && (
<Label <Label
htmlFor={id} htmlFor={id}
style={{ style={{
fontSize: style?.labelFontSize, position: "absolute",
color: style?.labelColor, top: `-${estimatedLabelHeight}px`,
fontWeight: style?.labelFontWeight, left: 0,
marginBottom: style?.labelMarginBottom, fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}} }}
className="flex-shrink-0 text-sm font-medium" className="text-sm font-medium whitespace-nowrap"
> >
{label} {label}
{required && <span className="ml-0.5 text-orange-500">*</span>} {required && <span className="ml-0.5 text-orange-500">*</span>}
</Label> </Label>
)} )}
<div className="min-h-0 flex-1">{renderDatePicker()}</div> <div className="h-full w-full">
{renderDatePicker()}
</div>
</div> </div>
); );
}); });

View File

@ -10,7 +10,7 @@
*/ */
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react"; import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react";
import { ConditionalConfig, CascadingConfig } from "@/types/v2-components"; import { ConditionalConfig, CascadingConfig, LayerConfig, LayerCondition } from "@/types/v2-components";
import { ValidationRule } from "@/types/v2-core"; import { ValidationRule } from "@/types/v2-core";
import type { import type {
FormStatus, FormStatus,
@ -89,6 +89,12 @@ export interface V2FormContextValue {
addRepeaterRow: (fieldName: string, row: Record<string, unknown>) => void; addRepeaterRow: (fieldName: string, row: Record<string, unknown>) => void;
updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void; updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void;
deleteRepeaterRow: (fieldName: string, index: number) => void; deleteRepeaterRow: (fieldName: string, index: number) => void;
// 조건부 레이어 시스템
layers: LayerConfig[];
setLayers: (layers: LayerConfig[]) => void;
evaluateLayer: (layer: LayerConfig) => boolean;
isComponentVisible: (componentId: string) => boolean;
} }
// ===== Context 생성 ===== // ===== Context 생성 =====

View File

@ -462,32 +462,40 @@ export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
return ( return (
<div <div
ref={ref} ref={ref}
id={id} id={id}
className="flex flex-col" className="relative"
style={{ style={{
width: componentWidth, width: componentWidth,
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && ( {showLabel && (
<Label <Label
htmlFor={id} htmlFor={id}
style={{ style={{
fontSize: style?.labelFontSize, position: "absolute",
color: style?.labelColor, top: `-${estimatedLabelHeight}px`,
fontWeight: style?.labelFontWeight, left: 0,
marginBottom: style?.labelMarginBottom, fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}} }}
className="text-sm font-medium flex-shrink-0" className="text-sm font-medium whitespace-nowrap"
> >
{label} {label}
{required && <span className="text-orange-500 ml-0.5">*</span>} {required && <span className="text-orange-500 ml-0.5">*</span>}
</Label> </Label>
)} )}
<div className="flex-1 min-h-0"> <div className="h-full w-full">
{renderHierarchy()} {renderHierarchy()}
</div> </div>
</div> </div>

View File

@ -317,7 +317,7 @@ const TextareaInput = forwardRef<
readOnly={readonly} readOnly={readonly}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50", "border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-full w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
/> />
@ -361,8 +361,21 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false); const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false);
const hasGeneratedNumberingRef = useRef(false); const hasGeneratedNumberingRef = useRef(false);
// tableName 추출 (props에서 전달받거나 config에서) // formData를 ref로 관리하여 closure 문제 해결 (채번 코드 생성 시 최신 값 사용)
const tableName = (props as any).tableName || (config as any).tableName; const formDataRef = useRef(formData);
formDataRef.current = formData;
// tableName 추출 (여러 소스에서 확인)
// 1. props에서 직접 전달받은 값
// 2. config에서 설정된 값
// 3. 컴포넌트 overrides에서 설정된 값 (V2 레이아웃)
// 4. screenInfo에서 화면 테이블명
const tableName =
(props as any).tableName ||
(config as any).tableName ||
(props as any).component?.tableName ||
(props as any).component?.overrides?.tableName ||
(props as any).screenInfo?.tableName;
// 수정 모드 여부 확인 // 수정 모드 여부 확인
const originalData = (props as any).originalData || (props as any)._originalData; const originalData = (props as any).originalData || (props as any)._originalData;
@ -445,8 +458,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시) // formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시)
// 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지) // 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지)
// inputType을 여러 소스에서 확인
const propsInputType = (props as any).inputType;
const categoryValuesForNumbering = useMemo(() => { const categoryValuesForNumbering = useMemo(() => {
const inputType = config.inputType || config.type || "text"; const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering") return ""; if (inputType !== "numbering") return "";
// formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외) // formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외)
const categoryFields: Record<string, string> = {}; const categoryFields: Record<string, string> = {};
@ -458,12 +473,13 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
} }
} }
return JSON.stringify(categoryFields); return JSON.stringify(categoryFields);
}, [config.inputType, config.type, formData, columnName]); }, [propsInputType, config.inputType, config.type, formData, columnName]);
// 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용) // 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용)
useEffect(() => { useEffect(() => {
const generateNumberingCode = async () => { const generateNumberingCode = async () => {
const inputType = config.inputType || config.type || "text"; // inputType을 여러 소스에서 확인 (props에서 직접 전달받거나 config에서)
const inputType = (props as any).inputType || config.inputType || config.type || "text";
// numbering 타입이 아니면 스킵 // numbering 타입이 아니면 스킵
if (inputType !== "numbering") { if (inputType !== "numbering") {
@ -524,18 +540,17 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
} }
// detailSettings에서 numberingRuleId 추출 // detailSettings에서 numberingRuleId 추출
if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") { if (targetColumn.detailSettings) {
try { try {
const parsed = JSON.parse(targetColumn.detailSettings); // 문자열이면 파싱, 객체면 그대로 사용
const parsed = typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null; numberingRuleIdRef.current = parsed.numberingRuleId || null;
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해) // 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
if (parsed.numberingRuleId && onFormDataChange && columnName) { if (parsed.numberingRuleId && onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId); onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
console.log("🔧 채번 규칙 ID를 formData에 저장:", {
key: `${columnName}_numberingRuleId`,
value: parsed.numberingRuleId,
});
} }
} catch { } catch {
// JSON 파싱 실패 // JSON 파싱 실패
@ -550,8 +565,9 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
return; return;
} }
// 채번 코드 생성 (formData 전달하여 카테고리 값 기반 생성) // 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
const previewResponse = await previewNumberingCode(numberingRuleId, formData); const currentFormData = formDataRef.current;
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
if (previewResponse.success && previewResponse.data?.generatedCode) { if (previewResponse.success && previewResponse.data?.generatedCode) {
const generatedCode = previewResponse.data.generatedCode; const generatedCode = previewResponse.data.generatedCode;
@ -610,6 +626,35 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, columnName, isEditMode, categoryValuesForNumbering]); }, [tableName, columnName, isEditMode, categoryValuesForNumbering]);
// 🆕 beforeFormSave 이벤트 리스너 - 저장 직전에 현재 조합된 값을 formData에 주입
useEffect(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering" || !columnName) return;
const handleBeforeFormSave = (event: CustomEvent) => {
const template = numberingTemplateRef.current;
if (!template || !template.includes("____")) return;
// 템플릿에서 prefix와 suffix 추출
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 현재 조합된 값 생성
const currentValue = templatePrefix + manualInputValue + templateSuffix;
// formData에 직접 주입
if (event.detail?.formData && columnName) {
event.detail.formData[columnName] = currentValue;
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
// 실제 표시할 값 (자동생성 값 또는 props value) // 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value; const displayValue = autoGeneratedValue ?? value;
@ -618,7 +663,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 타입별 입력 컴포넌트 렌더링 // 타입별 입력 컴포넌트 렌더링
const renderInput = () => { const renderInput = () => {
const inputType = config.inputType || config.type || "text"; const inputType = propsInputType || config.inputType || config.type || "text";
switch (inputType) { switch (inputType) {
case "text": case "text":
return ( return (
@ -754,7 +799,19 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const newValue = templatePrefix + newUserInput + templateSuffix; const newValue = templatePrefix + newUserInput + templateSuffix;
userEditedNumberingRef.current = true; userEditedNumberingRef.current = true;
setAutoGeneratedValue(newValue); setAutoGeneratedValue(newValue);
// 모든 방법으로 formData 업데이트 시도
onChange?.(newValue); onChange?.(newValue);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newValue);
}
// 커스텀 이벤트로도 전달 (최후의 보루)
if (typeof window !== "undefined" && columnName) {
window.dispatchEvent(new CustomEvent("numberingValueChanged", {
detail: { columnName, value: newValue }
}));
}
}} }}
placeholder="입력" placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none" className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
@ -787,37 +844,49 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}; };
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산 // 라벨이 표시될 때 입력 필드가 차지할 높이 계산
const showLabel = label && style?.labelDisplay !== false; // 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
const actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay === true;
// size에서 우선 가져오고, 없으면 style에서 가져옴 // size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
return ( return (
<div <div
ref={ref} ref={ref}
id={id} id={id}
className="flex flex-col" className="relative"
style={{ style={{
width: componentWidth, width: componentWidth,
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
{showLabel && ( {showLabel && (
<Label <Label
htmlFor={id} htmlFor={id}
style={{ style={{
fontSize: style?.labelFontSize, position: "absolute",
color: style?.labelColor, top: `-${estimatedLabelHeight}px`,
fontWeight: style?.labelFontWeight, left: 0,
marginBottom: style?.labelMarginBottom, fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}} }}
className="flex-shrink-0 text-sm font-medium" className="text-sm font-medium whitespace-nowrap"
> >
{label} {actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>} {required && <span className="ml-0.5 text-orange-500">*</span>}
</Label> </Label>
)} )}
<div className="min-h-0 flex-1">{renderInput()}</div> <div className="h-full w-full">
{renderInput()}
</div>
</div> </div>
); );
}); });

View File

@ -134,7 +134,7 @@ export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8" className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
style={{ style={{
width: size?.width || style?.width || "100%", width: size?.width || style?.width || "100%",
height: size?.height || style?.height || 400, height: size?.height || style?.height || "100%",
}} }}
> >
<p className="text-muted-foreground text-sm"> .</p> <p className="text-muted-foreground text-sm"> .</p>
@ -149,7 +149,7 @@ export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
className="flex flex-col overflow-auto" className="flex flex-col overflow-auto"
style={{ style={{
width: size?.width || style?.width || "100%", width: size?.width || style?.width || "100%",
height: size?.height || style?.height || 400, height: size?.height || style?.height || "100%",
}} }}
> >
<TableListComponent <TableListComponent

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -42,6 +42,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
allowClear?: boolean; allowClear?: boolean;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
style?: React.CSSProperties;
}>(({ }>(({
options, options,
value, value,
@ -52,7 +53,8 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
maxSelect, maxSelect,
allowClear = true, allowClear = true,
disabled, disabled,
className className,
style,
}, ref) => { }, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -64,7 +66,8 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
onValueChange={(v) => onChange?.(v)} onValueChange={(v) => onChange?.(v)}
disabled={disabled} disabled={disabled}
> >
<SelectTrigger ref={ref} className={cn("h-10", className)}> {/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */}
<SelectTrigger ref={ref} className={cn("w-full", className)} style={style}>
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -112,13 +115,15 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
{/* Button에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
<Button <Button
ref={ref} ref={ref}
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
disabled={disabled} disabled={disabled}
className={cn("h-10 w-full justify-between font-normal", className)} className={cn("w-full justify-between font-normal", className)}
style={style}
> >
<span className="truncate flex-1 text-left"> <span className="truncate flex-1 text-left">
{selectedLabels.length > 0 {selectedLabels.length > 0
@ -368,9 +373,9 @@ const SwapSelect = forwardRef<HTMLDivElement, {
return ( return (
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}> <div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
{/* 왼쪽: 선택 가능 */} {/* 왼쪽: 선택 가능 */}
<div className="flex-1 border rounded-md"> <div className="flex-1 border rounded-md flex flex-col min-h-0">
<div className="p-2 bg-muted text-xs font-medium border-b"> </div> <div className="p-2 bg-muted text-xs font-medium border-b shrink-0"> </div>
<div className="p-2 space-y-1 max-h-40 overflow-y-auto"> <div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
{available.map((option) => ( {available.map((option) => (
<div <div
key={option.value} key={option.value}
@ -412,9 +417,9 @@ const SwapSelect = forwardRef<HTMLDivElement, {
</div> </div>
{/* 오른쪽: 선택됨 */} {/* 오른쪽: 선택됨 */}
<div className="flex-1 border rounded-md"> <div className="flex-1 border rounded-md flex flex-col min-h-0">
<div className="p-2 bg-primary/10 text-xs font-medium border-b"></div> <div className="p-2 bg-primary/10 text-xs font-medium border-b shrink-0"></div>
<div className="p-2 space-y-1 max-h-40 overflow-y-auto"> <div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
{selected.map((option) => ( {selected.map((option) => (
<div <div
key={option.value} key={option.value}
@ -654,24 +659,31 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
// 모드별 컴포넌트 렌더링 // 모드별 컴포넌트 렌더링
const renderSelect = () => { const renderSelect = () => {
if (loading) { if (loading) {
return <div className="h-10 flex items-center text-sm text-muted-foreground"> ...</div>; return <div className="h-full flex items-center text-sm text-muted-foreground"> ...</div>;
} }
const isDisabled = disabled || readonly; const isDisabled = disabled || readonly;
// 명시적 높이가 있을 때만 style 전달, 없으면 undefined (기본 높이 h-6 사용)
const heightStyle: React.CSSProperties | undefined = componentHeight
? { height: componentHeight }
: undefined;
switch (config.mode) { switch (config.mode) {
case "dropdown": case "dropdown":
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
return ( return (
<DropdownSelect <DropdownSelect
options={options} options={options}
value={value} value={value}
onChange={onChange} onChange={onChange}
placeholder="선택" placeholder="선택"
searchable={config.searchable} searchable={config.mode === "combobox" ? true : config.searchable}
multiple={config.multiple} multiple={config.multiple}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
allowClear={config.allowClear} allowClear={config.allowClear}
disabled={isDisabled} disabled={isDisabled}
style={heightStyle}
/> />
); );
@ -686,6 +698,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
); );
case "check": case "check":
case "checkbox": // 🔧 기존 저장된 값 호환
return ( return (
<CheckSelect <CheckSelect
options={options} options={options}
@ -735,6 +748,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={isDisabled} disabled={isDisabled}
style={heightStyle}
/> />
); );
} }
@ -744,32 +758,40 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
return ( return (
<div <div
ref={ref} ref={ref}
id={id} id={id}
className="flex flex-col" className="relative"
style={{ style={{
width: componentWidth, width: componentWidth,
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && ( {showLabel && (
<Label <Label
htmlFor={id} htmlFor={id}
style={{ style={{
fontSize: style?.labelFontSize, position: "absolute",
color: style?.labelColor, top: `-${estimatedLabelHeight}px`,
fontWeight: style?.labelFontWeight, left: 0,
marginBottom: style?.labelMarginBottom, fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}} }}
className="text-sm font-medium flex-shrink-0" className="text-sm font-medium whitespace-nowrap"
> >
{label} {label}
{required && <span className="text-orange-500 ml-0.5">*</span>} {required && <span className="text-orange-500 ml-0.5">*</span>}
</Label> </Label>
)} )}
<div className="flex-1 min-h-0"> <div className="h-full w-full">
{renderSelect()} {renderSelect()}
</div> </div>
</div> </div>

View File

@ -48,6 +48,20 @@ export const V2DateConfigPanel: React.FC<V2DateConfigPanelProps> = ({
<Separator /> <Separator />
{/* 플레이스홀더 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={config.placeholder || ""}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="날짜 선택"
className="h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<Separator />
{/* 표시 형식 */} {/* 표시 형식 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>

View File

@ -27,11 +27,7 @@ interface V2SelectConfigPanelProps {
inputType?: string; inputType?: string;
} }
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config, onChange, inputType }) => {
config,
onChange,
inputType,
}) => {
// 엔티티 타입인지 확인 // 엔티티 타입인지 확인
const isEntityType = inputType === "entity"; const isEntityType = inputType === "entity";
// 엔티티 테이블의 컬럼 목록 // 엔티티 테이블의 컬럼 목록
@ -107,10 +103,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
{/* 선택 모드 */} {/* 선택 모드 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<Select <Select value={config.mode || "dropdown"} onValueChange={(value) => updateConfig("mode", value)}>
value={config.mode || "dropdown"}
onValueChange={(value) => updateConfig("mode", value)}
>
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" /> <SelectValue placeholder="모드 선택" />
</SelectTrigger> </SelectTrigger>
@ -130,10 +123,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
{/* 데이터 소스 */} {/* 데이터 소스 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<Select <Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
value={config.source || "static"}
onValueChange={(value) => updateConfig("source", value)}
>
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" /> <SelectValue placeholder="소스 선택" />
</SelectTrigger> </SelectTrigger>
@ -151,49 +141,65 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<Button <Button type="button" variant="ghost" size="sm" onClick={addOption} className="h-6 px-2 text-xs">
type="button" <Plus className="mr-1 h-3 w-3" />
variant="ghost"
size="sm"
onClick={addOption}
className="h-6 px-2 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button> </Button>
</div> </div>
<div className="space-y-2 max-h-40 overflow-y-auto"> <div className="max-h-40 space-y-2 overflow-y-auto">
{options.map((option: any, index: number) => ( {options.map((option: any, index: number) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-2">
<Input <Input
value={option.value || ""} value={option.value || ""}
onChange={(e) => updateOption(index, "value", e.target.value)} onChange={(e) => updateOption(index, "value", e.target.value)}
placeholder="값" placeholder="값"
className="h-7 text-xs flex-1" className="h-7 flex-1 text-xs"
/> />
<Input <Input
value={option.label || ""} value={option.label || ""}
onChange={(e) => updateOption(index, "label", e.target.value)} onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트" placeholder="표시 텍스트"
className="h-7 text-xs flex-1" className="h-7 flex-1 text-xs"
/> />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => removeOption(index)} onClick={() => removeOption(index)}
className="h-7 w-7 p-0 text-destructive" className="text-destructive h-7 w-7 p-0"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
))} ))}
{options.length === 0 && ( {options.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2"> <p className="text-muted-foreground py-2 text-center text-xs"> </p>
</p>
)} )}
</div> </div>
{/* 기본값 설정 */}
{options.length > 0 && (
<div className="mt-3 border-t pt-2">
<Label className="text-xs font-medium"></Label>
<Select
value={config.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{options.map((option: any, index: number) => (
<SelectItem key={`default-${index}`} value={option.value || `_idx_${index}`}>
{option.label || option.value || `옵션 ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]"> </p>
</div>
)}
</div> </div>
)} )}
@ -202,16 +208,13 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
{config.codeGroup ? ( {config.codeGroup ? (
<p className="text-sm font-medium text-foreground">{config.codeGroup}</p> <p className="text-foreground text-sm font-medium">{config.codeGroup}</p>
) : ( ) : (
<p className="text-xs text-amber-600"> <p className="text-xs text-amber-600"> </p>
</p>
)} )}
</div> </div>
)} )}
{/* 엔티티(참조 테이블) 설정 */} {/* 엔티티(참조 테이블) 설정 */}
{config.source === "entity" && ( {config.source === "entity" && (
<div className="space-y-3"> <div className="space-y-3">
@ -222,16 +225,16 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
readOnly readOnly
disabled disabled
placeholder="테이블 타입 관리에서 설정" placeholder="테이블 타입 관리에서 설정"
className="h-8 text-xs bg-muted" className="bg-muted h-8 text-xs"
/> />
<p className="text-[10px] text-muted-foreground"> <p className="text-muted-foreground text-[10px]">
( ) ( )
</p> </p>
</div> </div>
{/* 컬럼 로딩 중 표시 */} {/* 컬럼 로딩 중 표시 */}
{loadingColumns && ( {loadingColumns && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
... ...
</div> </div>
@ -265,7 +268,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
className="h-8 text-xs" className="h-8 text-xs"
/> />
)} )}
<p className="text-[10px] text-muted-foreground"> </p> <p className="text-muted-foreground text-[10px]"> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
@ -293,7 +296,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
className="h-8 text-xs" className="h-8 text-xs"
/> />
)} )}
<p className="text-[10px] text-muted-foreground"> </p> <p className="text-muted-foreground text-[10px]"> </p>
</div> </div>
</div> </div>
@ -318,7 +321,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
checked={config.multiple || false} checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)} onCheckedChange={(checked) => updateConfig("multiple", checked)}
/> />
<label htmlFor="multiple" className="text-xs"> </label> <label htmlFor="multiple" className="text-xs">
</label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -327,7 +332,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
checked={config.searchable || false} checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)} onCheckedChange={(checked) => updateConfig("searchable", checked)}
/> />
<label htmlFor="searchable" className="text-xs"> </label> <label htmlFor="searchable" className="text-xs">
</label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -336,7 +343,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
checked={config.allowClear !== false} checked={config.allowClear !== false}
onCheckedChange={(checked) => updateConfig("allowClear", checked)} onCheckedChange={(checked) => updateConfig("allowClear", checked)}
/> />
<label htmlFor="allowClear" className="text-xs"> </label> <label htmlFor="allowClear" className="text-xs">
</label>
</div> </div>
</div> </div>

View File

@ -298,3 +298,31 @@ export const setRepresentativeFile = async (objid: string): Promise<{
throw new Error("대표 파일 설정에 실패했습니다."); throw new Error("대표 파일 설정에 실패했습니다.");
} }
}; };
/**
* (, objid로 )
*/
export const getFileInfoByObjid = async (objid: string): Promise<{
success: boolean;
data?: {
objid: string;
realFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
regdate: string;
isRepresentative: boolean;
};
message?: string;
}> => {
try {
const response = await apiClient.get(`/files/info/${objid}`);
return response.data;
} catch (error) {
console.error("파일 정보 조회 오류:", error);
return {
success: false,
message: "파일 정보 조회에 실패했습니다.",
};
}
};

View File

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

View File

@ -31,10 +31,7 @@ export class ComponentRegistry {
throw new Error(`컴포넌트 등록 실패 (${definition.id}): ${validation.errors.join(", ")}`); throw new Error(`컴포넌트 등록 실패 (${definition.id}): ${validation.errors.join(", ")}`);
} }
// 중복 등록 체크 // 중복 등록 체크 (기존 정의를 덮어씀)
if (this.components.has(definition.id)) {
console.warn(`⚠️ 컴포넌트 중복 등록: ${definition.id} - 기존 정의를 덮어씁니다.`);
}
// 타임스탬프 추가 // 타임스탬프 추가
const enhancedDefinition = { const enhancedDefinition = {
@ -64,7 +61,6 @@ export class ComponentRegistry {
static unregisterComponent(id: string): void { static unregisterComponent(id: string): void {
const definition = this.components.get(id); const definition = this.components.get(id);
if (!definition) { if (!definition) {
console.warn(`⚠️ 등록되지 않은 컴포넌트 해제 시도: ${id}`);
return; return;
} }
@ -76,8 +72,6 @@ export class ComponentRegistry {
data: definition, data: definition,
timestamp: new Date(), timestamp: new Date(),
}); });
console.log(`🗑️ 컴포넌트 해제: ${id}`);
} }
/** /**
@ -355,7 +349,6 @@ export class ComponentRegistry {
}, },
force: async () => { force: async () => {
// hotReload 기능 비활성화 (불필요) // hotReload 기능 비활성화 (불필요)
console.log("⚠️ 강제 Hot Reload는 더 이상 필요하지 않습니다");
}, },
}, },

View File

@ -207,6 +207,88 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트 타입 변환 완료 // 컴포넌트 타입 변환 완료
// 🆕 조건부 렌더링 체크 (conditionalConfig)
// componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교
const conditionalConfig = (component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig;
// 디버그: 조건부 렌더링 설정 확인
if (conditionalConfig?.enabled) {
console.log(`🔍 [조건부 렌더링] ${component.id}:`, {
conditionalConfig,
formData: props.formData,
hasFormData: !!props.formData
});
}
if (conditionalConfig?.enabled && props.formData) {
const { field, operator, value, action } = conditionalConfig;
const fieldValue = props.formData[field];
console.log(`🔍 [조건부 렌더링 평가] ${component.id}:`, {
field,
fieldValue,
operator,
expectedValue: value,
action
});
// 조건 평가
let conditionMet = false;
switch (operator) {
case "=":
case "==":
case "===":
conditionMet = fieldValue === value;
break;
case "!=":
case "!==":
conditionMet = fieldValue !== value;
break;
case ">":
conditionMet = Number(fieldValue) > Number(value);
break;
case "<":
conditionMet = Number(fieldValue) < Number(value);
break;
case ">=":
conditionMet = Number(fieldValue) >= Number(value);
break;
case "<=":
conditionMet = Number(fieldValue) <= Number(value);
break;
case "contains":
conditionMet = String(fieldValue || "").includes(String(value));
break;
case "empty":
conditionMet = !fieldValue || fieldValue === "";
break;
case "notEmpty":
conditionMet = !!fieldValue && fieldValue !== "";
break;
default:
conditionMet = fieldValue === value;
}
// 액션에 따라 렌더링 결정
console.log(`🔍 [조건부 렌더링 결과] ${component.id}:`, {
conditionMet,
action,
shouldRender: action === "show" ? conditionMet : !conditionMet
});
if (action === "show" && !conditionMet) {
// "show" 액션: 조건이 충족되지 않으면 렌더링하지 않음
console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (show 조건 불충족)`);
return null;
}
if (action === "hide" && conditionMet) {
// "hide" 액션: 조건이 충족되면 렌더링하지 않음
console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (hide 조건 충족)`);
return null;
}
// "enable"/"disable" 액션은 conditionalDisabled props로 전달
}
// 🆕 모든 v2- 컴포넌트는 ComponentRegistry에서 통합 처리 // 🆕 모든 v2- 컴포넌트는 ComponentRegistry에서 통합 처리
// (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리) // (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
@ -217,14 +299,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const columnName = (component as any).columnName; const columnName = (component as any).columnName;
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원) // ⚠️ 단, componentType이 "select-basic" 또는 "v2-select"인 경우는 ComponentRegistry로 처리
// (다중선택, 체크박스, 라디오 등 고급 모드 지원)
if ( if (
(inputType === "category" || webType === "category") && (inputType === "category" || webType === "category") &&
tableName && tableName &&
columnName && columnName &&
componentType === "select-basic" (componentType === "select-basic" || componentType === "v2-select")
) { ) {
// select-basic ComponentRegistry에서 처리하도록 아래로 통과 // select-basic, v2-select는 ComponentRegistry에서 처리하도록 아래로 통과
} else if ((inputType === "category" || webType === "category") && tableName && columnName) { } else if ((inputType === "category" || webType === "category") && tableName && columnName) {
try { try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
@ -241,6 +324,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled; const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled;
const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly; const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly;
// 🔧 높이 계산: component.size에서 height 추출
const categorySize = (component as any).size;
const categoryStyle = (component as any).style;
const categoryLabel = (component as any).label;
const categoryId = component.id;
return ( return (
<CategorySelectComponent <CategorySelectComponent
tableName={tableName} tableName={tableName}
@ -252,6 +341,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
disabled={isFieldDisabled} disabled={isFieldDisabled}
readonly={isFieldReadonly} readonly={isFieldReadonly}
className="w-full" className="w-full"
size={categorySize}
style={categoryStyle}
label={categoryLabel}
id={categoryId}
isDesignMode={props.isDesignMode}
/> />
); );
} catch (error) { } catch (error) {
@ -343,7 +437,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const safeProps = filterDOMProps(restProps); const safeProps = filterDOMProps(restProps);
// 컴포넌트의 columnName에 해당하는 formData 값 추출 // 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || component.id; const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id;
// 🔍 파일 업로드 컴포넌트 디버깅
if (componentType === "v2-media" || componentType === "file-upload") {
console.log("[DynamicComponentRenderer] 파일 업로드:", {
componentType,
componentId: component.id,
columnName: (component as any).columnName,
configColumnName: (component as any).componentConfig?.columnName,
fieldName,
formDataValue: props.formData?.[fieldName],
formDataKeys: props.formData ? Object.keys(props.formData) : []
});
}
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화 // 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
let currentValue; let currentValue;
@ -412,10 +519,26 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}; };
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블) // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)
// 🆕 v2-input도 포함 (채번 규칙 조회 시 tableName 필요)
const useConfigTableName = const useConfigTableName =
componentType === "entity-search-input" || componentType === "entity-search-input" ||
componentType === "autocomplete-search-input" || componentType === "autocomplete-search-input" ||
componentType === "modal-repeater-table"; componentType === "modal-repeater-table" ||
componentType === "v2-input";
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const effectiveLabel = labelDisplay === true
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
const mergedStyle = {
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
width: finalStyle.width,
height: finalStyle.height,
};
const rendererProps = { const rendererProps = {
component, component,
@ -425,14 +548,29 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onDragEnd, onDragEnd,
size: component.size || newComponent.defaultSize, size: component.size || newComponent.defaultSize,
position: component.position, position: component.position,
style: finalStyle, // size를 포함한 최종 style
config: component.componentConfig, config: component.componentConfig,
componentConfig: component.componentConfig, componentConfig: component.componentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}), ...(component.componentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
label: effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
inputType: (component as any).inputType || component.componentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName,
value: currentValue, // formData에서 추출한 현재 값 전달 value: currentValue, // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달 // 새로운 기능들 전달
autoGeneration: component.autoGeneration || component.componentConfig?.autoGeneration, // 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환
autoGeneration: component.autoGeneration ||
component.componentConfig?.autoGeneration ||
((component as any).webTypeConfig?.numberingRuleId ? {
type: "numbering_rule" as const,
enabled: true,
options: {
numberingRuleId: (component as any).webTypeConfig.numberingRuleId,
},
} : undefined),
hidden: hiddenValue, hidden: hiddenValue,
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음) // React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
isInteractive, isInteractive,
@ -440,7 +578,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onFormDataChange, onFormDataChange,
onChange: handleChange, // 개선된 onChange 핸들러 전달 onChange: handleChange, // 개선된 onChange 핸들러 전달
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용 // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
tableName: useConfigTableName ? component.componentConfig?.tableName || tableName : tableName, // 🆕 component.tableName도 확인 (V2 레이아웃에서 overrides.tableName이 복원됨)
tableName: useConfigTableName
? component.componentConfig?.tableName || (component as any).tableName || tableName
: tableName,
menuId, // 🆕 메뉴 ID menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보 selectedScreen, // 🆕 화면 정보

View File

@ -8,6 +8,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { TableCategoryValue } from "@/types/tableCategoryValue"; import { TableCategoryValue } from "@/types/tableCategoryValue";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@ -23,6 +24,20 @@ interface CategorySelectComponentProps {
readonly?: boolean; readonly?: boolean;
tableName?: string; tableName?: string;
columnName?: string; columnName?: string;
// 🔧 높이 조절을 위한 props 추가
style?: React.CSSProperties & {
labelDisplay?: boolean;
labelFontSize?: string | number;
labelColor?: string;
labelFontWeight?: string | number;
labelMarginBottom?: string | number;
};
size?: { width?: number | string; height?: number | string };
// 🔧 라벨 표시를 위한 props 추가
label?: string;
id?: string;
// 🔧 디자인 모드 처리
isDesignMode?: boolean;
} }
/** /**
@ -43,7 +58,27 @@ export const CategorySelectComponent: React.FC<
readonly = false, readonly = false,
tableName: propTableName, tableName: propTableName,
columnName: propColumnName, columnName: propColumnName,
style,
size,
label: propLabel,
id: propId,
isDesignMode = false,
}) => { }) => {
// 🔧 높이 계산: size.height > style.height > 기본값(40px)
const componentHeight = size?.height || style?.height;
const heightStyle: React.CSSProperties = componentHeight
? { height: componentHeight }
: {};
// 🔧 라벨 관련 계산
const label = propLabel || component?.label;
const id = propId || component?.id;
const showLabel = label && style?.labelDisplay !== false;
// 라벨 높이 계산 (기본 20px)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const [categoryValues, setCategoryValues] = useState<TableCategoryValue[]>( const [categoryValues, setCategoryValues] = useState<TableCategoryValue[]>(
[] []
); );
@ -97,12 +132,49 @@ export const CategorySelectComponent: React.FC<
onChange?.(newValue); onChange?.(newValue);
}; };
// 🔧 공통 라벨 렌더링 함수
const renderLabel = () => {
if (!showLabel) return null;
return (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
);
};
// 🔧 공통 wrapper 스타일
const wrapperStyle: React.CSSProperties = {
width: size?.width || style?.width,
height: componentHeight,
};
// 🔧 디자인 모드일 때 클릭 방지
const designModeClass = isDesignMode ? "pointer-events-none" : "";
// 로딩 중 // 로딩 중
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-10 w-full items-center justify-center rounded-md border bg-background px-3 text-sm text-muted-foreground"> <div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderLabel()}
... <div
className="flex h-full w-full items-center justify-center rounded-md border bg-background px-3 text-sm text-muted-foreground"
style={heightStyle}
>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</div>
</div> </div>
); );
} }
@ -110,8 +182,14 @@ export const CategorySelectComponent: React.FC<
// 에러 // 에러
if (error) { if (error) {
return ( return (
<div className="flex h-10 w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 text-sm text-destructive"> <div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
{error} {renderLabel()}
<div
className="flex h-full w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 text-sm text-destructive"
style={heightStyle}
>
{error}
</div>
</div> </div>
); );
} }
@ -119,33 +197,44 @@ export const CategorySelectComponent: React.FC<
// 카테고리 값이 없음 // 카테고리 값이 없음
if (categoryValues.length === 0) { if (categoryValues.length === 0) {
return ( return (
<div className="flex h-10 w-full items-center rounded-md border bg-muted px-3 text-sm text-muted-foreground"> <div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
{renderLabel()}
<div
className="flex h-full w-full items-center rounded-md border bg-muted px-3 text-sm text-muted-foreground"
style={heightStyle}
>
</div>
</div> </div>
); );
} }
return ( return (
<Select <div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
value={value} {renderLabel()}
onValueChange={handleValueChange} <div className="h-full w-full">
disabled={disabled || readonly} <Select
required={required} value={value}
> onValueChange={handleValueChange}
<SelectTrigger className={`w-full ${className}`}> disabled={disabled || readonly}
<SelectValue placeholder={placeholder} /> required={required}
</SelectTrigger> >
<SelectContent> <SelectTrigger className={`w-full h-full ${className}`} style={heightStyle}>
{categoryValues.map((categoryValue) => ( <SelectValue placeholder={placeholder} />
<SelectItem </SelectTrigger>
key={categoryValue.valueId} <SelectContent>
value={categoryValue.valueCode} {categoryValues.map((categoryValue) => (
> <SelectItem
{categoryValue.valueLabel} key={categoryValue.valueId}
</SelectItem> value={categoryValue.valueCode}
))} >
</SelectContent> {categoryValue.valueLabel}
</Select> </SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
); );
}; };

View File

@ -14,22 +14,23 @@ import { FileUploadConfig } from "./types";
*/ */
export const FileUploadDefinition = createComponentDefinition({ export const FileUploadDefinition = createComponentDefinition({
id: "file-upload", id: "file-upload",
name: "파일 업로드", name: "파일 업로드 (레거시)",
nameEng: "FileUpload Component", nameEng: "FileUpload Component (Legacy)",
description: "파일 업로드를 위한 파일 선택 컴포넌트", description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)",
category: ComponentCategory.INPUT, category: ComponentCategory.INPUT,
webType: "file", webType: "file",
component: FileUploadComponent, component: FileUploadComponent,
defaultConfig: { defaultConfig: {
placeholder: "입력하세요", placeholder: "입력하세요",
}, },
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시) defaultSize: { width: 350, height: 240 },
configPanel: FileUploadConfigPanel, configPanel: FileUploadConfigPanel,
icon: "Edit", icon: "Edit",
tags: [], tags: [],
version: "1.0.0", version: "1.0.0",
author: "개발팀", author: "개발팀",
documentation: "https://docs.example.com/components/file-upload", documentation: "https://docs.example.com/components/file-upload",
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
}); });
// 타입 내보내기 // 타입 내보내기

View File

@ -13,9 +13,9 @@ import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel";
*/ */
export const ImageWidgetDefinition = createComponentDefinition({ export const ImageWidgetDefinition = createComponentDefinition({
id: "image-widget", id: "image-widget",
name: "이미지 위젯", name: "이미지 위젯 (레거시)",
nameEng: "Image Widget", nameEng: "Image Widget (Legacy)",
description: "이미지 표시 및 업로드", description: "이미지 표시 및 업로드 (레거시)",
category: ComponentCategory.INPUT, category: ComponentCategory.INPUT,
webType: "image", webType: "image",
component: ImageWidget, component: ImageWidget,
@ -32,6 +32,7 @@ export const ImageWidgetDefinition = createComponentDefinition({
version: "1.0.0", version: "1.0.0",
author: "개발팀", author: "개발팀",
documentation: "https://docs.example.com/components/image-widget", documentation: "https://docs.example.com/components/image-widget",
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
}); });
// 컴포넌트 내보내기 // 컴포넌트 내보내기

View File

@ -111,6 +111,7 @@ import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
/** /**
* *

View File

@ -220,8 +220,8 @@ export function RepeaterTable({
columns columns
.filter((col) => !col.hidden) .filter((col) => !col.hidden)
.forEach((col) => { .forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120; widths[col.field] = col.width ? parseInt(col.width) : 120;
}); });
return widths; return widths;
}); });
@ -404,10 +404,10 @@ export function RepeaterTable({
// 데이터가 있으면 데이터 기반 자동 맞춤, 없으면 균등 분배 // 데이터가 있으면 데이터 기반 자동 맞춤, 없으면 균등 분배
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (data.length > 0) { if (data.length > 0) {
applyAutoFitWidths(); applyAutoFitWidths();
} else { } else {
applyEqualizeWidths(); applyEqualizeWidths();
} }
}, 50); }, 50);
return () => clearTimeout(timer); return () => clearTimeout(timer);
@ -654,11 +654,17 @@ export function RepeaterTable({
<thead className="sticky top-0 z-20 bg-gray-50"> <thead className="sticky top-0 z-20 bg-gray-50">
<tr> <tr>
{/* 드래그 핸들 헤더 - 좌측 고정 */} {/* 드래그 핸들 헤더 - 좌측 고정 */}
<th className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700"> <th
key="header-drag"
className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700"
>
<span className="sr-only"></span> <span className="sr-only"></span>
</th> </th>
{/* 체크박스 헤더 - 좌측 고정 */} {/* 체크박스 헤더 - 좌측 고정 */}
<th className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 px-3 py-2 text-center font-medium text-gray-700"> <th
key="header-checkbox"
className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 px-3 py-2 text-center font-medium text-gray-700"
>
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
// @ts-expect-error - indeterminate는 HTML 속성 // @ts-expect-error - indeterminate는 HTML 속성
@ -667,7 +673,7 @@ export function RepeaterTable({
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")} className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
/> />
</th> </th>
{visibleColumns.map((col) => { {visibleColumns.map((col, colIndex) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource const activeOption = hasDynamicSource
@ -677,7 +683,7 @@ export function RepeaterTable({
return ( return (
<th <th
key={col.field} key={`header-col-${col.field || colIndex}`}
className="group relative cursor-pointer border-r border-b border-gray-200 px-3 py-2 text-left font-medium whitespace-nowrap text-gray-700 select-none" className="group relative cursor-pointer border-r border-b border-gray-200 px-3 py-2 text-left font-medium whitespace-nowrap text-gray-700 select-none"
style={{ width: `${columnWidths[col.field]}px` }} style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)} onDoubleClick={() => handleDoubleClick(col.field)}
@ -765,8 +771,9 @@ export function RepeaterTable({
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}> <SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<tbody className="bg-white"> <tbody className="bg-white">
{data.length === 0 ? ( {data.length === 0 ? (
<tr> <tr key="empty-row">
<td <td
key="empty-cell"
colSpan={visibleColumns.length + 2} colSpan={visibleColumns.length + 2}
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500" className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
> >
@ -787,8 +794,9 @@ export function RepeaterTable({
<> <>
{/* 드래그 핸들 - 좌측 고정 */} {/* 드래그 핸들 - 좌측 고정 */}
<td <td
key={`drag-${rowIndex}`}
className={cn( className={cn(
"sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center", "sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center",
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white", selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
)} )}
> >
@ -806,8 +814,9 @@ export function RepeaterTable({
</td> </td>
{/* 체크박스 - 좌측 고정 */} {/* 체크박스 - 좌측 고정 */}
<td <td
key={`check-${rowIndex}`}
className={cn( className={cn(
"sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center", "sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center",
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white", selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
)} )}
> >
@ -818,9 +827,9 @@ export function RepeaterTable({
/> />
</td> </td>
{/* 데이터 컬럼들 */} {/* 데이터 컬럼들 */}
{visibleColumns.map((col) => ( {visibleColumns.map((col, colIndex) => (
<td <td
key={col.field} key={`${rowIndex}-${col.field || colIndex}`}
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1" className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
style={{ style={{
width: `${columnWidths[col.field]}px`, width: `${columnWidths[col.field]}px`,

View File

@ -149,14 +149,11 @@ export function SimpleRepeaterTableComponent({
} }
// API 호출 // API 호출
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${initialConfig.sourceTable}/data`, {
`/table-management/tables/${initialConfig.sourceTable}/data`, search: filters,
{ page: 1,
search: filters, size: 1000, // 대량 조회
page: 1, });
size: 1000, // 대량 조회
}
);
if (response.data.success && response.data.data?.data) { if (response.data.success && response.data.data?.data) {
const loadedData = response.data.data.data; const loadedData = response.data.data.data;
@ -182,7 +179,7 @@ export function SimpleRepeaterTableComponent({
// 2. 조인 데이터 처리 // 2. 조인 데이터 처리
const joinColumns = columns.filter( const joinColumns = columns.filter(
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey (col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey,
); );
if (joinColumns.length > 0) { if (joinColumns.length > 0) {
@ -208,25 +205,20 @@ export function SimpleRepeaterTableComponent({
const [tableName] = groupKey.split(":"); const [tableName] = groupKey.split(":");
// 조인 키 값 수집 (중복 제거) // 조인 키 값 수집 (중복 제거)
const keyValues = Array.from(new Set( const keyValues = Array.from(
baseMappedData new Set(baseMappedData.map((row: any) => row[key]).filter((v: any) => v !== undefined && v !== null)),
.map((row: any) => row[key]) );
.filter((v: any) => v !== undefined && v !== null)
));
if (keyValues.length === 0) return; if (keyValues.length === 0) return;
try { try {
// 조인 테이블 조회 // 조인 테이블 조회
// refKey(타겟 테이블 컬럼)로 검색 // refKey(타겟 테이블 컬럼)로 검색
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
`/table-management/tables/${tableName}/data`, search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
{ page: 1,
search: { [refKey]: keyValues }, // { id: [1, 2, 3] } size: 1000,
page: 1, });
size: 1000,
}
);
if (response.data.success && response.data.data?.data) { if (response.data.success && response.data.data?.data) {
const joinedRows = response.data.data.data; const joinedRows = response.data.data.data;
@ -251,7 +243,7 @@ export function SimpleRepeaterTableComponent({
console.error(`조인 실패 (${tableName}):`, error); console.error(`조인 실패 (${tableName}):`, error);
// 실패 시 무시하고 진행 (값은 undefined) // 실패 시 무시하고 진행 (값은 undefined)
} }
}) }),
); );
} }
@ -399,9 +391,12 @@ export function SimpleRepeaterTableComponent({
// 기존 onFormDataChange도 호출 (호환성) // 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange && columnName) { if (onFormDataChange && columnName) {
// 테이블별 데이터를 통합하여 전달 // 테이블별 데이터를 통합하여 전달
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) => onFormDataChange(
rows.map((row: any) => ({ ...row, _targetTable: table })) columnName,
)); Object.entries(dataByTable).flatMap(([table, rows]) =>
rows.map((row: any) => ({ ...row, _targetTable: table })),
),
);
} }
}; };
@ -543,24 +538,14 @@ export function SimpleRepeaterTableComponent({
if (!allowAdd || readOnly || value.length >= maxRows) return null; if (!allowAdd || readOnly || value.length >= maxRows) return null;
return ( return (
<Button <Button type="button" variant="outline" size="sm" onClick={handleAddRow} className="h-8 text-xs">
type="button" <Plus className="mr-1 h-3.5 w-3.5" />
variant="outline"
size="sm"
onClick={handleAddRow}
className="h-8 text-xs"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{addButtonText} {addButtonText}
</Button> </Button>
); );
}; };
const renderCell = ( const renderCell = (row: any, column: SimpleRepeaterColumnConfig, rowIndex: number) => {
row: any,
column: SimpleRepeaterColumnConfig,
rowIndex: number
) => {
const cellValue = row[column.field]; const cellValue = row[column.field];
// 계산 필드는 편집 불가 // 계산 필드는 편집 불가
@ -583,9 +568,7 @@ export function SimpleRepeaterTableComponent({
<Input <Input
type="number" type="number"
value={cellValue || ""} value={cellValue || ""}
onChange={(e) => onChange={(e) => handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)}
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-7 text-xs" className="h-7 text-xs"
/> />
); );
@ -604,19 +587,19 @@ export function SimpleRepeaterTableComponent({
return ( return (
<Select <Select
value={cellValue || ""} value={cellValue || ""}
onValueChange={(newValue) => onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}
handleCellEdit(rowIndex, column.field, newValue)
}
> >
<SelectTrigger className="h-7 text-xs"> <SelectTrigger className="h-7 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => ( {column.selectOptions
<SelectItem key={option.value} value={option.value}> ?.filter((option) => option.value && option.value !== "")
{option.label} .map((option) => (
</SelectItem> <SelectItem key={option.value} value={option.value}>
))} {option.label}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
); );
@ -636,11 +619,11 @@ export function SimpleRepeaterTableComponent({
// 로딩 중일 때 // 로딩 중일 때
if (isLoading) { if (isLoading) {
return ( return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}> <div className={cn("bg-background overflow-hidden rounded-md border", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}> <div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center"> <div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" /> <Loader2 className="text-primary mx-auto mb-2 h-8 w-8 animate-spin" />
<p className="text-sm text-muted-foreground"> ...</p> <p className="text-muted-foreground text-sm"> ...</p>
</div> </div>
</div> </div>
</div> </div>
@ -650,14 +633,14 @@ export function SimpleRepeaterTableComponent({
// 에러 발생 시 // 에러 발생 시
if (loadError) { if (loadError) {
return ( return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}> <div className={cn("bg-background overflow-hidden rounded-md border", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}> <div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center"> <div className="text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2"> <div className="bg-destructive/10 mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
<X className="h-6 w-6 text-destructive" /> <X className="text-destructive h-6 w-6" />
</div> </div>
<p className="text-sm font-medium text-destructive mb-1"> </p> <p className="text-destructive mb-1 text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground">{loadError}</p> <p className="text-muted-foreground text-xs">{loadError}</p>
</div> </div>
</div> </div>
</div> </div>
@ -668,30 +651,27 @@ export function SimpleRepeaterTableComponent({
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0); const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
return ( return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}> <div className={cn("bg-background overflow-hidden rounded-md border", className)}>
{/* 상단 행 추가 버튼 */} {/* 상단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "bottom" && ( {allowAdd && addButtonPosition !== "bottom" && (
<div className="p-2 border-b bg-muted/50"> <div className="bg-muted/50 border-b p-2">
<AddRowButton /> <AddRowButton />
</div> </div>
)} )}
<div <div className="overflow-x-auto overflow-y-auto" style={{ maxHeight }}>
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight }}
>
<table className="w-full text-xs sm:text-sm"> <table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10"> <thead className="bg-muted sticky top-0 z-10">
<tr> <tr>
{showRowNumber && ( {showRowNumber && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12"> <th key="header-rownum" className="text-muted-foreground w-12 px-4 py-2 text-left font-medium">
# #
</th> </th>
)} )}
{columns.map((col) => ( {columns.map((col) => (
<th <th
key={col.field} key={`header-${col.field}`}
className="px-4 py-2 text-left font-medium text-muted-foreground" className="text-muted-foreground px-4 py-2 text-left font-medium"
style={{ width: col.width }} style={{ width: col.width }}
> >
{col.label} {col.label}
@ -699,7 +679,7 @@ export function SimpleRepeaterTableComponent({
</th> </th>
))} ))}
{!readOnly && allowDelete && ( {!readOnly && allowDelete && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20"> <th key="header-delete" className="text-muted-foreground w-20 px-4 py-2 text-left font-medium">
</th> </th>
)} )}
@ -707,11 +687,8 @@ export function SimpleRepeaterTableComponent({
</thead> </thead>
<tbody className="bg-background"> <tbody className="bg-background">
{value.length === 0 ? ( {value.length === 0 ? (
<tr> <tr key="empty-row">
<td <td key="empty-cell" colSpan={totalColumns} className="text-muted-foreground px-4 py-8 text-center">
colSpan={totalColumns}
className="px-4 py-8 text-center text-muted-foreground"
>
{allowAdd ? ( {allowAdd ? (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span> </span> <span> </span>
@ -724,25 +701,25 @@ export function SimpleRepeaterTableComponent({
</tr> </tr>
) : ( ) : (
value.map((row, rowIndex) => ( value.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t hover:bg-accent/50"> <tr key={`row-${rowIndex}`} className="hover:bg-accent/50 border-t">
{showRowNumber && ( {showRowNumber && (
<td className="px-4 py-2 text-center text-muted-foreground"> <td key={`rownum-${rowIndex}`} className="text-muted-foreground px-4 py-2 text-center">
{rowIndex + 1} {rowIndex + 1}
</td> </td>
)} )}
{columns.map((col) => ( {columns.map((col) => (
<td key={col.field} className="px-2 py-1"> <td key={`${rowIndex}-${col.field}`} className="px-2 py-1">
{renderCell(row, col, rowIndex)} {renderCell(row, col, rowIndex)}
</td> </td>
))} ))}
{!readOnly && allowDelete && ( {!readOnly && allowDelete && (
<td className="px-4 py-2 text-center"> <td key={`delete-${rowIndex}`} className="px-4 py-2 text-center">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleRowDelete(rowIndex)} onClick={() => handleRowDelete(rowIndex)}
disabled={value.length <= minRows} disabled={value.length <= minRows}
className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50" className="text-destructive hover:text-destructive h-7 w-7 p-0 disabled:opacity-50"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@ -757,35 +734,29 @@ export function SimpleRepeaterTableComponent({
{/* 합계 표시 */} {/* 합계 표시 */}
{summaryConfig?.enabled && summaryValues && ( {summaryConfig?.enabled && summaryValues && (
<div className={cn( <div
"border-t bg-muted/30 p-3", className={cn("bg-muted/30 border-t p-3", summaryConfig.position === "bottom-right" && "flex justify-end")}
summaryConfig.position === "bottom-right" && "flex justify-end" >
)}> <div className={cn(summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full")}>
<div className={cn(
summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full"
)}>
{summaryConfig.title && ( {summaryConfig.title && (
<div className="text-xs font-medium text-muted-foreground mb-2"> <div className="text-muted-foreground mb-2 text-xs font-medium">{summaryConfig.title}</div>
{summaryConfig.title}
</div>
)} )}
<div className={cn( <div
"grid gap-2", className={cn(
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" "grid gap-2",
)}> summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{summaryConfig.fields.map((field) => ( {summaryConfig.fields.map((field) => (
<div <div
key={field.field} key={field.field}
className={cn( className={cn(
"flex justify-between items-center px-3 py-1.5 rounded", "flex items-center justify-between rounded px-3 py-1.5",
field.highlight ? "bg-primary/10 font-semibold" : "bg-background" field.highlight ? "bg-primary/10 font-semibold" : "bg-background",
)} )}
> >
<span className="text-xs text-muted-foreground">{field.label}</span> <span className="text-muted-foreground text-xs">{field.label}</span>
<span className={cn( <span className={cn("text-sm font-medium", field.highlight && "text-primary")}>
"text-sm font-medium",
field.highlight && "text-primary"
)}>
{formatSummaryValue(field, summaryValues[field.field] || 0)} {formatSummaryValue(field, summaryValues[field.field] || 0)}
</span> </span>
</div> </div>
@ -797,10 +768,10 @@ export function SimpleRepeaterTableComponent({
{/* 하단 행 추가 버튼 */} {/* 하단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "top" && value.length > 0 && ( {allowAdd && addButtonPosition !== "top" && value.length > 0 && (
<div className="p-2 border-t bg-muted/50 flex justify-between items-center"> <div className="bg-muted/50 flex items-center justify-between border-t p-2">
<AddRowButton /> <AddRowButton />
{maxRows !== Infinity && ( {maxRows !== Infinity && (
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground text-xs">
{value.length} / {maxRows} {value.length} / {maxRows}
</span> </span>
)} )}
@ -809,4 +780,3 @@ export function SimpleRepeaterTableComponent({
</div> </div>
); );
} }

View File

@ -115,14 +115,14 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent" className="absolute top-0 right-0 h-full px-2 hover:bg-transparent"
onClick={() => !isDisabled && setOpen(!open)} onClick={() => !isDisabled && setOpen(!open)}
disabled={isDisabled} disabled={isDisabled}
> >
{loading ? ( {loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
) : ( ) : (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" /> <ChevronsUpDown className="text-muted-foreground h-4 w-4" />
)} )}
</Button> </Button>
</div> </div>
@ -149,12 +149,7 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
setOpen(false); setOpen(false);
}} }}
> >
<Check <Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label} {option.label}
</CommandItem> </CommandItem>
))} ))}
@ -948,13 +943,17 @@ export function UniversalFormModalComponent({
// 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장) // 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장)
const originalTableSectionKey = `_originalTableSectionData_${section.id}`; const originalTableSectionKey = `_originalTableSectionData_${section.id}`;
newFormData[originalTableSectionKey] = JSON.parse(JSON.stringify(items)); newFormData[originalTableSectionKey] = JSON.parse(JSON.stringify(items));
console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`); console.log(
`[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`,
);
// 기존 originalGroupedData에도 추가 (하위 호환성) // 기존 originalGroupedData에도 추가 (하위 호환성)
if (!groupedDataInitializedRef.current) { if (!groupedDataInitializedRef.current) {
setOriginalGroupedData((prev) => { setOriginalGroupedData((prev) => {
const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))]; const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))];
console.log(`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`); console.log(
`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`,
);
return newOriginal; return newOriginal;
}); });
} }
@ -1443,8 +1442,9 @@ export function UniversalFormModalComponent({
if (isNewRecord || hasNoValue) { if (isNewRecord || hasNoValue) {
try { try {
// allocateNumberingCode로 실제 순번 증가 // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const response = await allocateNumberingCode(field.numberingRule.ruleId); const userInputCode = mainData[field.columnName] as string;
const response = await allocateNumberingCode(field.numberingRule.ruleId, userInputCode, mainData);
if (response.success && response.data?.generatedCode) { if (response.success && response.data?.generatedCode) {
mainData[field.columnName] = response.data.generatedCode; mainData[field.columnName] = response.data.generatedCode;
} }
@ -2432,14 +2432,14 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent" className="absolute top-0 right-0 h-full px-2 hover:bg-transparent"
onClick={() => !disabled && !loading && setOpen(!open)} onClick={() => !disabled && !loading && setOpen(!open)}
disabled={disabled || loading} disabled={disabled || loading}
> >
{loading ? ( {loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
) : ( ) : (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" /> <ChevronsUpDown className="text-muted-foreground h-4 w-4" />
)} )}
</Button> </Button>
</div> </div>
@ -2462,12 +2462,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
setOpen(false); setOpen(false);
}} }}
> >
<Check <Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label} {option.label}
</CommandItem> </CommandItem>
))} ))}

View File

@ -555,13 +555,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
// 스타일 계산 // 스타일 계산
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 // 🔧 사용자가 설정한 크기가 있으면 그대로 사용
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
const componentStyle: React.CSSProperties = { const componentStyle: React.CSSProperties = {
...component.style, ...component.style,
...style, ...style,
width: "100%",
height: "100%",
}; };
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
@ -641,19 +638,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
// 성공한 경우에만 성공 토스트 표시 // 성공한 경우에만 성공 토스트 표시
// edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리 // save, delete, submit 액션에서만 성공 메시지 표시
// (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시) // 그 외 액션은 조용히 처리 (불필요한 "완료되었습니다" 토스트 방지)
const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; const successToastActions = ["save", "delete", "submit"];
if (!silentSuccessActions.includes(actionConfig.type)) { if (successToastActions.includes(actionConfig.type)) {
// 기본 성공 메시지 결정 // 기본 성공 메시지 결정
const defaultSuccessMessage = const defaultSuccessMessage =
actionConfig.type === "save" actionConfig.type === "save"
? "저장되었습니다." ? "저장되었습니다."
: actionConfig.type === "delete" : actionConfig.type === "delete"
? "삭제되었습니다." ? "삭제되었습니다."
: actionConfig.type === "submit" : "제출되었습니다.";
? "제출되었습니다."
: "완료되었습니다.";
// 커스텀 메시지 사용 조건: // 커스텀 메시지 사용 조건:
// 1. 커스텀 메시지가 있고 // 1. 커스텀 메시지가 있고
@ -1273,19 +1268,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일 // 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지 // 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
const userStyle = component.style const userStyle = component.style
? Object.fromEntries( ? Object.fromEntries(
Object.entries(component.style).filter( Object.entries(component.style).filter(([key]) => !["background", "backgroundColor"].includes(key)),
([key]) => !["width", "height", "background", "backgroundColor"].includes(key),
),
) )
: {}; : {};
// 🔧 사용자가 설정한 크기 우선 사용, 없으면 100%
const buttonWidth = component.size?.width ? `${component.size.width}px` : style?.width || "100%";
const buttonHeight = component.size?.height ? `${component.size.height}px` : style?.height || "100%";
const buttonElementStyle: React.CSSProperties = { const buttonElementStyle: React.CSSProperties = {
width: "100%", width: buttonWidth,
height: "100%", height: buttonHeight,
minHeight: "40px", minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
border: "none", border: "none",
borderRadius: "0.5rem", borderRadius: "0.5rem",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
@ -1308,7 +1305,31 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...userStyle, ...userStyle,
}; };
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; // 버튼 텍스트 결정 (다양한 소스에서 가져옴)
// "기본 버튼"은 컴포넌트 생성 시 기본값이므로 무시
const labelValue = component.label === "기본 버튼" ? undefined : component.label;
// 액션 타입에 따른 기본 텍스트 (modal 액션과 동일하게)
const actionType = processedConfig.action?.type || component.componentConfig?.action?.type;
const actionDefaultText: Record<string, string> = {
save: "저장",
delete: "삭제",
modal: "등록",
edit: "수정",
copy: "복사",
close: "닫기",
cancel: "취소",
};
const buttonContent =
processedConfig.text ||
component.webTypeConfig?.text ||
component.componentConfig?.text ||
component.config?.text ||
component.style?.labelText ||
labelValue ||
actionDefaultText[actionType as string] ||
"버튼";
return ( return (
<> <>

View File

@ -115,31 +115,36 @@ export function V2CategoryManagerComponent({
}, []); }, []);
return ( return (
<div ref={containerRef} className="flex h-full min-h-[10px] gap-0" style={{ height: config.height }}> <div ref={containerRef} className="flex h-full min-h-[10px] gap-0 overflow-hidden" style={{ height: config.height }}>
{/* 좌측: 카테고리 컬럼 리스트 */} {/* 좌측: 카테고리 컬럼 리스트 - 스크롤 가능 */}
{config.showColumnList && ( {config.showColumnList && (
<> <>
<div style={{ width: `${leftWidth}%` }} className="pr-3"> <div
<CategoryColumnList style={{ width: `${leftWidth}%` }}
tableName={effectiveTableName} className="flex h-full flex-col overflow-hidden pr-3"
selectedColumn={selectedColumn?.uniqueKey || null} >
onColumnSelect={handleColumnSelect} <div className="flex-1 overflow-y-auto">
menuObjid={effectiveMenuObjid} <CategoryColumnList
/> tableName={effectiveTableName}
selectedColumn={selectedColumn?.uniqueKey || null}
onColumnSelect={handleColumnSelect}
menuObjid={effectiveMenuObjid}
/>
</div>
</div> </div>
{/* 리사이저 */} {/* 리사이저 */}
<div <div
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
className="group hover:bg-accent/50 relative flex w-3 cursor-col-resize items-center justify-center border-r transition-colors" className="group hover:bg-accent/50 relative flex h-full w-3 shrink-0 cursor-col-resize items-center justify-center border-r transition-colors"
> >
<GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" /> <GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" />
</div> </div>
</> </>
)} )}
{/* 우측: 카테고리 값 관리 */} {/* 우측: 카테고리 값 관리 - 고정 */}
<div style={{ width: config.showColumnList ? `${100 - leftWidth - 1}%` : "100%" }} className="flex flex-col pl-3"> <div style={{ width: config.showColumnList ? `${100 - leftWidth - 1}%` : "100%" }} className="flex h-full flex-col overflow-hidden pl-3">
{/* 뷰 모드 토글 */} {/* 뷰 모드 토글 */}
{config.showViewModeToggle && ( {config.showViewModeToggle && (
<div className="mb-2 flex items-center justify-end gap-1"> <div className="mb-2 flex items-center justify-end gap-1">
@ -167,8 +172,8 @@ export function V2CategoryManagerComponent({
</div> </div>
)} )}
{/* 카테고리 값 관리 컴포넌트 */} {/* 카테고리 값 관리 컴포넌트 - 스크롤 가능 */}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1 overflow-y-auto">
{selectedColumn ? ( {selectedColumn ? (
viewMode === "tree" ? ( viewMode === "tree" ? (
<CategoryValueManagerTree <CategoryValueManagerTree

View File

@ -29,10 +29,15 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
} }
}; };
// 라벨: style.labelText 우선, 없으면 component.label 사용
// style.labelDisplay가 false면 라벨 숨김
const style = component.style || {};
const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label);
return ( return (
<V2Date <V2Date
id={component.id} id={component.id}
label={component.label} label={effectiveLabel}
required={component.required} required={component.required}
readonly={config.readonly || component.readonly} readonly={config.readonly || component.readonly}
disabled={config.disabled || component.disabled} disabled={config.disabled || component.disabled}
@ -41,7 +46,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
config={{ config={{
dateType: config.dateType || config.webType || "date", dateType: config.dateType || config.webType || "date",
format: config.format || "YYYY-MM-DD", format: config.format || "YYYY-MM-DD",
placeholder: config.placeholder || "날짜 선택", placeholder: config.placeholder || style.placeholder || "날짜 선택",
showTime: config.showTime || false, showTime: config.showTime || false,
use24Hours: config.use24Hours ?? true, use24Hours: config.use24Hours ?? true,
minDate: config.minDate, minDate: config.minDate,

View File

@ -0,0 +1,529 @@
"use client";
import React, { useState, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileInfo, FileUploadConfig } from "./types";
import {
Upload,
Download,
Trash2,
Eye,
File,
FileText,
Image as ImageIcon,
Video,
Music,
Archive,
Presentation,
X,
Star,
ZoomIn,
ZoomOut,
RotateCcw,
} from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { FileViewerModal } from "./FileViewerModal";
interface FileManagerModalProps {
isOpen: boolean;
onClose: () => void;
uploadedFiles: FileInfo[];
onFileUpload: (files: File[]) => Promise<void>;
onFileDownload: (file: FileInfo) => void;
onFileDelete: (file: FileInfo) => void;
onFileView: (file: FileInfo) => void;
onSetRepresentative?: (file: FileInfo) => void; // 대표 이미지 설정 콜백
config: FileUploadConfig;
isDesignMode?: boolean;
}
export const FileManagerModal: React.FC<FileManagerModalProps> = ({
isOpen,
onClose,
uploadedFiles,
onFileUpload,
onFileDownload,
onFileDelete,
onFileView,
onSetRepresentative,
config,
isDesignMode = false,
}) => {
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null); // 선택된 파일 (좌측 미리보기용)
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); // 이미지 미리보기 URL
const [zoomLevel, setZoomLevel] = useState(1); // 🔍 확대/축소 레벨
const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); // 🖱️ 이미지 위치
const [isDragging, setIsDragging] = useState(false); // 🖱️ 드래그 중 여부
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); // 🖱️ 드래그 시작 위치
const fileInputRef = useRef<HTMLInputElement>(null);
const imageContainerRef = useRef<HTMLDivElement>(null);
// 파일 아이콘 가져오기
const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
return <ImageIcon className="w-5 h-5 text-blue-500" />;
} else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
return <FileText className="w-5 h-5 text-red-500" />;
} else if (['xls', 'xlsx', 'csv'].includes(ext)) {
return <FileText className="w-5 h-5 text-green-500" />;
} else if (['ppt', 'pptx'].includes(ext)) {
return <Presentation className="w-5 h-5 text-orange-500" />;
} else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) {
return <Video className="w-5 h-5 text-purple-500" />;
} else if (['mp3', 'wav', 'ogg'].includes(ext)) {
return <Music className="w-5 h-5 text-pink-500" />;
} else if (['zip', 'rar', '7z'].includes(ext)) {
return <Archive className="w-5 h-5 text-yellow-500" />;
} else {
return <File className="w-5 h-5 text-gray-500" />;
}
};
// 파일 업로드 핸들러
const handleFileUpload = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
setUploading(true);
try {
const fileArray = Array.from(files);
await onFileUpload(fileArray);
console.log('✅ FileManagerModal: 파일 업로드 완료');
} catch (error) {
console.error('❌ FileManagerModal: 파일 업로드 오류:', error);
} finally {
setUploading(false);
console.log('🔄 FileManagerModal: 업로드 상태 초기화');
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (config.disabled || isDesignMode) return;
const files = e.dataTransfer.files;
handleFileUpload(files);
};
// 파일 선택 핸들러
const handleFileSelect = () => {
if (config.disabled || isDesignMode) return;
fileInputRef.current?.click();
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
handleFileUpload(files);
}
// 입력값 초기화
e.target.value = '';
};
// 파일 뷰어 핸들러
const handleFileViewInternal = (file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
};
const handleViewerClose = () => {
setIsViewerOpen(false);
setViewerFile(null);
};
// 파일 클릭 시 미리보기 로드
const handleFileClick = async (file: FileInfo) => {
setSelectedFile(file);
setZoomLevel(1); // 🔍 파일 선택 시 확대/축소 레벨 초기화
setImagePosition({ x: 0, y: 0 }); // 🖱️ 이미지 위치 초기화
// 이미지 파일인 경우 미리보기 로드
// 🔑 점(.)을 제거하고 확장자만 비교
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
const ext = file.fileExt.toLowerCase().replace('.', '');
if (imageExtensions.includes(ext) || file.isImage) {
try {
// 🔑 이미 previewUrl이 있으면 바로 사용
if (file.previewUrl) {
setPreviewImageUrl(file.previewUrl);
return;
}
// 이전 Blob URL 해제
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${file.objid}`, {
responseType: 'blob'
});
const blob = new Blob([response.data]);
const blobUrl = URL.createObjectURL(blob);
setPreviewImageUrl(blobUrl);
} catch (error) {
console.error("이미지 로드 실패:", error);
// 🔑 에러 발생 시에도 previewUrl이 있으면 사용
if (file.previewUrl) {
setPreviewImageUrl(file.previewUrl);
} else {
setPreviewImageUrl(null);
}
}
} else {
setPreviewImageUrl(null);
}
};
// 컴포넌트 언마운트 시 Blob URL 해제
React.useEffect(() => {
return () => {
if (previewImageUrl) {
URL.revokeObjectURL(previewImageUrl);
}
};
}, [previewImageUrl]);
// 🔑 모달이 열릴 때 첫 번째 파일을 자동으로 선택하고 확대/축소 레벨 초기화
React.useEffect(() => {
if (isOpen) {
setZoomLevel(1); // 🔍 모달 열릴 때 확대/축소 레벨 초기화
setImagePosition({ x: 0, y: 0 }); // 🖱️ 이미지 위치 초기화
if (uploadedFiles.length > 0 && !selectedFile) {
const firstFile = uploadedFiles[0];
handleFileClick(firstFile);
}
}
}, [isOpen, uploadedFiles, selectedFile]);
// 🖱️ 마우스 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
if (zoomLevel > 1) {
setIsDragging(true);
setDragStart({ x: e.clientX - imagePosition.x, y: e.clientY - imagePosition.y });
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging && zoomLevel > 1) {
setImagePosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
// 🔍 확대/축소 레벨이 1로 돌아가면 위치도 초기화
React.useEffect(() => {
if (zoomLevel <= 1) {
setImagePosition({ x: 0, y: 0 });
}
}, [zoomLevel]);
return (
<>
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-[95vw] w-[1400px] max-h-[90vh] overflow-hidden [&>button]:hidden">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<DialogTitle className="text-lg font-semibold">
({uploadedFiles.length})
</DialogTitle>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-gray-100"
onClick={onClose}
title="닫기"
>
<X className="w-4 h-4" />
</Button>
</DialogHeader>
<div className="flex flex-col space-y-3 h-[75vh]">
{/* 파일 업로드 영역 - 높이 축소 */}
{!isDesignMode && (
<div
className={`
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploading ? 'opacity-75' : ''}
`}
onClick={() => {
if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click();
}
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple={config.multiple}
accept={config.accept}
onChange={handleFileInputChange}
className="hidden"
disabled={config.disabled}
/>
{uploading ? (
<div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-sm text-blue-600 font-medium"> ...</span>
</div>
) : (
<div className="flex items-center justify-center gap-3">
<Upload className="h-6 w-6 text-gray-400" />
<p className="text-sm font-medium text-gray-700">
</p>
</div>
)}
</div>
)}
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
<div className="flex-1 flex gap-4 min-h-0">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
<div className="flex-1 border border-gray-200 rounded-lg bg-gray-900 flex flex-col overflow-hidden relative">
{/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && (
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 bg-black/60 rounded-lg p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.max(0.25, prev - 0.25))}
disabled={zoomLevel <= 0.25}
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-white text-xs min-w-[50px] text-center">
{Math.round(zoomLevel * 100)}%
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.min(4, prev + 0.25))}
disabled={zoomLevel >= 4}
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(1)}
>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
)}
{/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */}
<div
ref={imageContainerRef}
className={`flex-1 flex items-center justify-center overflow-hidden p-4 ${
zoomLevel > 1 ? (isDragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
}`}
onWheel={(e) => {
if (selectedFile && previewImageUrl) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoomLevel(prev => Math.min(4, Math.max(0.25, prev + delta)));
}
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{selectedFile && previewImageUrl ? (
<img
src={previewImageUrl}
alt={selectedFile.realFileName}
className="transition-transform duration-100 select-none"
style={{
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
transformOrigin: 'center center',
}}
draggable={false}
/>
) : selectedFile ? (
<div className="flex flex-col items-center text-gray-400">
{getFileIcon(selectedFile.fileExt)}
<p className="mt-2 text-sm"> </p>
</div>
) : (
<div className="flex flex-col items-center text-gray-400">
<ImageIcon className="w-16 h-16 mb-2" />
<p className="text-sm"> </p>
</div>
)}
</div>
{/* 파일 정보 바 */}
{selectedFile && (
<div className="bg-black/60 text-white text-xs px-3 py-2 text-center truncate">
{selectedFile.realFileName}
</div>
)}
</div>
{/* 우측: 파일 목록 (고정 너비) */}
<div className="w-[400px] shrink-0 border border-gray-200 rounded-lg overflow-hidden flex flex-col">
<div className="p-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">
</h3>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-3">
{uploadedFiles.length > 0 ? (
<div className="space-y-2">
{uploadedFiles.map((file) => (
<div
key={file.objid}
className={`
flex items-center space-x-3 p-2 rounded-lg transition-colors cursor-pointer
${selectedFile?.objid === file.objid ? 'bg-blue-50 border border-blue-200' : 'bg-gray-50 hover:bg-gray-100'}
`}
onClick={() => handleFileClick(file)}
>
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 truncate">
{file.realFileName}
</span>
{file.isRepresentative && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
</Badge>
)}
</div>
<p className="text-xs text-gray-500">
{formatFileSize(file.fileSize)} {file.fileExt.toUpperCase()}
</p>
</div>
<div className="flex items-center space-x-1">
{onSetRepresentative && (
<Button
variant={file.isRepresentative ? "default" : "ghost"}
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onSetRepresentative(file);
}}
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
>
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleFileViewInternal(file);
}}
title="미리보기"
>
<Eye className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="w-3 h-3" />
</Button>
{!isDesignMode && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
onFileDelete(file);
}}
title="삭제"
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<File className="w-12 h-12 mb-3 text-gray-300" />
<p className="text-sm font-medium text-gray-600"> </p>
<p className="text-xs text-gray-500 mt-1">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* 파일 뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={onFileDownload}
onDelete={!isDesignMode ? onFileDelete : undefined}
/>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,287 @@
"use client";
import React, { useMemo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { FileUploadConfig } from "./types";
import { V2FileUploadDefaultConfig } from "./config";
export interface FileUploadConfigPanelProps {
config: FileUploadConfig;
onChange: (config: Partial<FileUploadConfig>) => void;
screenTableName?: string;
}
/**
* V2 FileUpload
* UI
*/
export const FileUploadConfigPanel: React.FC<FileUploadConfigPanelProps> = ({
config: propConfig,
onChange,
screenTableName,
}) => {
// config 안전하게 초기화 (useMemo)
const config = useMemo(() => ({
...V2FileUploadDefaultConfig,
...propConfig,
}), [propConfig]);
// 핸들러
const handleChange = useCallback(<K extends keyof FileUploadConfig>(
key: K,
value: FileUploadConfig[K]
) => {
onChange({ [key]: value });
}, [onChange]);
// 파일 크기를 MB 단위로 변환
const maxSizeMB = useMemo(() => {
return (config.maxSize || 10 * 1024 * 1024) / (1024 * 1024);
}, [config.maxSize]);
const handleMaxSizeChange = useCallback((value: string) => {
const mb = parseFloat(value) || 10;
handleChange("maxSize", mb * 1024 * 1024);
}, [handleChange]);
return (
<div className="space-y-4 p-4">
<div className="text-sm font-medium text-muted-foreground">
V2
</div>
<Separator />
{/* 기본 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs"></Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
placeholder="파일을 선택하세요"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="accept" className="text-xs"> </Label>
<Select
value={config.accept || "*/*"}
onValueChange={(value) => handleChange("accept", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="파일 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="*/*"> </SelectItem>
<SelectItem value="image/*"></SelectItem>
<SelectItem value=".pdf,.doc,.docx,.xls,.xlsx"></SelectItem>
<SelectItem value="image/*,.pdf"> + PDF</SelectItem>
<SelectItem value=".zip,.rar,.7z"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label htmlFor="maxSize" className="text-xs"> (MB)</Label>
<Input
id="maxSize"
type="number"
min={1}
max={100}
value={maxSizeMB}
onChange={(e) => handleMaxSizeChange(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFiles" className="text-xs"> </Label>
<Input
id="maxFiles"
type="number"
min={1}
max={50}
value={config.maxFiles || 10}
onChange={(e) => handleChange("maxFiles", parseInt(e.target.value) || 10)}
className="h-8 text-xs"
/>
</div>
</div>
</div>
<Separator />
{/* 동작 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={config.multiple !== false}
onCheckedChange={(checked) => handleChange("multiple", checked as boolean)}
/>
<Label htmlFor="multiple" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allowDelete"
checked={config.allowDelete !== false}
onCheckedChange={(checked) => handleChange("allowDelete", checked as boolean)}
/>
<Label htmlFor="allowDelete" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allowDownload"
checked={config.allowDownload !== false}
onCheckedChange={(checked) => handleChange("allowDownload", checked as boolean)}
/>
<Label htmlFor="allowDownload" className="text-xs"> </Label>
</div>
</div>
<Separator />
{/* 표시 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="showPreview"
checked={config.showPreview !== false}
onCheckedChange={(checked) => handleChange("showPreview", checked as boolean)}
/>
<Label htmlFor="showPreview" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showFileList"
checked={config.showFileList !== false}
onCheckedChange={(checked) => handleChange("showFileList", checked as boolean)}
/>
<Label htmlFor="showFileList" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showFileSize"
checked={config.showFileSize !== false}
onCheckedChange={(checked) => handleChange("showFileSize", checked as boolean)}
/>
<Label htmlFor="showFileSize" className="text-xs"> </Label>
</div>
</div>
<Separator />
{/* 상태 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked as boolean)}
/>
<Label htmlFor="required" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked as boolean)}
/>
<Label htmlFor="readonly" className="text-xs"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked as boolean)}
/>
<Label htmlFor="disabled" className="text-xs"></Label>
</div>
</div>
<Separator />
{/* 스타일 설정 */}
<div className="space-y-3">
<Label className="text-xs font-semibold uppercase text-muted-foreground">
</Label>
<div className="space-y-2">
<Label htmlFor="variant" className="text-xs"> </Label>
<Select
value={config.variant || "default"}
onValueChange={(value) => handleChange("variant", value as "default" | "outlined" | "filled")}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="outlined"></SelectItem>
<SelectItem value="filled"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="size" className="text-xs"></Label>
<Select
value={config.size || "md"}
onValueChange={(value) => handleChange("size", value as "sm" | "md" | "lg")}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
<SelectItem value="lg"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 도움말 */}
<div className="space-y-2">
<Label htmlFor="helperText" className="text-xs"></Label>
<Input
id="helperText"
value={config.helperText || ""}
onChange={(e) => handleChange("helperText", e.target.value)}
placeholder="파일 업로드에 대한 안내 문구"
className="h-8 text-xs"
/>
</div>
</div>
);
};

View File

@ -0,0 +1,543 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileInfo } from "./types";
import { Download, X, AlertTriangle, FileText, Trash2, ExternalLink } from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { API_BASE_URL } from "@/lib/api/client";
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
const loadOfficeLibrariesFromCDN = async () => {
if (typeof window === "undefined") return { XLSX: null, mammoth: null };
try {
// XLSX 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).XLSX) {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// mammoth 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).mammoth) {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
return {
XLSX: (window as any).XLSX,
mammoth: (window as any).mammoth,
};
} catch (error) {
console.error("Office 라이브러리 CDN 로드 실패:", error);
return { XLSX: null, mammoth: null };
}
};
interface FileViewerModalProps {
file: FileInfo | null;
isOpen: boolean;
onClose: () => void;
onDownload?: (file: FileInfo) => void;
onDelete?: (file: FileInfo) => void;
}
/**
*
*/
export const FileViewerModal: React.FC<FileViewerModalProps> = ({ file, isOpen, onClose, onDownload, onDelete }) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [renderedContent, setRenderedContent] = useState<string | null>(null);
// Office 문서를 CDN 라이브러리로 렌더링하는 함수
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
try {
setIsLoading(true);
// CDN에서 라이브러리 로드
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
if (fileExt === "docx" && mammoth) {
// Word 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
const htmlContent = `
<div>
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
${result.value || "내용을 읽을 수 없습니다."}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
// Excel 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: "array" });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(worksheet, {
table: { className: "excel-table" },
});
const htmlContent = `
<div>
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
<p style="margin: 0 0 15px 0; color: #666; font-size: 14px;">시트: ${sheetName}</p>
<div style="max-height: 400px; overflow: auto; border: 1px solid #ddd; border-radius: 5px;">
<style>
.excel-table { border-collapse: collapse; width: 100%; }
.excel-table td, .excel-table th { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 12px; }
.excel-table th { background-color: #f5f5f5; font-weight: bold; }
</style>
${html}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (fileExt === "doc") {
// .doc 파일은 .docx로 변환 안내
const htmlContent = `
<div style="text-align: center; padding: 40px;">
<h3 style="color: #333; margin-bottom: 15px;">📄 ${fileName}</h3>
<p style="color: #666; margin-bottom: 10px;">.doc .docx로 .</p>
<p style="color: #666; font-size: 14px;">(.docx )</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["ppt", "pptx"].includes(fileExt)) {
// PowerPoint는 미리보기 불가 안내
const htmlContent = `
<div style="text-align: center; padding: 40px;">
<h3 style="color: #333; margin-bottom: 15px;">📑 ${fileName}</h3>
<p style="color: #666; margin-bottom: 10px;">PowerPoint .</p>
<p style="color: #666; font-size: 14px;"> .</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
}
return false; // 지원하지 않는 형식
} catch (error) {
console.error("Office 문서 렌더링 오류:", error);
const htmlContent = `
<div style="color: red; text-align: center; padding: 20px;">
Office .<br>
.
</div>
`;
setRenderedContent(htmlContent);
return true; // 오류 메시지라도 표시
} finally {
setIsLoading(false);
}
};
// 파일이 변경될 때마다 미리보기 URL 생성
useEffect(() => {
if (!file || !isOpen) {
setPreviewUrl(null);
setPreviewError(null);
setRenderedContent(null);
return;
}
setIsLoading(true);
setPreviewError(null);
// 로컬 파일인 경우
if (file._file) {
const url = URL.createObjectURL(file._file);
setPreviewUrl(url);
setIsLoading(false);
return () => URL.revokeObjectURL(url);
}
let cleanup: (() => void) | undefined;
// 서버 파일인 경우 - 미리보기 API 호출
const generatePreviewUrl = async () => {
try {
const fileExt = file.fileExt.toLowerCase();
// 미리보기 지원 파일 타입 정의
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const documentExtensions = [
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"rtf",
"odt",
"ods",
"odp",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
];
const textExtensions = ["txt", "md", "json", "xml", "csv"];
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
const supportedExtensions = [...imageExtensions, ...documentExtensions, ...textExtensions, ...mediaExtensions];
if (supportedExtensions.includes(fileExt)) {
// 이미지나 PDF는 인증된 요청으로 Blob 생성
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
try {
// 인증된 요청으로 파일 데이터 가져오기
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
cleanup = () => URL.revokeObjectURL(blobUrl);
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error("파일 미리보기 로드 실패:", error);
setPreviewError("파일을 불러올 수 없습니다. 권한을 확인해주세요.");
}
} else if (documentExtensions.includes(fileExt)) {
// Office 문서는 OnlyOffice 또는 안정적인 뷰어 사용
try {
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
try {
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
if (!renderSuccess) {
// 렌더링 실패 시 Blob URL 사용
setPreviewUrl(blobUrl);
}
} catch (error) {
console.error("Office 문서 렌더링 중 오류:", error);
// 오류 발생 시 Blob URL 사용
setPreviewUrl(blobUrl);
}
} else {
// 기타 문서는 직접 Blob URL 사용
setPreviewUrl(blobUrl);
}
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error("Office 문서 로드 실패:", error);
// 오류 발생 시 다운로드 옵션 제공
setPreviewError(`${fileExt.toUpperCase()} 문서를 미리보기할 수 없습니다. 다운로드하여 확인해주세요.`);
}
} else {
// 기타 파일은 다운로드 URL 사용
const url = `${API_BASE_URL.replace("/api", "")}/api/files/download/${file.objid}`;
setPreviewUrl(url);
}
} else {
// 지원하지 않는 파일 타입
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
}
} catch (error) {
console.error("미리보기 URL 생성 오류:", error);
setPreviewError("미리보기를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
generatePreviewUrl();
// cleanup 함수 반환
return () => {
if (cleanup) {
cleanup();
}
};
}, [file, isOpen]);
if (!file) return null;
// 파일 타입별 미리보기 컴포넌트
const renderPreview = () => {
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
</div>
);
}
if (previewError) {
return (
<div className="flex h-96 flex-col items-center justify-center">
<AlertTriangle className="mb-4 h-16 w-16 text-yellow-500" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="text-center text-sm">{previewError}</p>
<Button variant="outline" onClick={() => onDownload?.(file)} className="mt-4">
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
);
}
const fileExt = file.fileExt.toLowerCase();
// 이미지 파일
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
return (
<div className="flex max-h-96 items-center justify-center overflow-hidden">
<img
src={previewUrl || ""}
alt={file.realFileName}
className="max-h-full max-w-full rounded-lg object-contain shadow-lg"
onError={(e) => {
console.error("이미지 로드 오류:", previewUrl, e);
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
}}
onLoad={() => {
console.log("이미지 로드 성공:", previewUrl);
}}
/>
</div>
);
}
// 텍스트 파일
if (["txt", "md", "json", "xml", "csv"].includes(fileExt)) {
return (
<div className="h-96 overflow-auto">
<iframe
src={previewUrl || ""}
className="h-full w-full rounded-lg border"
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
/>
</div>
);
}
// PDF 파일 - 브라우저 기본 뷰어 사용
if (fileExt === "pdf") {
return (
<div className="h-[600px] overflow-auto rounded-lg border bg-gray-50">
<object
data={previewUrl || ""}
type="application/pdf"
className="h-full w-full rounded-lg"
title="PDF Viewer"
>
<iframe src={previewUrl || ""} className="h-full w-full rounded-lg" title="PDF Viewer Fallback">
<div className="flex h-full flex-col items-center justify-center p-8">
<FileText className="mb-4 h-16 w-16 text-gray-400" />
<p className="mb-2 text-lg font-medium">PDF를 </p>
<p className="mb-4 text-center text-sm text-gray-600">
PDF . .
</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
PDF
</Button>
</div>
</iframe>
</object>
</div>
);
}
// Office 문서 - 모든 Office 문서는 다운로드 권장
if (
[
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"hwp",
"hwpx",
"hwpml",
"hcdt",
"hpt",
"pages",
"numbers",
"keynote",
].includes(fileExt)
) {
// Office 문서 안내 메시지 표시
return (
<div className="relative flex h-96 flex-col items-center justify-center overflow-auto rounded-lg border bg-gradient-to-br from-blue-50 to-indigo-50 p-8">
<FileText className="mb-6 h-20 w-20 text-blue-500" />
<h3 className="mb-2 text-xl font-semibold text-gray-800">Office </h3>
<p className="mb-6 max-w-md text-center text-sm text-gray-600">
{fileExt === "docx" || fileExt === "doc"
? "Word 문서"
: fileExt === "xlsx" || fileExt === "xls"
? "Excel 문서"
: fileExt === "pptx" || fileExt === "ppt"
? "PowerPoint 문서"
: "Office 문서"}
.
<br />
.
</p>
<div className="flex gap-3">
<Button onClick={() => onDownload?.(file)} size="lg" className="shadow-md">
<Download className="mr-2 h-5 w-5" />
</Button>
</div>
</div>
);
}
// 비디오 파일
if (["mp4", "webm", "ogg"].includes(fileExt)) {
return (
<div className="flex items-center justify-center">
<video controls className="max-h-96 w-full" onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}>
<source src={previewUrl || ""} type={`video/${fileExt}`} />
</video>
</div>
);
}
// 오디오 파일
if (["mp3", "wav", "ogg"].includes(fileExt)) {
return (
<div className="flex h-96 flex-col items-center justify-center">
<div className="mb-6 flex h-32 w-32 items-center justify-center rounded-full bg-gray-100">
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<audio controls className="w-full max-w-md" onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}>
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
</audio>
</div>
);
}
// 기타 파일 타입
return (
<div className="flex h-96 flex-col items-center justify-center">
<FileText className="mb-4 h-16 w-16 text-gray-400" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="mb-4 text-center text-sm">{file.fileExt.toUpperCase()} .</p>
<Button variant="outline" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<DialogTitle className="truncate text-lg font-semibold">{file.realFileName}</DialogTitle>
<Badge variant="secondary" className="text-xs">
{file.fileExt.toUpperCase()}
</Badge>
</div>
</div>
<DialogDescription>
: {formatFileSize(file.fileSize || file.size || 0)} | : {file.fileExt.toUpperCase()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">{renderPreview()}</div>
{/* 파일 정보 및 액션 버튼 */}
<div className="mt-2 flex items-center space-x-4 text-sm text-gray-500">
<span>: {formatFileSize(file.fileSize || file.size || 0)}</span>
{(file.uploadedAt || file.regdate) && (
<span>: {new Date(file.uploadedAt || file.regdate || "").toLocaleString()}</span>
)}
</div>
<div className="flex justify-end space-x-2 border-t pt-4">
<Button variant="outline" size="sm" onClick={() => onDownload?.(file)}>
<Download className="mr-2 h-4 w-4" />
</Button>
{onDelete && (
<Button
variant="outline"
size="sm"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => onDelete(file)}
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
)}
<Button variant="outline" size="sm" onClick={onClose}>
<X className="mr-2 h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,56 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2FileUploadDefinition } from "./index";
import { FileUploadComponent } from "./FileUploadComponent";
/**
* V2 FileUpload
*
*/
export class V2FileUploadRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2FileUploadDefinition;
render(): React.ReactElement {
return <FileUploadComponent {...this.props} renderer={this} />;
}
/**
*
*/
// file 타입 특화 속성 처리
protected getFileUploadProps() {
const baseProps = this.getWebTypeProps();
// file 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 file 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
V2FileUploadRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
V2FileUploadRenderer.enableHotReload();
}

View File

@ -0,0 +1,62 @@
"use client";
import { FileUploadConfig } from "./types";
/**
* V2 FileUpload
*/
export const V2FileUploadDefaultConfig: FileUploadConfig = {
placeholder: "파일을 선택하세요",
multiple: true,
accept: "*/*",
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 10,
// 공통 기본값
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
// V2 추가 설정 기본값
showPreview: true,
showFileList: true,
showFileSize: true,
allowDelete: true,
allowDownload: true,
};
/**
* V2 FileUpload
*
*/
export const V2FileUploadConfigSchema = {
placeholder: { type: "string", default: "파일을 선택하세요" },
multiple: { type: "boolean", default: true },
accept: { type: "string", default: "*/*" },
maxSize: { type: "number", default: 10 * 1024 * 1024 },
maxFiles: { type: "number", default: 10 },
// 공통 스키마
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default"
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
},
// V2 추가 설정 스키마
showPreview: { type: "boolean", default: true },
showFileList: { type: "boolean", default: true },
showFileSize: { type: "boolean", default: true },
allowDelete: { type: "boolean", default: true },
allowDownload: { type: "boolean", default: true },
};

View File

@ -0,0 +1,46 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { FileUploadComponent } from "./FileUploadComponent";
import { FileUploadConfigPanel } from "./FileUploadConfigPanel";
import { FileUploadConfig } from "./types";
/**
* V2 FileUpload
* V2
*/
export const V2FileUploadDefinition = createComponentDefinition({
id: "v2-file-upload",
name: "파일 업로드",
nameEng: "V2 FileUpload Component",
description: "V2 파일 업로드를 위한 파일 선택 컴포넌트",
category: ComponentCategory.INPUT,
webType: "file",
component: FileUploadComponent,
defaultConfig: {
placeholder: "파일을 선택하세요",
multiple: true,
accept: "*/*",
maxSize: 10 * 1024 * 1024, // 10MB
},
defaultSize: { width: 350, height: 240 },
configPanel: FileUploadConfigPanel,
icon: "Upload",
tags: ["file", "upload", "attachment", "v2"],
version: "2.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/v2-file-upload",
});
// 타입 내보내기
export type { FileUploadConfig, FileInfo, FileUploadProps, FileUploadStatus, FileUploadResponse } from "./types";
// 컴포넌트 내보내기
export { FileUploadComponent } from "./FileUploadComponent";
export { V2FileUploadRenderer } from "./V2FileUploadRenderer";
// 기본 export
export default V2FileUploadDefinition;

View File

@ -0,0 +1,114 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* (AttachedFileInfo와 )
*/
export interface FileInfo {
// AttachedFileInfo 기본 속성들
objid: string;
savedFileName: string;
realFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
docType?: string;
docTypeName?: string;
targetObjid: string;
parentTargetObjid?: string;
companyCode?: string;
writer?: string;
regdate?: string;
status?: string;
// 추가 호환성 속성들
path?: string; // filePath와 동일
name?: string; // realFileName과 동일
id?: string; // objid와 동일
size?: number; // fileSize와 동일
type?: string; // docType과 동일
uploadedAt?: string; // regdate와 동일
_file?: File; // 로컬 파일 객체 (업로드 전)
// 대표 이미지 설정
isRepresentative?: boolean; // 대표 이미지로 설정 여부
}
/**
* V2 FileUpload
*/
export interface FileUploadConfig extends ComponentConfig {
// file 관련 설정
placeholder?: string;
multiple?: boolean;
accept?: string;
maxSize?: number; // bytes
maxFiles?: number; // 최대 파일 수
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
helperText?: string;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// V2 추가 설정
showPreview?: boolean; // 미리보기 표시 여부
showFileList?: boolean; // 파일 목록 표시 여부
showFileSize?: boolean; // 파일 크기 표시 여부
allowDelete?: boolean; // 삭제 허용 여부
allowDownload?: boolean; // 다운로드 허용 여부
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
onFileUpload?: (files: FileInfo[]) => void;
onFileDelete?: (fileId: string) => void;
onFileDownload?: (file: FileInfo) => void;
}
/**
* V2 FileUpload Props
*/
export interface FileUploadProps {
id?: string;
name?: string;
value?: any;
config?: FileUploadConfig;
className?: string;
style?: React.CSSProperties;
// 파일 관련
uploadedFiles?: FileInfo[];
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
onFileUpload?: (files: FileInfo[]) => void;
onFileDelete?: (fileId: string) => void;
onFileDownload?: (file: FileInfo) => void;
}
/**
*
*/
export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
/**
*
*/
export interface FileUploadResponse {
success: boolean;
data?: FileInfo[];
files?: FileInfo[];
message?: string;
error?: string;
}

View File

@ -25,15 +25,29 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
// 값 변경 핸들러 // 값 변경 핸들러
const handleChange = (value: any) => { const handleChange = (value: any) => {
console.log("🔄 [V2InputRenderer] handleChange 호출:", {
columnName,
value,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
if (isInteractive && onFormDataChange && columnName) { if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value); onFormDataChange(columnName, value);
} else {
console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", {
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
columnName,
});
} }
}; };
// 라벨: style.labelText 우선, 없으면 component.label 사용 // 라벨: style.labelText 우선, 없으면 component.label 사용
// style.labelDisplay가 false면 라벨 숨김 // 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로)
const style = component.style || {}; const style = component.style || {};
const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label); const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay;
// labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김)
const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined;
return ( return (
<V2Input <V2Input

View File

@ -3,65 +3,92 @@
import React from "react"; import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2MediaDefinition } from "./index"; import { V2MediaDefinition } from "./index";
import { V2Media } from "@/components/v2/V2Media"; import FileUploadComponent from "../file-upload/FileUploadComponent";
/** /**
* V2Media * V2Media
* , , , * FileUploadComponent를
* *
*/ */
export class V2MediaRenderer extends AutoRegisteringComponentRenderer { export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2MediaDefinition; static componentDefinition = V2MediaDefinition;
render(): React.ReactElement { render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; const {
component,
formData,
onFormDataChange,
isDesignMode,
isSelected,
isInteractive,
onUpdate,
...restProps
} = this.props;
// 컴포넌트 설정 추출 // 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {}; const config = component.componentConfig || component.config || {};
const columnName = component.columnName; const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName; const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기 // 🔍 디버깅: 컴포넌트 정보 로깅
const currentValue = formData?.[columnName] ?? component.value ?? ""; console.log("📸 [V2MediaRenderer] 컴포넌트 정보:", {
componentId: component.id,
columnName: columnName,
tableName: tableName,
formDataId: formData?.id,
formDataTableName: formData?.tableName,
});
// 값 변경 핸들러 // V1 file-upload에서 사용하는 형태로 설정 매핑
const handleChange = (value: any) => {
if (isInteractive && onFormDataChange && columnName) {
onFormDataChange(columnName, value);
}
};
// V1 file-upload, image-widget에서 넘어온 설정 매핑
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType); const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
// maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용) // maxSize: MB → bytes 변환
const maxSizeBytes = config.maxSize const maxSizeBytes = config.maxSize
? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024) ? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024)
: 10 * 1024 * 1024; // 기본 10MB : 10 * 1024 * 1024; // 기본 10MB
// 레거시 컴포넌트 설정 형태로 변환
const legacyComponentConfig = {
maxFileCount: config.multiple ? 10 : 1,
maxFileSize: maxSizeBytes,
accept: config.accept || this.getDefaultAccept(mediaType),
docType: config.docType || "DOCUMENT",
docTypeName: config.docTypeName || "일반 문서",
showFileList: config.showFileList ?? true,
dragDrop: config.dragDrop ?? true,
};
// 레거시 컴포넌트 형태로 변환
const legacyComponent = {
...component,
id: component.id,
columnName: columnName,
tableName: tableName,
componentConfig: legacyComponentConfig,
};
// onFormDataChange 래퍼: FileUploadComponent는 (fieldName, value) 형태로 호출함
const handleFormDataChange = (fieldName: string, value: any) => {
if (onFormDataChange) {
// 메타 데이터(__로 시작하는 키)는 건너뛰기
if (!fieldName.startsWith("__")) {
console.log("📸 [V2MediaRenderer] formData 업데이트:", { fieldName, value });
onFormDataChange(fieldName, value);
}
}
};
return ( return (
<V2Media <FileUploadComponent
id={component.id} component={legacyComponent}
label={component.label} componentConfig={legacyComponentConfig}
required={component.required} componentStyle={component.style || {}}
readonly={config.readonly || component.readonly} className=""
disabled={config.disabled || component.disabled} isInteractive={isInteractive ?? true}
value={currentValue} isDesignMode={isDesignMode ?? false}
onChange={handleChange} formData={formData || {}}
config={{ onFormDataChange={handleFormDataChange}
type: mediaType, onUpdate={onUpdate}
multiple: config.multiple ?? false,
preview: config.preview ?? true,
maxSize: maxSizeBytes,
accept: config.accept || this.getDefaultAccept(mediaType),
uploadEndpoint: config.uploadEndpoint || "/api/upload",
}}
style={component.style}
size={component.size}
formData={formData}
columnName={columnName}
tableName={tableName}
{...restProps}
/> />
); );
} }

View File

@ -20,8 +20,26 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
const columnName = component.columnName; const columnName = component.columnName;
const tableName = component.tableName || this.props.tableName; const tableName = component.tableName || this.props.tableName;
// formData에서 현재 값 가져오기 // formData에서 현재 값 가져오기 (기본값 지원)
const currentValue = formData?.[columnName] ?? component.value ?? ""; const defaultValue = config.defaultValue || "";
let currentValue = formData?.[columnName] ?? component.value ?? "";
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
if (
(currentValue === "" || currentValue === undefined || currentValue === null) &&
defaultValue &&
isInteractive &&
onFormDataChange &&
columnName
) {
// 초기 렌더링 시 기본값을 formData에 설정
setTimeout(() => {
if (!formData?.[columnName]) {
onFormDataChange(columnName, defaultValue);
}
}, 0);
currentValue = defaultValue;
}
// 값 변경 핸들러 // 값 변경 핸들러
const handleChange = (value: any) => { const handleChange = (value: any) => {
@ -30,6 +48,25 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
} }
}; };
// 🔧 DynamicComponentRenderer에서 전달한 style/size를 우선 사용 (height 포함)
// restProps.style에 mergedStyle(height 변환됨)이 있고, restProps.size에도 size가 있음
const effectiveStyle = restProps.style || component.style;
const effectiveSize = restProps.size || component.size;
// 🔍 디버깅: props 확인 (warn으로 변경하여 캡처되도록)
console.warn("🔍 [V2SelectRenderer] props 디버깅:", {
componentId: component.id,
"component.style": component.style,
"component.size": component.size,
"restProps.style": restProps.style,
"restProps.size": restProps.size,
effectiveStyle,
effectiveSize,
});
// 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함)
const { style: _style, size: _size, ...restPropsClean } = restProps as any;
return ( return (
<V2Select <V2Select
id={component.id} id={component.id}
@ -51,12 +88,12 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
entityLabelColumn: config.entityLabelColumn, entityLabelColumn: config.entityLabelColumn,
entityValueColumn: config.entityValueColumn, entityValueColumn: config.entityValueColumn,
}} }}
style={component.style}
size={component.size}
tableName={tableName} tableName={tableName}
columnName={columnName} columnName={columnName}
formData={formData} formData={formData}
{...restProps} {...restPropsClean}
style={effectiveStyle}
size={effectiveSize}
/> />
); );
} }

View File

@ -41,7 +41,10 @@ export const V2SelectDefinition = createComponentDefinition({
{ value: "dropdown", label: "드롭다운" }, { value: "dropdown", label: "드롭다운" },
{ value: "combobox", label: "콤보박스 (검색)" }, { value: "combobox", label: "콤보박스 (검색)" },
{ value: "radio", label: "라디오 버튼" }, { value: "radio", label: "라디오 버튼" },
{ value: "checkbox", label: "체크박스" }, { value: "check", label: "체크박스" },
{ value: "tag", label: "태그" },
{ value: "toggle", label: "토글" },
{ value: "swap", label: "스왑 (좌우 이동)" },
], ],
}, },
source: { source: {

View File

@ -1676,7 +1676,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 커스텀 모달 화면 열기 // 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || ""; const rightTableName = componentConfig.rightPanel?.tableName || "";
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) // Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드)
let primaryKeyName = "id"; let primaryKeyName = "id";
let primaryKeyValue: any; let primaryKeyValue: any;
@ -1686,11 +1686,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} else if (item.ID !== undefined && item.ID !== null) { } else if (item.ID !== undefined && item.ID !== null) {
primaryKeyName = "ID"; primaryKeyName = "ID";
primaryKeyValue = item.ID; primaryKeyValue = item.ID;
} else if (item.user_id !== undefined && item.user_id !== null) {
// user_info 테이블 등 user_id를 Primary Key로 사용하는 경우
primaryKeyName = "user_id";
primaryKeyValue = item.user_id;
} else { } else {
// 첫 번째 필드를 Primary Key로 간주 // 테이블명_id 패턴 확인 (예: dept_id, item_id 등)
const firstKey = Object.keys(item)[0]; const tableIdKey = rightTableName ? `${rightTableName.replace(/_info$/, "")}_id` : "";
primaryKeyName = firstKey; if (tableIdKey && item[tableIdKey] !== undefined && item[tableIdKey] !== null) {
primaryKeyValue = item[firstKey]; primaryKeyName = tableIdKey;
primaryKeyValue = item[tableIdKey];
} else {
// 마지막으로 첫 번째 필드를 Primary Key로 간주
const firstKey = Object.keys(item)[0];
primaryKeyName = firstKey;
primaryKeyValue = item[firstKey];
}
} }
console.log("✅ 수정 모달 열기:", { console.log("✅ 수정 모달 열기:", {

View File

@ -85,7 +85,7 @@ interface GroupedData {
// 캐시 및 유틸리티 // 캐시 및 유틸리티
// ======================================== // ========================================
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>(); const tableColumnCache = new Map<string, { columns: any[]; inputTypes?: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>(); const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
@ -460,6 +460,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]); const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터 // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
let result = data; let result = data;
@ -473,14 +476,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
} }
// 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) // 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함)
if (Object.keys(headerFilters).length > 0) { if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => { result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => { return Object.entries(headerFilters).every(([columnName, values]) => {
if (values.size === 0) return true; if (values.size === 0) return true;
// 여러 가능한 컬럼명 시도 // joinColumnMapping을 사용하여 조인된 컬럼명 확인
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; const mappedColumnName = joinColumnMapping[columnName] || columnName;
// 여러 가능한 컬럼명 시도 (mappedColumnName 우선)
const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
return values.has(cellStr); return values.has(cellStr);
@ -541,7 +547,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
return result; return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
@ -554,7 +560,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [tableLabel, setTableLabel] = useState<string>(""); const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
const [columnMeta, setColumnMeta] = useState< const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string }> Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
>({}); >({});
@ -1005,7 +1010,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// unregisterTable 함수는 의존성이 없어 안정적임 // unregisterTable 함수는 의존성이 없어 안정적임
]); ]);
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
useEffect(() => { useEffect(() => {
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return; if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
@ -1019,12 +1024,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setSortColumn(column); setSortColumn(column);
setSortDirection(direction); setSortDirection(direction);
hasInitializedSort.current = true; hasInitializedSort.current = true;
return;
} }
} catch (error) { } catch (error) {
// 정렬 상태 복원 실패 // 정렬 상태 복원 실패
} }
} }
}, [tableConfig.selectedTable, userId]);
// localStorage에 저장된 정렬이 없으면 defaultSort 설정 적용
if (tableConfig.defaultSort?.columnName) {
setSortColumn(tableConfig.defaultSort.columnName);
setSortDirection(tableConfig.defaultSort.direction || "asc");
hasInitializedSort.current = true;
}
}, [tableConfig.selectedTable, tableConfig.defaultSort, userId]);
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기 // 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
useEffect(() => { useEffect(() => {
@ -1465,8 +1478,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try { try {
const page = tableConfig.pagination?.currentPage || currentPage; const page = tableConfig.pagination?.currentPage || currentPage;
const pageSize = localPageSize; const pageSize = localPageSize;
const sortBy = sortColumn || undefined; // 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
const sortOrder = sortDirection; const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection);
const search = searchTerm || undefined; const search = searchTerm || undefined;
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
@ -4047,18 +4061,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const inputType = meta?.inputType || column.inputType; const inputType = meta?.inputType || column.inputType;
// 🖼️ 이미지 타입: 작은 썸네일 표시 // 🖼️ 이미지 타입: 작은 썸네일 표시
if (inputType === "image" && value && typeof value === "string") { if (inputType === "image" && value) {
const imageUrl = getFullImageUrl(value); // value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
// 🔑 download 대신 preview 사용 (공개 접근 허용)
const strValue = String(value);
const isObjid = /^\d+$/.test(strValue);
const imageUrl = isObjid
? `/api/files/preview/${strValue}`
: getFullImageUrl(strValue);
return ( return (
<img <div className="flex justify-center">
src={imageUrl} <img
alt="이미지" src={imageUrl}
className="h-10 w-10 rounded object-cover" alt="이미지"
onError={(e) => { className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
e.currentTarget.src = style={{ maxWidth: "40px", maxHeight: "40px" }}
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E"; onClick={(e) => {
}} e.stopPropagation();
/> // 이미지 클릭 시 새 탭에서 크게 보기
window.open(imageUrl, "_blank");
}}
onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘 표시
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
); );
} }

View File

@ -319,7 +319,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const handleChange = (key: keyof TableListConfig, value: any) => { const handleChange = (key: keyof TableListConfig, value: any) => {
// 기존 config와 병합하여 전달 (다른 속성 손실 방지) // 기존 config와 병합하여 전달 (다른 속성 손실 방지)
onChange({ ...config, [key]: value }); const newConfig = { ...config, [key]: value };
console.log("📊 TableListConfigPanel handleChange:", { key, value, newConfig });
onChange(newConfig);
}; };
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => { const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {
@ -884,6 +886,67 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 기본 정렬 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="defaultSortColumn" className="text-xs">
</Label>
<select
id="defaultSortColumn"
value={config.defaultSort?.columnName || ""}
onChange={(e) => {
if (e.target.value) {
handleChange("defaultSort", {
columnName: e.target.value,
direction: config.defaultSort?.direction || "asc",
});
} else {
handleChange("defaultSort", undefined);
}
}}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value=""> </option>
{availableColumns.map((col) => (
<option key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</option>
))}
</select>
</div>
{config.defaultSort?.columnName && (
<div className="space-y-1">
<Label htmlFor="defaultSortDirection" className="text-xs">
</Label>
<select
id="defaultSortDirection"
value={config.defaultSort?.direction || "asc"}
onChange={(e) =>
handleChange("defaultSort", {
...config.defaultSort,
columnName: config.defaultSort?.columnName || "",
direction: e.target.value as "asc" | "desc",
})
}
className="h-8 w-full rounded-md border px-2 text-xs"
>
<option value="asc"> (AZ, 19)</option>
<option value="desc"> (ZA, 91)</option>
</select>
</div>
)}
</div>
</div>
{/* 가로 스크롤 및 컬럼 고정 */} {/* 가로 스크롤 및 컬럼 고정 */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>

View File

@ -278,6 +278,12 @@ export interface TableListConfig extends ComponentConfig {
autoLoad: boolean; autoLoad: boolean;
refreshInterval?: number; // 초 단위 refreshInterval?: number; // 초 단위
// 🆕 기본 정렬 설정
defaultSort?: {
columnName: string; // 정렬할 컬럼명
direction: "asc" | "desc"; // 정렬 방향
};
// 🆕 툴바 버튼 표시 설정 // 🆕 툴바 버튼 표시 설정
toolbar?: ToolbarConfig; toolbar?: ToolbarConfig;

View File

@ -475,9 +475,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
filterValue = filterValue.join("|"); filterValue = filterValue.join("|");
} }
// 🔧 filterType에 따라 operator 설정
// - "select" 유형: 정확히 일치 (equals)
// - "text" 유형: 부분 일치 (contains)
// - "date", "number": 각각 적절한 처리
let operator = "contains"; // 기본값
if (filter.filterType === "select") {
operator = "equals"; // 선택 필터는 정확히 일치
} else if (filter.filterType === "number") {
operator = "equals"; // 숫자도 정확히 일치
}
return { return {
...filter, ...filter,
value: filterValue || "", value: filterValue || "",
operator, // operator 추가
}; };
}) })
.filter((f) => { .filter((f) => {

View File

@ -84,23 +84,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
return ( return (
<div style={componentStyle} className={className} {...domProps}> <div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */} {/* v2-text-display는 텍스트 표시 전용이므로 별도 라벨 불필요 */}
{component.label && (component.style?.labelDisplay ?? true) && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#64748b",
fontWeight: "500",
}}
>
{component.label}
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
</label>
)}
<div style={textStyle} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd}> <div style={textStyle} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd}>
{componentConfig.text || "텍스트를 입력하세요"} {componentConfig.text || "텍스트를 입력하세요"}
</div> </div>

View File

@ -5,32 +5,10 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
SelectContent, import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
SelectItem, import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
@ -52,10 +30,7 @@ interface ColumnInfo {
displayName: string; displayName: string;
} }
export function TimelineSchedulerConfigPanel({ export function TimelineSchedulerConfigPanel({ config, onChange }: TimelineSchedulerConfigPanelProps) {
config,
onChange,
}: TimelineSchedulerConfigPanelProps) {
const [tables, setTables] = useState<TableInfo[]>([]); const [tables, setTables] = useState<TableInfo[]>([]);
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]); const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]); const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
@ -74,7 +49,7 @@ export function TimelineSchedulerConfigPanel({
tableList.map((t: any) => ({ tableList.map((t: any) => ({
tableName: t.table_name || t.tableName, tableName: t.table_name || t.tableName,
displayName: t.display_name || t.displayName || t.table_name || t.tableName, displayName: t.display_name || t.displayName || t.table_name || t.tableName,
})) })),
); );
} }
} catch (err) { } catch (err) {
@ -100,7 +75,7 @@ export function TimelineSchedulerConfigPanel({
columns.map((col: any) => ({ columns.map((col: any) => ({
columnName: col.column_name || col.columnName, columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName, displayName: col.display_name || col.displayName || col.column_name || col.columnName,
})) })),
); );
} }
} catch (err) { } catch (err) {
@ -125,7 +100,7 @@ export function TimelineSchedulerConfigPanel({
columns.map((col: any) => ({ columns.map((col: any) => ({
columnName: col.column_name || col.columnName, columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName, displayName: col.display_name || col.displayName || col.column_name || col.columnName,
})) })),
); );
} }
} catch (err) { } catch (err) {
@ -168,11 +143,9 @@ export function TimelineSchedulerConfigPanel({
<Accordion type="multiple" defaultValue={["source", "resource", "display"]}> <Accordion type="multiple" defaultValue={["source", "resource", "display"]}>
{/* 소스 데이터 설정 (스케줄 생성 기준) */} {/* 소스 데이터 설정 (스케줄 생성 기준) */}
<AccordionItem value="source"> <AccordionItem value="source">
<AccordionTrigger className="text-sm font-medium"> <AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2"> <AccordionContent className="space-y-3 pt-2">
<p className="text-[10px] text-muted-foreground mb-2"> <p className="text-muted-foreground mb-2 text-[10px]">
(저장: schedule_mng) (저장: schedule_mng)
</p> </p>
@ -208,20 +181,14 @@ export function TimelineSchedulerConfigPanel({
className="h-8 w-full justify-between text-xs" className="h-8 w-full justify-between text-xs"
disabled={loading} disabled={loading}
> >
{config.sourceConfig?.tableName ? ( {config.sourceConfig?.tableName
tables.find((t) => t.tableName === config.sourceConfig?.tableName) ? tables.find((t) => t.tableName === config.sourceConfig?.tableName)?.displayName ||
?.displayName || config.sourceConfig.tableName config.sourceConfig.tableName
) : ( : "소스 테이블 선택..."}
"소스 테이블 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command <Command
filter={(value, search) => { filter={(value, search) => {
const lowerSearch = search.toLowerCase(); const lowerSearch = search.toLowerCase();
@ -233,9 +200,7 @@ export function TimelineSchedulerConfigPanel({
> >
<CommandInput placeholder="테이블 검색..." className="text-xs" /> <CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList> <CommandList>
<CommandEmpty className="text-xs"> <CommandEmpty className="text-xs"> .</CommandEmpty>
.
</CommandEmpty>
<CommandGroup> <CommandGroup>
{tables.map((table) => ( {tables.map((table) => (
<CommandItem <CommandItem
@ -250,16 +215,12 @@ export function TimelineSchedulerConfigPanel({
<Check <Check
className={cn( className={cn(
"mr-2 h-3 w-3", "mr-2 h-3 w-3",
config.sourceConfig?.tableName === table.tableName config.sourceConfig?.tableName === table.tableName ? "opacity-100" : "opacity-0",
? "opacity-100"
: "opacity-0"
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span>{table.displayName}</span> <span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground"> <span className="text-muted-foreground text-[10px]">{table.tableName}</span>
{table.tableName}
</span>
</div> </div>
</CommandItem> </CommandItem>
))} ))}
@ -272,11 +233,11 @@ export function TimelineSchedulerConfigPanel({
{/* 소스 필드 매핑 */} {/* 소스 필드 매핑 */}
{config.sourceConfig?.tableName && ( {config.sourceConfig?.tableName && (
<div className="space-y-2 mt-2"> <div className="mt-2 space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{/* 기준일 필드 */} {/* 기준일 필드 */}
<div className="space-y-1 col-span-2"> <div className="col-span-2 space-y-1">
<Label className="text-[10px]"> (/) *</Label> <Label className="text-[10px]"> (/) *</Label>
<Select <Select
value={config.sourceConfig?.dueDateField || ""} value={config.sourceConfig?.dueDateField || ""}
@ -293,9 +254,7 @@ export function TimelineSchedulerConfigPanel({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[10px] text-muted-foreground"> <p className="text-muted-foreground text-[10px]"> </p>
</p>
</div> </div>
{/* 수량 필드 */} {/* 수량 필드 */}
@ -339,7 +298,7 @@ export function TimelineSchedulerConfigPanel({
</div> </div>
{/* 그룹명 필드 */} {/* 그룹명 필드 */}
<div className="space-y-1 col-span-2"> <div className="col-span-2 space-y-1">
<Label className="text-[10px]"> ()</Label> <Label className="text-[10px]"> ()</Label>
<Select <Select
value={config.sourceConfig?.groupNameField || ""} value={config.sourceConfig?.groupNameField || ""}
@ -365,21 +324,14 @@ export function TimelineSchedulerConfigPanel({
{/* 리소스 설정 */} {/* 리소스 설정 */}
<AccordionItem value="resource"> <AccordionItem value="resource">
<AccordionTrigger className="text-sm font-medium"> <AccordionTrigger className="text-sm font-medium"> (/)</AccordionTrigger>
(/)
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2"> <AccordionContent className="space-y-3 pt-2">
<p className="text-[10px] text-muted-foreground mb-2"> <p className="text-muted-foreground mb-2 text-[10px]"> Y축에 (, )</p>
Y축에 (, )
</p>
{/* 리소스 테이블 선택 */} {/* 리소스 테이블 선택 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Popover <Popover open={resourceTableSelectOpen} onOpenChange={setResourceTableSelectOpen}>
open={resourceTableSelectOpen}
onOpenChange={setResourceTableSelectOpen}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@ -388,20 +340,13 @@ export function TimelineSchedulerConfigPanel({
className="h-8 w-full justify-between text-xs" className="h-8 w-full justify-between text-xs"
disabled={loading} disabled={loading}
> >
{config.resourceTable ? ( {config.resourceTable
tables.find((t) => t.tableName === config.resourceTable) ? tables.find((t) => t.tableName === config.resourceTable)?.displayName || config.resourceTable
?.displayName || config.resourceTable : "리소스 테이블 선택..."}
) : (
"리소스 테이블 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command <Command
filter={(value, search) => { filter={(value, search) => {
const lowerSearch = search.toLowerCase(); const lowerSearch = search.toLowerCase();
@ -413,9 +358,7 @@ export function TimelineSchedulerConfigPanel({
> >
<CommandInput placeholder="테이블 검색..." className="text-xs" /> <CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList> <CommandList>
<CommandEmpty className="text-xs"> <CommandEmpty className="text-xs"> .</CommandEmpty>
.
</CommandEmpty>
<CommandGroup> <CommandGroup>
{tables.map((table) => ( {tables.map((table) => (
<CommandItem <CommandItem
@ -430,16 +373,12 @@ export function TimelineSchedulerConfigPanel({
<Check <Check
className={cn( className={cn(
"mr-2 h-3 w-3", "mr-2 h-3 w-3",
config.resourceTable === table.tableName config.resourceTable === table.tableName ? "opacity-100" : "opacity-0",
? "opacity-100"
: "opacity-0"
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span>{table.displayName}</span> <span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground"> <span className="text-muted-foreground text-[10px]">{table.tableName}</span>
{table.tableName}
</span>
</div> </div>
</CommandItem> </CommandItem>
))} ))}
@ -452,7 +391,7 @@ export function TimelineSchedulerConfigPanel({
{/* 리소스 필드 매핑 */} {/* 리소스 필드 매핑 */}
{config.resourceTable && ( {config.resourceTable && (
<div className="space-y-2 mt-2"> <div className="mt-2 space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{/* ID 필드 */} {/* ID 필드 */}
@ -502,18 +441,14 @@ export function TimelineSchedulerConfigPanel({
{/* 표시 설정 */} {/* 표시 설정 */}
<AccordionItem value="display"> <AccordionItem value="display">
<AccordionTrigger className="text-sm font-medium"> <AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2"> <AccordionContent className="space-y-3 pt-2">
{/* 기본 줌 레벨 */} {/* 기본 줌 레벨 */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Select <Select
value={config.defaultZoomLevel || "day"} value={config.defaultZoomLevel || "day"}
onValueChange={(v) => onValueChange={(v) => updateConfig({ defaultZoomLevel: v as any })}
updateConfig({ defaultZoomLevel: v as any })
}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue /> <SelectValue />
@ -534,9 +469,7 @@ export function TimelineSchedulerConfigPanel({
<Input <Input
type="number" type="number"
value={config.height || 500} value={config.height || 500}
onChange={(e) => onChange={(e) => updateConfig({ height: parseInt(e.target.value) || 500 })}
updateConfig({ height: parseInt(e.target.value) || 500 })
}
className="h-8 text-xs" className="h-8 text-xs"
/> />
</div> </div>
@ -547,9 +480,7 @@ export function TimelineSchedulerConfigPanel({
<Input <Input
type="number" type="number"
value={config.rowHeight || 50} value={config.rowHeight || 50}
onChange={(e) => onChange={(e) => updateConfig({ rowHeight: parseInt(e.target.value) || 50 })}
updateConfig({ rowHeight: parseInt(e.target.value) || 50 })
}
className="h-8 text-xs" className="h-8 text-xs"
/> />
</div> </div>
@ -558,26 +489,17 @@ export function TimelineSchedulerConfigPanel({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Switch <Switch checked={config.editable ?? true} onCheckedChange={(v) => updateConfig({ editable: v })} />
checked={config.editable ?? true}
onCheckedChange={(v) => updateConfig({ editable: v })}
/>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Switch <Switch checked={config.draggable ?? true} onCheckedChange={(v) => updateConfig({ draggable: v })} />
checked={config.draggable ?? true}
onCheckedChange={(v) => updateConfig({ draggable: v })}
/>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Switch <Switch checked={config.resizable ?? true} onCheckedChange={(v) => updateConfig({ resizable: v })} />
checked={config.resizable ?? true}
onCheckedChange={(v) => updateConfig({ resizable: v })}
/>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -3,13 +3,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
import { import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types";
TimelineSchedulerConfig,
ScheduleItem,
Resource,
ZoomLevel,
UseTimelineDataResult,
} from "../types";
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config"; import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
// schedule_mng 테이블 고정 (공통 스케줄 테이블) // schedule_mng 테이블 고정 (공통 스케줄 테이블)
@ -37,16 +31,14 @@ const addDays = (date: Date, days: number): Date => {
export function useTimelineData( export function useTimelineData(
config: TimelineSchedulerConfig, config: TimelineSchedulerConfig,
externalSchedules?: ScheduleItem[], externalSchedules?: ScheduleItem[],
externalResources?: Resource[] externalResources?: Resource[],
): UseTimelineDataResult { ): UseTimelineDataResult {
// 상태 // 상태
const [schedules, setSchedules] = useState<ScheduleItem[]>([]); const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>( const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(config.defaultZoomLevel || "day");
config.defaultZoomLevel || "day"
);
const [viewStartDate, setViewStartDate] = useState<Date>(() => { const [viewStartDate, setViewStartDate] = useState<Date>(() => {
if (config.initialDate) { if (config.initialDate) {
return new Date(config.initialDate); return new Date(config.initialDate);
@ -69,9 +61,7 @@ export function useTimelineData(
}, [viewStartDate, zoomLevel]); }, [viewStartDate, zoomLevel]);
// 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용 // 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용
const tableName = config.useCustomTable && config.customTableName const tableName = config.useCustomTable && config.customTableName ? config.customTableName : SCHEDULE_TABLE;
? config.customTableName
: SCHEDULE_TABLE;
const resourceTableName = config.resourceTable; const resourceTableName = config.resourceTable;
@ -134,17 +124,13 @@ export function useTimelineData(
sourceKeys: currentSourceKeys, sourceKeys: currentSourceKeys,
}); });
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
`/table-management/tables/${tableName}/data`, page: 1,
{ size: 10000,
page: 1, autoFilter: true,
size: 10000, });
autoFilter: true,
}
);
const responseData = const responseData = response.data?.data?.data || response.data?.data || [];
response.data?.data?.data || response.data?.data || [];
let rawData = Array.isArray(responseData) ? responseData : []; let rawData = Array.isArray(responseData) ? responseData : [];
// 클라이언트 측 필터링 적용 (schedule_mng 테이블인 경우) // 클라이언트 측 필터링 적용 (schedule_mng 테이블인 경우)
@ -156,9 +142,7 @@ export function useTimelineData(
// 선택된 품목 필터 (source_group_key 기준) // 선택된 품목 필터 (source_group_key 기준)
if (currentSourceKeys.length > 0) { if (currentSourceKeys.length > 0) {
rawData = rawData.filter((row: any) => rawData = rawData.filter((row: any) => currentSourceKeys.includes(row.source_group_key));
currentSourceKeys.includes(row.source_group_key)
);
} }
console.log("[useTimelineData] 필터링 후 스케줄:", rawData.length, "건"); console.log("[useTimelineData] 필터링 후 스케줄:", rawData.length, "건");
@ -194,9 +178,7 @@ export function useTimelineData(
title: String(row[effectiveMapping.title] || ""), title: String(row[effectiveMapping.title] || ""),
startDate: row[effectiveMapping.startDate] || "", startDate: row[effectiveMapping.startDate] || "",
endDate: row[effectiveMapping.endDate] || "", endDate: row[effectiveMapping.endDate] || "",
status: effectiveMapping.status status: effectiveMapping.status ? row[effectiveMapping.status] || "planned" : "planned",
? row[effectiveMapping.status] || "planned"
: "planned",
progress, progress,
color: fieldMapping.color ? row[fieldMapping.color] : undefined, color: fieldMapping.color ? row[fieldMapping.color] : undefined,
data: row, data: row,
@ -228,26 +210,20 @@ export function useTimelineData(
} }
try { try {
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${resourceTableName}/data`, {
`/table-management/tables/${resourceTableName}/data`, page: 1,
{ size: 1000,
page: 1, autoFilter: true,
size: 1000, });
autoFilter: true,
}
);
const responseData = const responseData = response.data?.data?.data || response.data?.data || [];
response.data?.data?.data || response.data?.data || [];
const rawData = Array.isArray(responseData) ? responseData : []; const rawData = Array.isArray(responseData) ? responseData : [];
// 데이터를 Resource 형태로 변환 // 데이터를 Resource 형태로 변환
const mappedResources: Resource[] = rawData.map((row: any) => ({ const mappedResources: Resource[] = rawData.map((row: any) => ({
id: String(row[resourceFieldMapping.id] || ""), id: String(row[resourceFieldMapping.id] || ""),
name: String(row[resourceFieldMapping.name] || ""), name: String(row[resourceFieldMapping.name] || ""),
group: resourceFieldMapping.group group: resourceFieldMapping.group ? row[resourceFieldMapping.group] : undefined,
? row[resourceFieldMapping.group]
: undefined,
})); }));
setResources(mappedResources); setResources(mappedResources);
@ -270,44 +246,41 @@ export function useTimelineData(
// 이벤트 버스 리스너 - 테이블 선택 변경 (품목 선택 시 해당 스케줄만 표시) // 이벤트 버스 리스너 - 테이블 선택 변경 (품목 선택 시 해당 스케줄만 표시)
useEffect(() => { useEffect(() => {
const unsubscribeSelection = v2EventBus.subscribe( const unsubscribeSelection = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
V2_EVENTS.TABLE_SELECTION_CHANGE, console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", {
(payload) => { tableName: payload.tableName,
console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", { selectedCount: payload.selectedCount,
tableName: payload.tableName, });
selectedCount: payload.selectedCount,
});
// 설정된 그룹 필드명 사용 (없으면 기본값들 fallback) // 설정된 그룹 필드명 사용 (없으면 기본값들 fallback)
const groupByField = config.sourceConfig?.groupByField; const groupByField = config.sourceConfig?.groupByField;
// 선택된 데이터에서 source_group_key 추출 // 선택된 데이터에서 source_group_key 추출
const sourceKeys: string[] = []; const sourceKeys: string[] = [];
for (const row of payload.selectedRows || []) { for (const row of payload.selectedRows || []) {
// 설정된 필드명 우선, 없으면 일반적인 필드명 fallback // 설정된 필드명 우선, 없으면 일반적인 필드명 fallback
let key: string | undefined; let key: string | undefined;
if (groupByField && row[groupByField]) { if (groupByField && row[groupByField]) {
key = row[groupByField]; key = row[groupByField];
} else { } else {
// fallback: 일반적으로 사용되는 필드명들 // fallback: 일반적으로 사용되는 필드명들
key = row.part_code || row.source_group_key || row.item_code; key = row.part_code || row.source_group_key || row.item_code;
}
if (key && !sourceKeys.includes(key)) {
sourceKeys.push(key);
}
} }
console.log("[useTimelineData] 선택된 그룹 키:", { if (key && !sourceKeys.includes(key)) {
groupByField, sourceKeys.push(key);
keys: sourceKeys, }
});
// 상태 업데이트 및 ref 동기화
selectedSourceKeysRef.current = sourceKeys;
setSelectedSourceKeys(sourceKeys);
} }
);
console.log("[useTimelineData] 선택된 그룹 키:", {
groupByField,
keys: sourceKeys,
});
// 상태 업데이트 및 ref 동기화
selectedSourceKeysRef.current = sourceKeys;
setSelectedSourceKeys(sourceKeys);
});
return () => { return () => {
unsubscribeSelection(); unsubscribeSelection();
@ -325,27 +298,21 @@ export function useTimelineData(
// 이벤트 버스 리스너 - 스케줄 생성 완료 및 테이블 새로고침 // 이벤트 버스 리스너 - 스케줄 생성 완료 및 테이블 새로고침
useEffect(() => { useEffect(() => {
// TABLE_REFRESH 이벤트 수신 - 스케줄 새로고침 // TABLE_REFRESH 이벤트 수신 - 스케줄 새로고침
const unsubscribeRefresh = v2EventBus.subscribe( const unsubscribeRefresh = v2EventBus.subscribe(V2_EVENTS.TABLE_REFRESH, (payload) => {
V2_EVENTS.TABLE_REFRESH, // schedule_mng 또는 해당 테이블에 대한 새로고침
(payload) => { if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) {
// schedule_mng 또는 해당 테이블에 대한 새로고침 console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) { fetchSchedules();
console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
} }
); });
// SCHEDULE_GENERATE_COMPLETE 이벤트 수신 - 스케줄 자동 생성 완료 시 새로고침 // SCHEDULE_GENERATE_COMPLETE 이벤트 수신 - 스케줄 자동 생성 완료 시 새로고침
const unsubscribeComplete = v2EventBus.subscribe( const unsubscribeComplete = v2EventBus.subscribe(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, (payload) => {
V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, if (payload.success) {
(payload) => { console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
if (payload.success) { fetchSchedules();
console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
} }
); });
return () => { return () => {
unsubscribeRefresh(); unsubscribeRefresh();
@ -390,23 +357,20 @@ export function useTimelineData(
if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate; if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate;
if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId; if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId;
if (updates.title) updateData[fieldMapping.title] = updates.title; if (updates.title) updateData[fieldMapping.title] = updates.title;
if (updates.status && fieldMapping.status) if (updates.status && fieldMapping.status) updateData[fieldMapping.status] = updates.status;
updateData[fieldMapping.status] = updates.status;
if (updates.progress !== undefined && fieldMapping.progress) if (updates.progress !== undefined && fieldMapping.progress)
updateData[fieldMapping.progress] = updates.progress; updateData[fieldMapping.progress] = updates.progress;
await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData); await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData);
// 로컬 상태 업데이트 // 로컬 상태 업데이트
setSchedules((prev) => setSchedules((prev) => prev.map((s) => (s.id === id ? { ...s, ...updates } : s)));
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
);
} catch (err: any) { } catch (err: any) {
console.error("스케줄 업데이트 오류:", err); console.error("스케줄 업데이트 오류:", err);
throw err; throw err;
} }
}, },
[tableName, fieldMapping, config.editable] [tableName, fieldMapping, config.editable],
); );
// 스케줄 추가 // 스케줄 추가
@ -427,10 +391,7 @@ export function useTimelineData(
if (fieldMapping.progress && schedule.progress !== undefined) if (fieldMapping.progress && schedule.progress !== undefined)
insertData[fieldMapping.progress] = schedule.progress; insertData[fieldMapping.progress] = schedule.progress;
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${tableName}/data`, insertData);
`/table-management/tables/${tableName}/data`,
insertData
);
const newId = response.data?.data?.id || Date.now().toString(); const newId = response.data?.data?.id || Date.now().toString();
@ -441,7 +402,7 @@ export function useTimelineData(
throw err; throw err;
} }
}, },
[tableName, fieldMapping, config.editable] [tableName, fieldMapping, config.editable],
); );
// 스케줄 삭제 // 스케줄 삭제
@ -459,7 +420,7 @@ export function useTimelineData(
throw err; throw err;
} }
}, },
[tableName, config.editable] [tableName, config.editable],
); );
// 새로고침 // 새로고침

View File

@ -10,12 +10,7 @@ export type ZoomLevel = "day" | "week" | "month";
/** /**
* *
*/ */
export type ScheduleStatus = export type ScheduleStatus = "planned" | "in_progress" | "completed" | "delayed" | "cancelled";
| "planned"
| "in_progress"
| "completed"
| "delayed"
| "cancelled";
/** /**
* ( ) * ( )
@ -107,10 +102,10 @@ export interface ResourceFieldMapping {
* (schedule_mng.schedule_type) * (schedule_mng.schedule_type)
*/ */
export type ScheduleType = export type ScheduleType =
| "PRODUCTION" // 생산계획 | "PRODUCTION" // 생산계획
| "MAINTENANCE" // 정비계획 | "MAINTENANCE" // 정비계획
| "SHIPPING" // 배차계획 | "SHIPPING" // 배차계획
| "WORK_ASSIGN"; // 작업배정 | "WORK_ASSIGN"; // 작업배정
/** /**
* ( ) * ( )

View File

@ -484,6 +484,15 @@ export class ButtonActionExecutor {
this.saveCallCount++; this.saveCallCount++;
const callId = this.saveCallCount; const callId = this.saveCallCount;
// 🔧 디버그: context.formData 확인 (handleSave 진입 시점)
console.log("🔍 [handleSave] 진입 시 context.formData:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
hasCompanyLogo: "company_logo" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
companyLogoValue: context.formData?.company_logo,
});
const { formData, originalData, tableName, screenId, onSave } = context; const { formData, originalData, tableName, screenId, onSave } = context;
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시 // 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
@ -524,6 +533,14 @@ export class ButtonActionExecutor {
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음 // 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 = { const beforeSaveEventDetail = {
formData: context.formData, formData: context.formData,
skipDefaultSave: false, skipDefaultSave: false,
@ -539,6 +556,13 @@ export class ButtonActionExecutor {
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
// 🔧 디버그: beforeFormSave 이벤트 후 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
// 검증 실패 시 저장 중단 // 검증 실패 시 저장 중단
if (beforeSaveEventDetail.validationFailed) { if (beforeSaveEventDetail.validationFailed) {
console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors); console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors);
@ -668,6 +692,10 @@ export class ButtonActionExecutor {
return await this.handleBatchSave(config, context, selectedItemsKeys); return await this.handleBatchSave(config, context, selectedItemsKeys);
} else { } else {
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행"); console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
// 🔧 디버그: formData 상세 확인
console.log("🔍 [handleSave] formData 키 목록:", Object.keys(context.formData || {}));
console.log("🔍 [handleSave] formData.company_image:", context.formData?.company_image);
console.log("🔍 [handleSave] formData.company_logo:", context.formData?.company_logo);
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData); console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
} }
@ -709,7 +737,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
try { try {
const allocateResult = await allocateNumberingCode(ruleId); // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = context.formData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, context.formData);
if (allocateResult.success && allocateResult.data?.generatedCode) { if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode; const newCode = allocateResult.data.generatedCode;
@ -1002,7 +1032,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try { try {
const allocateResult = await allocateNumberingCode(ruleId); // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = formData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) { if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode; const newCode = allocateResult.data.generatedCode;
@ -1588,7 +1620,18 @@ export class ButtonActionExecutor {
if (config.enableDataflowControl && config.dataflowConfig) { if (config.enableDataflowControl && config.dataflowConfig) {
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우) // 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
// 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨 // 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
const formData: Record<string, any> = (saveResult.data || context.formData || {}) as Record<string, any>; // 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출
// saveResult.data = API 응답 { success, data, message }
// saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... }
// 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>;
console.log("📦 [executeAfterSaveControl] savedRecord 구조:", Object.keys(savedRecord));
console.log("📦 [executeAfterSaveControl] actualFormData 추출:", Object.keys(formData));
console.log("📦 [executeAfterSaveControl] formData.sabun:", formData.sabun);
let parsedSectionData: any[] = []; let parsedSectionData: any[] = [];
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기 // comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
@ -2026,7 +2069,9 @@ export class ButtonActionExecutor {
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try { try {
const allocateResult = await allocateNumberingCode(ruleId); // 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
const userInputCode = commonFieldsData[fieldName] as string;
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) { if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode; const newCode = allocateResult.data.generatedCode;
@ -2881,8 +2926,7 @@ export class ButtonActionExecutor {
if (v2ListComponent) { if (v2ListComponent) {
dataSourceId = dataSourceId =
v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.tableName;
v2ListComponent.componentConfig.tableName;
console.log("✨ V2List 자동 감지:", { console.log("✨ V2List 자동 감지:", {
componentId: v2ListComponent.id, componentId: v2ListComponent.id,
tableName: dataSourceId, tableName: dataSourceId,
@ -3015,9 +3059,13 @@ export class ButtonActionExecutor {
} }
// 4. 모달 열기 이벤트 발생 // 4. 모달 열기 이벤트 발생
// passSelectedData가 true이면 editData로 전달 (수정 모드처럼 모든 필드 표시) // 🔧 수정: openModalWithData는 "신규 등록 + 연결 데이터 전달"용이므로
// editData가 아닌 splitPanelParentData로 전달해야 채번 등이 정상 작동함
const isPassDataMode = passSelectedData && selectedData.length > 0; const isPassDataMode = passSelectedData && selectedData.length > 0;
// 🔧 isEditMode 옵션이 명시적으로 true인 경우에만 수정 모드로 처리
const useAsEditData = config.isEditMode === true;
const modalEvent = new CustomEvent("openScreenModal", { const modalEvent = new CustomEvent("openScreenModal", {
detail: { detail: {
screenId: config.targetScreenId, screenId: config.targetScreenId,
@ -3026,19 +3074,18 @@ export class ButtonActionExecutor {
size: config.modalSize || "md", size: config.modalSize || "md",
selectedData: selectedData, selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean), selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
// 🆕 데이터 전달 모드일 때는 editData로 전달하여 모든 필드가 표시되도록 함 // 🔧 수정: isEditMode가 명시적으로 true인 경우에만 editData로 전달
editData: isPassDataMode ? parentData : undefined, // 기본적으로는 splitPanelParentData로 전달하여 신규 등록 + 연결 데이터 모드
splitPanelParentData: isPassDataMode ? undefined : parentData, editData: useAsEditData && isPassDataMode ? parentData : undefined,
splitPanelParentData: isPassDataMode ? parentData : undefined,
urlParams: dataSourceId ? { dataSourceId } : undefined, urlParams: dataSourceId ? { dataSourceId } : undefined,
}, },
}); });
window.dispatchEvent(modalEvent); window.dispatchEvent(modalEvent);
// 성공 메시지 (autoDetectDataSource 모드에서만) // 모달 열기는 UI 전환이므로 성공 토스트를 표시하지 않음
if (autoDetectDataSource && config.successMessage) { // (저장 등 실제 액션 완료 시에만 토스트 표시)
toast.success(config.successMessage);
}
return true; return true;
} }
@ -3227,8 +3274,7 @@ export class ButtonActionExecutor {
window.dispatchEvent(modalEvent); window.dispatchEvent(modalEvent);
// 성공 메시지 (간단하게) // 모달 열기는 UI 전환이므로 성공 토스트를 표시하지 않음
toast.success(config.successMessage || "다음 단계로 진행합니다.");
return true; return true;
} else { } else {
@ -3455,10 +3501,13 @@ export class ButtonActionExecutor {
const screenModalEvent = new CustomEvent("openScreenModal", { const screenModalEvent = new CustomEvent("openScreenModal", {
detail: { detail: {
screenId: config.targetScreenId, screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정", title: isCreateMode ? config.editModalTitle || "데이터 복사" : config.editModalTitle || "데이터 수정",
description: description, description: description,
size: config.modalSize || "lg", size: config.modalSize || "lg",
editData: rowData, // 🆕 수정 데이터 전달 // 🔧 복사 모드에서는 editData 대신 splitPanelParentData로 전달하여 채번이 생성되도록 함
editData: isCreateMode ? undefined : rowData,
splitPanelParentData: isCreateMode ? rowData : undefined,
isCreateMode: isCreateMode, // 🆕 복사 모드 플래그 전달
}, },
}); });
window.dispatchEvent(screenModalEvent); window.dispatchEvent(screenModalEvent);
@ -3986,16 +4035,27 @@ export class ButtonActionExecutor {
const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비: context-data 모드는 배열을 기대함 // 데이터 소스 준비: context-data 모드는 배열을 기대함
// 우선순위: selectedRowsData > savedData > formData // 🔧 저장 후 제어: savedData > formData > selectedRowsData
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함) // - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요!
// - savedData: 저장 API 응답 데이터 // - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위
// - formData: 폼에 입력된 데이터
let sourceData: any[]; let sourceData: any[];
if (context.selectedRowsData && context.selectedRowsData.length > 0) { if (context.savedData) {
// 저장된 데이터가 있으면 우선 사용 (저장 API 응답)
sourceData = Array.isArray(context.savedData) ? context.savedData : [context.savedData];
console.log("📦 [executeAfterSaveControl] savedData 사용:", sourceData);
console.log("📦 [executeAfterSaveControl] savedData 필드:", Object.keys(context.savedData));
console.log("📦 [executeAfterSaveControl] savedData.sabun:", context.savedData.sabun);
} else if (context.formData && Object.keys(context.formData).length > 0) {
// 폼 데이터 사용
sourceData = [context.formData];
console.log("📦 [executeAfterSaveControl] formData 사용:", sourceData);
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
// 테이블 섹션 데이터 (마지막 순위)
sourceData = context.selectedRowsData; sourceData = context.selectedRowsData;
console.log("📦 [executeAfterSaveControl] selectedRowsData 사용:", sourceData);
} else { } else {
const savedData = context.savedData || context.formData || {}; sourceData = [];
sourceData = Array.isArray(savedData) ? savedData : [savedData]; console.warn("⚠️ [executeAfterSaveControl] 데이터 소스 없음!");
} }
let allSuccess = true; let allSuccess = true;
@ -4095,16 +4155,25 @@ export class ButtonActionExecutor {
const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비: context-data 모드는 배열을 기대함 // 데이터 소스 준비: context-data 모드는 배열을 기대함
// 우선순위: selectedRowsData > savedData > formData // 🔧 저장 후 제어: savedData > formData > selectedRowsData
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함) // - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요!
// - savedData: 저장 API 응답 데이터 // - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위
// - formData: 폼에 입력된 데이터
let sourceData: any[]; let sourceData: any[];
if (context.selectedRowsData && context.selectedRowsData.length > 0) { if (context.savedData) {
// 저장된 데이터가 있으면 우선 사용 (저장 API 응답)
sourceData = Array.isArray(context.savedData) ? context.savedData : [context.savedData];
console.log("📦 [executeSingleFlowControl] savedData 사용:", sourceData);
} else if (context.formData && Object.keys(context.formData).length > 0) {
// 폼 데이터 사용
sourceData = [context.formData];
console.log("📦 [executeSingleFlowControl] formData 사용:", sourceData);
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
// 테이블 섹션 데이터 (마지막 순위)
sourceData = context.selectedRowsData; sourceData = context.selectedRowsData;
console.log("📦 [executeSingleFlowControl] selectedRowsData 사용:", sourceData);
} else { } else {
const savedData = context.savedData || context.formData || {}; sourceData = [];
sourceData = Array.isArray(savedData) ? savedData : [savedData]; console.warn("⚠️ [executeSingleFlowControl] 데이터 소스 없음!");
} }
// repeat-screen-modal 데이터가 있으면 병합 // repeat-screen-modal 데이터가 있으면 병합
@ -7094,7 +7163,7 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
modalSize: "md", modalSize: "md",
passSelectedData: true, passSelectedData: true,
autoDetectDataSource: true, autoDetectDataSource: true,
successMessage: "다음 단계로 진행합니다.", // 모달 열기는 UI 전환이므로 successMessage 제거
}, },
modal: { modal: {
type: "modal", type: "modal",

View File

@ -43,13 +43,20 @@ export interface ButtonExecutionResult {
} }
interface ControlConfig { interface ControlConfig {
type: "relationship"; type: "relationship" | "flow";
relationshipConfig: { relationshipConfig?: {
relationshipId: string; relationshipId: string;
relationshipName: string; relationshipName: string;
executionTiming: "before" | "after" | "replace"; executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>; contextData?: Record<string, any>;
}; };
// 🆕 플로우 기반 제어 설정
flowConfig?: {
flowId: number;
flowName: string;
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>;
};
} }
interface ExecutionPlan { interface ExecutionPlan {
@ -163,15 +170,22 @@ export class ImprovedButtonActionExecutor {
return plan; return plan;
} }
// enableDataflowControl 체크를 제거하고 dataflowConfig만 있으면 실행 // 🔧 controlMode가 없으면 flowConfig/relationshipConfig 존재 여부로 자동 판단
const effectiveControlMode = dataflowConfig.controlMode
|| (dataflowConfig.flowConfig ? "flow" : null)
|| (dataflowConfig.relationshipConfig ? "relationship" : null)
|| "none";
console.log("📋 실행 계획 생성:", { console.log("📋 실행 계획 생성:", {
controlMode: dataflowConfig.controlMode, controlMode: dataflowConfig.controlMode,
effectiveControlMode,
hasFlowConfig: !!dataflowConfig.flowConfig,
hasRelationshipConfig: !!dataflowConfig.relationshipConfig, hasRelationshipConfig: !!dataflowConfig.relationshipConfig,
enableDataflowControl: buttonConfig.enableDataflowControl, enableDataflowControl: buttonConfig.enableDataflowControl,
}); });
// 관계 기반 제어만 지원 // 관계 기반 제어
if (dataflowConfig.controlMode === "relationship" && dataflowConfig.relationshipConfig) { if (effectiveControlMode === "relationship" && dataflowConfig.relationshipConfig) {
const control: ControlConfig = { const control: ControlConfig = {
type: "relationship", type: "relationship",
relationshipConfig: dataflowConfig.relationshipConfig, relationshipConfig: dataflowConfig.relationshipConfig,
@ -191,11 +205,34 @@ export class ImprovedButtonActionExecutor {
} }
} }
// 🆕 플로우 기반 제어
if (effectiveControlMode === "flow" && dataflowConfig.flowConfig) {
const control: ControlConfig = {
type: "flow",
flowConfig: dataflowConfig.flowConfig,
};
console.log("📋 플로우 제어 설정:", dataflowConfig.flowConfig);
switch (dataflowConfig.flowConfig.executionTiming) {
case "before":
plan.beforeControls.push(control);
break;
case "after":
plan.afterControls.push(control);
break;
case "replace":
plan.afterControls.push(control);
plan.hasReplaceControl = true;
break;
}
}
return plan; return plan;
} }
/** /**
* 🔥 ( ) * 🔥 ( )
*/ */
private static async executeControls( private static async executeControls(
controls: ControlConfig[], controls: ControlConfig[],
@ -206,8 +243,16 @@ export class ImprovedButtonActionExecutor {
for (const control of controls) { for (const control of controls) {
try { try {
// 관계 실행만 지원 let result: ExecutionResult;
const result = await this.executeRelationship(control.relationshipConfig, formData, context);
// 🆕 제어 타입에 따라 분기 처리
if (control.type === "flow" && control.flowConfig) {
result = await this.executeFlow(control.flowConfig, formData, context);
} else if (control.type === "relationship" && control.relationshipConfig) {
result = await this.executeRelationship(control.relationshipConfig, formData, context);
} else {
throw new Error(`지원하지 않는 제어 타입: ${control.type}`);
}
results.push(result); results.push(result);
@ -215,7 +260,7 @@ export class ImprovedButtonActionExecutor {
if (!result.success) { if (!result.success) {
throw new Error(result.message); throw new Error(result.message);
} }
} catch (error) { } catch (error: any) {
console.error(`제어 실행 실패 (${control.type}):`, error); console.error(`제어 실행 실패 (${control.type}):`, error);
results.push({ results.push({
success: false, success: false,
@ -230,6 +275,61 @@ export class ImprovedButtonActionExecutor {
return results; return results;
} }
/**
* 🆕
*/
private static async executeFlow(
config: {
flowId: number;
flowName: string;
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>;
},
formData: Record<string, any>,
context: ButtonExecutionContext,
): Promise<ExecutionResult> {
const startTime = Date.now();
try {
console.log(`🔄 플로우 실행 시작: ${config.flowName} (ID: ${config.flowId})`);
// 플로우 실행 API 호출
const response = await apiClient.post(`/api/dataflow/node-flows/${config.flowId}/execute`, {
formData,
contextData: config.contextData || {},
selectedRows: context.selectedRows || [],
flowSelectedData: context.flowSelectedData || [],
screenId: context.screenId,
companyCode: context.companyCode,
userId: context.userId,
});
const executionTime = Date.now() - startTime;
if (response.data?.success) {
console.log(`✅ 플로우 실행 성공: ${config.flowName}`, response.data);
return {
success: true,
message: `플로우 "${config.flowName}" 실행 완료`,
executionTime,
data: response.data,
};
} else {
throw new Error(response.data?.message || "플로우 실행 실패");
}
} catch (error: any) {
const executionTime = Date.now() - startTime;
console.error(`❌ 플로우 실행 실패: ${config.flowName}`, error);
return {
success: false,
message: `플로우 "${config.flowName}" 실행 실패: ${error.message}`,
executionTime,
error: error.message,
};
}
}
/** /**
* 🔥 * 🔥
*/ */

View File

@ -49,7 +49,7 @@ function applyDefaultsToNestedComponents(components: any[]): any[] {
} }
// 결과 객체 초기화 (원본 복사) // 결과 객체 초기화 (원본 복사)
let result = { ...nestedComp }; const result = { ...nestedComp };
// 🆕 탭 위젯인 경우 재귀적으로 탭 내부 컴포넌트도 처리 // 🆕 탭 위젯인 경우 재귀적으로 탭 내부 컴포넌트도 처리
if (nestedComponentType === "v2-tabs-widget") { if (nestedComponentType === "v2-tabs-widget") {
@ -75,14 +75,18 @@ function applyDefaultsToNestedComponents(components: any[]): any[] {
const config = result.componentConfig || {}; const config = result.componentConfig || {};
result.componentConfig = { result.componentConfig = {
...config, ...config,
leftPanel: config.leftPanel ? { leftPanel: config.leftPanel
...config.leftPanel, ? {
components: applyDefaultsToNestedComponents(config.leftPanel.components || []), ...config.leftPanel,
} : config.leftPanel, components: applyDefaultsToNestedComponents(config.leftPanel.components || []),
rightPanel: config.rightPanel ? { }
...config.rightPanel, : config.leftPanel,
components: applyDefaultsToNestedComponents(config.rightPanel.components || []), rightPanel: config.rightPanel
} : config.rightPanel, ? {
...config.rightPanel,
components: applyDefaultsToNestedComponents(config.rightPanel.components || []),
}
: config.rightPanel,
}; };
} }
@ -183,13 +187,17 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
label: overrides.label || mergedConfig.label || "", // 라벨이 없으면 빈 문자열 label: overrides.label || mergedConfig.label || "", // 라벨이 없으면 빈 문자열
required: overrides.required, required: overrides.required,
readonly: overrides.readonly, readonly: overrides.readonly,
hidden: overrides.hidden, // 🆕 숨김 설정 복원
codeCategory: overrides.codeCategory, codeCategory: overrides.codeCategory,
inputType: overrides.inputType, inputType: overrides.inputType,
webType: overrides.webType, webType: overrides.webType,
// 🆕 autoFill 설정 복원 (자동 입력 기능) // 🆕 autoFill 설정 복원 (자동 입력 기능)
autoFill: overrides.autoFill, autoFill: overrides.autoFill,
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
style: overrides.style || {},
// 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등)
webTypeConfig: overrides.webTypeConfig || {},
// 기존 구조 호환을 위한 추가 필드 // 기존 구조 호환을 위한 추가 필드
style: {},
parentId: null, parentId: null,
gridColumns: 12, gridColumns: 12,
gridRowIndex: 0, gridRowIndex: 0,
@ -231,21 +239,59 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
const topLevelProps: Record<string, any> = {}; const topLevelProps: Record<string, any> = {};
if (comp.tableName) topLevelProps.tableName = comp.tableName; if (comp.tableName) topLevelProps.tableName = comp.tableName;
if (comp.columnName) topLevelProps.columnName = comp.columnName; if (comp.columnName) topLevelProps.columnName = comp.columnName;
if (comp.label) topLevelProps.label = comp.label; // 🔧 label은 빈 문자열도 저장 (라벨 삭제 지원)
if (comp.label !== undefined) topLevelProps.label = comp.label;
if (comp.required !== undefined) topLevelProps.required = comp.required; if (comp.required !== undefined) topLevelProps.required = comp.required;
if (comp.readonly !== undefined) topLevelProps.readonly = comp.readonly; if (comp.readonly !== undefined) topLevelProps.readonly = comp.readonly;
if (comp.hidden !== undefined) topLevelProps.hidden = comp.hidden; // 🆕 숨김 설정 저장
if (comp.codeCategory) topLevelProps.codeCategory = comp.codeCategory; if (comp.codeCategory) topLevelProps.codeCategory = comp.codeCategory;
if (comp.inputType) topLevelProps.inputType = comp.inputType; if (comp.inputType) topLevelProps.inputType = comp.inputType;
if (comp.webType) topLevelProps.webType = comp.webType; if (comp.webType) topLevelProps.webType = comp.webType;
// 🆕 autoFill 설정 저장 (자동 입력 기능) // 🆕 autoFill 설정 저장 (자동 입력 기능)
if (comp.autoFill) topLevelProps.autoFill = comp.autoFill; if (comp.autoFill) topLevelProps.autoFill = comp.autoFill;
// 🆕 style 설정 저장 (라벨 텍스트, 라벨 스타일 등)
if (comp.style && Object.keys(comp.style).length > 0) topLevelProps.style = comp.style;
// 🔧 webTypeConfig 저장 (버튼 제어기능, 플로우 가시성 등)
if (comp.webTypeConfig && Object.keys(comp.webTypeConfig).length > 0) {
topLevelProps.webTypeConfig = comp.webTypeConfig;
// 🔍 디버그: webTypeConfig 저장 확인
if (comp.webTypeConfig.dataflowConfig || comp.webTypeConfig.enableDataflowControl) {
console.log("💾 webTypeConfig 저장:", {
componentId: comp.id,
enableDataflowControl: comp.webTypeConfig.enableDataflowControl,
dataflowConfig: comp.webTypeConfig.dataflowConfig,
});
}
}
// 현재 설정에서 차이값만 추출 // 현재 설정에서 차이값만 추출
const fullConfig = comp.componentConfig || {}; const fullConfig = comp.componentConfig || {};
const configOverrides = extractCustomConfig(fullConfig, defaults); const configOverrides = extractCustomConfig(fullConfig, defaults);
// 🔧 디버그: style 저장 확인 (주석 처리)
// if (comp.style?.labelDisplay !== undefined || configOverrides.style?.labelDisplay !== undefined) { console.log("💾 저장 시 style 변환:", { componentId: comp.id, "comp.style": comp.style, "configOverrides.style": configOverrides.style, "topLevelProps.style": topLevelProps.style }); }
// 상위 레벨 속성과 componentConfig 병합 // 상위 레벨 속성과 componentConfig 병합
const overrides = { ...topLevelProps, ...configOverrides }; // 🔧 style은 양쪽을 병합하되 comp.style(topLevelProps.style)을 우선시
const mergedStyle = {
...(configOverrides.style || {}),
...(topLevelProps.style || {}),
};
// 🔧 webTypeConfig도 병합 (topLevelProps가 우선, dataflowConfig 등 보존)
const mergedWebTypeConfig = {
...(configOverrides.webTypeConfig || {}),
...(topLevelProps.webTypeConfig || {}),
};
const overrides = {
...topLevelProps,
...configOverrides,
// 🆕 병합된 style 사용 (comp.style 값이 최종 우선)
...(Object.keys(mergedStyle).length > 0 ? { style: mergedStyle } : {}),
// 🆕 병합된 webTypeConfig 사용 (comp.webTypeConfig가 최종 우선)
...(Object.keys(mergedWebTypeConfig).length > 0 ? { webTypeConfig: mergedWebTypeConfig } : {}),
};
return { return {
id: comp.id, id: comp.id,

View File

@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
config: { mode: "dropdown", source: "category" }, config: { mode: "dropdown", source: "category" },
}, },
// 파일/이미지 → V2Media // 파일/이미지 → V2 파일 업로드
file: { file: {
componentType: "v2-media", componentType: "v2-file-upload",
config: { type: "file", multiple: false }, config: { multiple: true, accept: "*/*", maxFiles: 10 },
}, },
image: { image: {
componentType: "v2-media", componentType: "v2-file-upload",
config: { type: "image", showPreview: true }, config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
}, },
img: { img: {
componentType: "v2-media", componentType: "v2-file-upload",
config: { type: "image", showPreview: true }, config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
}, },
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용) // 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
code: "v2-select", code: "v2-select",
entity: "v2-select", entity: "v2-select",
category: "v2-select", category: "v2-select",
file: "v2-media", file: "v2-file-upload",
image: "v2-media", image: "v2-file-upload",
img: "v2-media", img: "v2-file-upload",
button: "button-primary", button: "button-primary",
label: "v2-input", label: "v2-input",
}; };

View File

@ -10,11 +10,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { v2EventBus } from "../events/EventBus"; import { v2EventBus } from "../events/EventBus";
import { V2_EVENTS } from "../events/types"; import { V2_EVENTS } from "../events/types";
import type { import type { ScheduleType, V2ScheduleGenerateRequestEvent, V2ScheduleGenerateApplyEvent } from "../events/types";
ScheduleType,
V2ScheduleGenerateRequestEvent,
V2ScheduleGenerateApplyEvent,
} from "../events/types";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { toast } from "sonner"; import { toast } from "sonner";
@ -122,13 +118,10 @@ function getDefaultPeriod(): { start: string; end: string } {
* const { showConfirmDialog, previewResult, handleConfirm } = useScheduleGenerator(config); * const { showConfirmDialog, previewResult, handleConfirm } = useScheduleGenerator(config);
* ``` * ```
*/ */
export function useScheduleGenerator( export function useScheduleGenerator(scheduleConfig?: ScheduleGenerationConfig | null): UseScheduleGeneratorReturn {
scheduleConfig?: ScheduleGenerationConfig | null
): UseScheduleGeneratorReturn {
// 상태 // 상태
const [selectedData, setSelectedData] = useState<any[]>([]); const [selectedData, setSelectedData] = useState<any[]>([]);
const [previewResult, setPreviewResult] = const [previewResult, setPreviewResult] = useState<SchedulePreviewResult | null>(null);
useState<SchedulePreviewResult | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const currentRequestIdRef = useRef<string>(""); const currentRequestIdRef = useRef<string>("");
@ -136,57 +129,53 @@ export function useScheduleGenerator(
// 1. 테이블 선택 데이터 추적 (TABLE_SELECTION_CHANGE 이벤트 수신) // 1. 테이블 선택 데이터 추적 (TABLE_SELECTION_CHANGE 이벤트 수신)
useEffect(() => { useEffect(() => {
const unsubscribe = v2EventBus.subscribe( const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
V2_EVENTS.TABLE_SELECTION_CHANGE, // scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장
(payload) => { if (scheduleConfig?.source?.tableName) {
// scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장 if (payload.tableName === scheduleConfig.source.tableName) {
if (scheduleConfig?.source?.tableName) {
if (payload.tableName === scheduleConfig.source.tableName) {
setSelectedData(payload.selectedRows);
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (특정 테이블):", payload.selectedCount, "건");
}
} else {
// scheduleConfig가 없으면 모든 테이블의 선택 데이터를 저장
setSelectedData(payload.selectedRows); setSelectedData(payload.selectedRows);
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (모든 테이블):", payload.selectedCount, "건"); console.log("[useScheduleGenerator] 선택 데이터 업데이트 (특정 테이블):", payload.selectedCount, "건");
} }
} else {
// scheduleConfig가 없으면 모든 테이블의 선택 데이터를 저장
setSelectedData(payload.selectedRows);
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (모든 테이블):", payload.selectedCount, "건");
} }
); });
return unsubscribe; return unsubscribe;
}, [scheduleConfig?.source?.tableName]); }, [scheduleConfig?.source?.tableName]);
// 2. 스케줄 생성 요청 처리 (SCHEDULE_GENERATE_REQUEST 수신) // 2. 스케줄 생성 요청 처리 (SCHEDULE_GENERATE_REQUEST 수신)
useEffect(() => { useEffect(() => {
console.log("[useScheduleGenerator] 이벤트 구독 시작");
const unsubscribe = v2EventBus.subscribe( const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.SCHEDULE_GENERATE_REQUEST, V2_EVENTS.SCHEDULE_GENERATE_REQUEST,
async (payload: V2ScheduleGenerateRequestEvent) => { async (payload: V2ScheduleGenerateRequestEvent) => {
console.log("[useScheduleGenerator] SCHEDULE_GENERATE_REQUEST 수신:", payload); console.log("[useScheduleGenerator] SCHEDULE_GENERATE_REQUEST 수신:", payload);
// 이벤트에서 config가 오면 사용, 없으면 기존 scheduleConfig 또는 기본 config 사용 // 이벤트에서 config가 오면 사용, 없으면 기존 scheduleConfig 또는 기본 config 사용
const configToUse = (payload as any).config || scheduleConfig || { const configToUse = (payload as any).config ||
// 기본 설정 (생산계획 화면용) scheduleConfig || {
scheduleType: payload.scheduleType || "PRODUCTION", // 기본 설정 (생산계획 화면용)
source: { scheduleType: payload.scheduleType || "PRODUCTION",
tableName: "sales_order_mng", source: {
groupByField: "part_code", tableName: "sales_order_mng",
quantityField: "balance_qty", groupByField: "part_code",
dueDateField: "delivery_date", // 기준일 필드 (납기일) quantityField: "balance_qty",
}, dueDateField: "delivery_date", // 기준일 필드 (납기일)
resource: { },
type: "ITEM", resource: {
idField: "part_code", type: "ITEM",
nameField: "part_name", idField: "part_code",
}, nameField: "part_name",
rules: { },
leadTimeDays: 3, rules: {
dailyCapacity: 100, leadTimeDays: 3,
}, dailyCapacity: 100,
target: { },
tableName: "schedule_mng", target: {
}, tableName: "schedule_mng",
}; },
};
console.log("[useScheduleGenerator] 사용할 config:", configToUse); console.log("[useScheduleGenerator] 사용할 config:", configToUse);
@ -250,7 +239,7 @@ export function useScheduleGenerator(
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
} },
); );
return unsubscribe; return unsubscribe;
}, [selectedData, scheduleConfig]); }, [selectedData, scheduleConfig]);
@ -299,10 +288,9 @@ export function useScheduleGenerator(
tableName: configToUse?.target?.tableName || "schedule_mng", tableName: configToUse?.target?.tableName || "schedule_mng",
}); });
toast.success( toast.success(`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`, {
`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`, id: "schedule-apply",
{ id: "schedule-apply" } });
);
setShowConfirmDialog(false); setShowConfirmDialog(false);
setPreviewResult(null); setPreviewResult(null);
} catch (error: any) { } catch (error: any) {
@ -311,7 +299,7 @@ export function useScheduleGenerator(
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
} },
); );
return unsubscribe; return unsubscribe;
}, [previewResult, scheduleConfig]); }, [previewResult, scheduleConfig]);

View File

@ -15,7 +15,8 @@ const nextConfig = {
// 실험적 기능 활성화 // 실험적 기능 활성화
experimental: { experimental: {
outputFileTracingRoot: undefined, // 메모리 사용량 최적화 (Next.js 15+)
webpackMemoryOptimizations: true,
}, },
// API 프록시 설정 - 백엔드로 요청 전달 // API 프록시 설정 - 백엔드로 요청 전달

View File

@ -232,13 +232,27 @@ export interface V2MediaConfig {
maxSize?: number; maxSize?: number;
preview?: boolean; preview?: boolean;
uploadEndpoint?: string; uploadEndpoint?: string;
// 레거시 FileUpload 호환 설정
docType?: string;
docTypeName?: string;
showFileList?: boolean;
dragDrop?: boolean;
} }
export interface V2MediaProps extends V2BaseProps { export interface V2MediaProps extends V2BaseProps {
v2Type: "V2Media"; v2Type?: "V2Media";
config: V2MediaConfig; config?: V2MediaConfig;
value?: string | string[]; // 파일 URL 또는 배열 value?: string | string[]; // 파일 URL 또는 배열
onChange?: (value: string | string[]) => void; onChange?: (value: string | string[]) => void;
// 레거시 FileUpload 호환 props
formData?: Record<string, any>;
columnName?: string;
tableName?: string;
// 부모 컴포넌트 시그니처: (fieldName, value) 형식
onFormDataChange?: (fieldName: string, value: any) => void;
isDesignMode?: boolean;
isInteractive?: boolean;
onUpdate?: (updates: Partial<any>) => void;
} }
// ===== V2List ===== // ===== V2List =====
@ -530,3 +544,28 @@ export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
// Button (Input의 버튼 모드) // Button (Input의 버튼 모드)
"button-primary": "V2Input", "button-primary": "V2Input",
}; };
// ===== 조건부 레이어 시스템 =====
/**
*
*
*/
export interface LayerCondition {
field: string; // 트리거 필드 (columnName 또는 탭ID)
operator: "=" | "!=" | "in" | "notIn" | "isEmpty" | "isNotEmpty";
value: string | string[]; // 비교값
}
/**
*
*
*/
export interface LayerConfig {
layerId: string; // 고유 ID
layerName: string; // 표시명 (설정용)
conditions: LayerCondition[]; // 조건 목록
conditionLogic?: "AND" | "OR"; // 조건 조합 방식 (기본: AND)
targetComponents: string[]; // 표시할 컴포넌트 ID 목록
alwaysVisible?: boolean; // 항상 표시 (조건 무시)
}

View File

@ -26,12 +26,14 @@ if %errorlevel% neq 0 (
echo [OK] Docker Desktop이 실행 중입니다. echo [OK] Docker Desktop이 실행 중입니다.
echo. echo.
REM 기존 컨테이너 정리 REM 기존 컨테이너 및 이미지 정리
echo [2/5] 기존 컨테이너 정리 중... echo [2/5] 기존 컨테이너 및 이미지 정리 중...
docker rm -f pms-backend-win pms-frontend-win 2>nul docker rm -f pms-backend-win pms-frontend-win 2>nul
docker rmi -f erp-node-backend erp-node-frontend 2>nul
docker network rm pms-network 2>nul docker network rm pms-network 2>nul
docker network create pms-network 2>nul docker network create pms-network 2>nul
echo [OK] 컨테이너 정리 완료 docker system prune -f >nul 2>&1
echo [OK] 컨테이너 및 이미지 정리 완료
echo. echo.
REM 병렬 빌드 (docker-compose 자체가 병렬 처리) REM 병렬 빌드 (docker-compose 자체가 병렬 처리)
@ -39,8 +41,8 @@ echo [3/5] 이미지 빌드 중... (백엔드 + 프론트엔드 병렬)
echo 이 작업은 시간이 걸릴 수 있습니다... echo 이 작업은 시간이 걸릴 수 있습니다...
echo. echo.
REM 백엔드 빌드 REM 백엔드 빌드 (캐시 없이 완전 재빌드)
docker-compose -f docker-compose.backend.win.yml build docker-compose -f docker-compose.backend.win.yml build --no-cache
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo [ERROR] 백엔드 빌드 실패! echo [ERROR] 백엔드 빌드 실패!
pause pause
@ -49,8 +51,8 @@ if %errorlevel% neq 0 (
echo [OK] 백엔드 빌드 완료 echo [OK] 백엔드 빌드 완료
echo. echo.
REM 프론트엔드 빌드 REM 프론트엔드 빌드 (캐시 없이 완전 재빌드)
docker-compose -f docker-compose.frontend.win.yml build docker-compose -f docker-compose.frontend.win.yml build --no-cache
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo [ERROR] 프론트엔드 빌드 실패! echo [ERROR] 프론트엔드 빌드 실패!
pause pause