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:
commit
15d2a654bd
|
|
@ -1044,6 +1044,7 @@
|
|||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
|
|
@ -2371,6 +2372,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
|
|
@ -3474,6 +3476,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -3710,6 +3713,7 @@
|
|||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
|
|
@ -3927,6 +3931,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4453,6 +4458,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"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.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -7425,6 +7432,7 @@
|
|||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
|
|
@ -8394,7 +8402,6 @@
|
|||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
|
|
@ -9283,6 +9290,7 @@
|
|||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
|
|
@ -10133,7 +10141,6 @@
|
|||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
|
|
@ -10942,6 +10949,7 @@
|
|||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
|
|
@ -11047,6 +11055,7 @@
|
|||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -793,8 +793,9 @@ export const previewFile = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 제외)
|
||||
if (companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
// 🔒 멀티테넌시: 회사 코드 일치 여부 확인 (최고 관리자 및 공개 접근 제외)
|
||||
// 공개 접근(req.user가 없는 경우)은 미리보기 허용 (이미지 표시용)
|
||||
if (companyCode && companyCode !== "*" && fileRecord.company_code !== companyCode) {
|
||||
console.warn("⚠️ 다른 회사 파일 접근 시도:", {
|
||||
userId: req.user?.userId,
|
||||
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
|
||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||
|
|
|
|||
|
|
@ -3,392 +3,545 @@
|
|||
*/
|
||||
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import {
|
||||
authenticateToken,
|
||||
AuthenticatedRequest,
|
||||
} from "../middleware/authMiddleware";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 규칙 목록 조회 (전체)
|
||||
router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
router.get(
|
||||
"/",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
try {
|
||||
const rules = await numberingRuleService.getRuleList(companyCode);
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const rules = await numberingRuleService.getRuleList(companyCode);
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 메뉴별 사용 가능한 규칙 조회
|
||||
router.get("/available/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
|
||||
router.get(
|
||||
"/available/:menuObjid?",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid
|
||||
? parseInt(req.params.menuObjid)
|
||||
: undefined;
|
||||
|
||||
logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode });
|
||||
logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode });
|
||||
|
||||
try {
|
||||
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
|
||||
|
||||
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
rulesCount: rules.length
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", {
|
||||
error: error.message,
|
||||
errorCode: error.code,
|
||||
errorStack: error.stack,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const rules = await numberingRuleService.getAvailableRulesForMenu(
|
||||
companyCode,
|
||||
menuObjid
|
||||
);
|
||||
|
||||
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
rulesCount: rules.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", {
|
||||
error: error.message,
|
||||
errorCode: error.code,
|
||||
errorStack: error.stack,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
|
||||
router.get("/available-for-screen", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName } = req.query;
|
||||
router.get(
|
||||
"/available-for-screen",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName } = req.query;
|
||||
|
||||
try {
|
||||
// tableName 필수 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "tableName is required",
|
||||
});
|
||||
}
|
||||
|
||||
const rules = await numberingRuleService.getAvailableRulesForScreen(
|
||||
companyCode,
|
||||
tableName
|
||||
);
|
||||
|
||||
logger.info("화면용 채번 규칙 조회 성공", {
|
||||
companyCode,
|
||||
tableName,
|
||||
count: rules.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("화면용 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
tableName,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 규칙 조회
|
||||
router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getRuleById(ruleId, companyCode);
|
||||
if (!rule) {
|
||||
return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" });
|
||||
}
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 규칙 생성
|
||||
router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
||||
logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", {
|
||||
companyCode,
|
||||
userId,
|
||||
ruleId: ruleConfig.ruleId,
|
||||
ruleName: ruleConfig.ruleName,
|
||||
scopeType: ruleConfig.scopeType,
|
||||
menuObjid: ruleConfig.menuObjid,
|
||||
tableName: ruleConfig.tableName,
|
||||
partsCount: ruleConfig.parts?.length,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
|
||||
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) {
|
||||
return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" });
|
||||
}
|
||||
|
||||
// 🆕 scopeType이 'table'인 경우 tableName 필수 체크
|
||||
if (ruleConfig.scopeType === "table") {
|
||||
if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") {
|
||||
try {
|
||||
// tableName 필수 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다",
|
||||
error: "tableName is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
|
||||
|
||||
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
|
||||
ruleId: newRule.ruleId,
|
||||
menuObjid: newRule.menuObjid,
|
||||
});
|
||||
const rules = await numberingRuleService.getAvailableRulesForScreen(
|
||||
companyCode,
|
||||
tableName
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: newRule });
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
|
||||
logger.info("화면용 채번 규칙 조회 성공", {
|
||||
companyCode,
|
||||
tableName,
|
||||
count: rules.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("화면용 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
tableName,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 특정 규칙 조회
|
||||
router.get(
|
||||
"/:ruleId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getRuleById(ruleId, companyCode);
|
||||
if (!rule) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "규칙을 찾을 수 없습니다" });
|
||||
}
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 규칙 생성
|
||||
router.post(
|
||||
"/",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
||||
logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", {
|
||||
companyCode,
|
||||
userId,
|
||||
ruleId: ruleConfig.ruleId,
|
||||
ruleName: ruleConfig.ruleName,
|
||||
scopeType: ruleConfig.scopeType,
|
||||
menuObjid: ruleConfig.menuObjid,
|
||||
tableName: ruleConfig.tableName,
|
||||
partsCount: ruleConfig.parts?.length,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({
|
||||
success: false,
|
||||
error: "최소 1개 이상의 규칙 파트가 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 scopeType이 'table'인 경우 tableName 필수 체크
|
||||
if (ruleConfig.scopeType === "table") {
|
||||
if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newRule = await numberingRuleService.createRule(
|
||||
ruleConfig,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
|
||||
ruleId: newRule.ruleId,
|
||||
menuObjid: newRule.menuObjid,
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: newRule });
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
|
||||
}
|
||||
logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 규칙 수정
|
||||
router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const updates = req.body;
|
||||
router.put(
|
||||
"/:ruleId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
||||
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
||||
|
||||
try {
|
||||
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
|
||||
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
||||
return res.json({ success: true, data: updatedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
try {
|
||||
const updatedRule = await numberingRuleService.updateRule(
|
||||
ruleId,
|
||||
updates,
|
||||
companyCode
|
||||
);
|
||||
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
||||
return res.json({ success: true, data: updatedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
}
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 규칙 삭제
|
||||
router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
router.delete(
|
||||
"/:ruleId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRule(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
try {
|
||||
await numberingRuleService.deleteRule(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
}
|
||||
logger.error("규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
logger.error("규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 코드 미리보기 (순번 증가 없음)
|
||||
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
router.post(
|
||||
"/:ruleId/preview",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(
|
||||
ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 코드 할당 (저장 시점에 실제 순번 증가)
|
||||
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
|
||||
router.post(
|
||||
"/:ruleId/allocate",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드
|
||||
|
||||
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData });
|
||||
logger.info("코드 할당 요청", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
hasFormData: !!formData,
|
||||
userInputCode,
|
||||
});
|
||||
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
|
||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(
|
||||
ruleId,
|
||||
companyCode,
|
||||
formData,
|
||||
userInputCode
|
||||
);
|
||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { generatedCode: allocatedCode },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("코드 할당 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 코드 생성 (기존 호환성 유지, deprecated)
|
||||
router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
router.post(
|
||||
"/:ruleId/generate",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode);
|
||||
return res.json({ success: true, data: { generatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.generateCode(
|
||||
ruleId,
|
||||
companyCode
|
||||
);
|
||||
return res.json({ success: true, data: { generatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 시퀀스 초기화
|
||||
router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
router.post(
|
||||
"/:ruleId/reset",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.resetSequence(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "시퀀스가 초기화되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("시퀀스 초기화 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
await numberingRuleService.resetSequence(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "시퀀스가 초기화되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("시퀀스 초기화 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// ==================== 테스트 테이블용 API ====================
|
||||
|
||||
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회
|
||||
router.get("/test/list/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
|
||||
router.get(
|
||||
"/test/list/:menuObjid?",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid
|
||||
? parseInt(req.params.menuObjid)
|
||||
: undefined;
|
||||
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 요청", { companyCode, menuObjid });
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 요청", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
});
|
||||
|
||||
try {
|
||||
const rules = await numberingRuleService.getRulesFromTest(companyCode, menuObjid);
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 성공", { companyCode, menuObjid, count: rules.length });
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const rules = await numberingRuleService.getRulesFromTest(
|
||||
companyCode,
|
||||
menuObjid
|
||||
);
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 성공", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
count: rules.length,
|
||||
});
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 목록 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// [테스트] 테이블+컬럼 기반 채번 규칙 조회
|
||||
router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
router.get(
|
||||
"/test/by-column/:tableName/:columnName",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, columnName);
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// [테스트] 테스트 테이블에 채번 규칙 저장
|
||||
// 채번 규칙은 독립적으로 생성 가능 (나중에 테이블 타입 관리에서 컬럼에 연결)
|
||||
router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
router.post(
|
||||
"/test/save",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
||||
logger.info("[테스트] 채번 규칙 저장 요청", {
|
||||
ruleId: ruleConfig.ruleId,
|
||||
ruleName: ruleConfig.ruleName,
|
||||
tableName: ruleConfig.tableName || "(미지정)",
|
||||
columnName: ruleConfig.columnName || "(미지정)",
|
||||
});
|
||||
logger.info("[테스트] 채번 규칙 저장 요청", {
|
||||
ruleId: ruleConfig.ruleId,
|
||||
ruleName: ruleConfig.ruleName,
|
||||
tableName: ruleConfig.tableName || "(미지정)",
|
||||
columnName: ruleConfig.columnName || "(미지정)",
|
||||
});
|
||||
|
||||
try {
|
||||
// ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결)
|
||||
if (!ruleConfig.ruleName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "ruleName is required"
|
||||
});
|
||||
try {
|
||||
// ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결)
|
||||
if (!ruleConfig.ruleName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "ruleName is required",
|
||||
});
|
||||
}
|
||||
|
||||
const savedRule = await numberingRuleService.saveRuleToTest(
|
||||
ruleConfig,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
|
||||
const savedRule = await numberingRuleService.saveRuleToTest(ruleConfig, companyCode, userId);
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// [테스트] 테스트 테이블에서 채번 규칙 삭제
|
||||
router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
router.delete(
|
||||
"/test/:ruleId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// [테스트] 코드 미리보기 (테스트 테이블 사용)
|
||||
router.post("/test/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body;
|
||||
router.post(
|
||||
"/test/:ruleId/preview",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body;
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(
|
||||
ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// ==================== 회사별 채번규칙 복제 API ====================
|
||||
|
||||
// 회사별 채번규칙 복제
|
||||
router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
router.post(
|
||||
"/copy-for-company",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
|
||||
// 최고 관리자만 사용 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: "최고 관리자만 사용할 수 있습니다"
|
||||
});
|
||||
}
|
||||
// 최고 관리자만 사용 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: "최고 관리자만 사용할 수 있습니다",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다"
|
||||
});
|
||||
}
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("회사별 채번규칙 복제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const result = await numberingRuleService.copyRulesForCompany(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("회사별 채번규칙 복제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
|
|||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
// 데이터 조회 (screens 배열 포함)
|
||||
// 데이터 조회 (screens 배열 포함) - 삭제된 화면(is_active = 'D') 제외
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
sg.*,
|
||||
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
|
||||
(SELECT COUNT(*) FROM screen_group_screens sgs
|
||||
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(
|
||||
json_build_object(
|
||||
'id', sgs.id,
|
||||
|
|
@ -64,6 +66,7 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
|
|||
) 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 screens
|
||||
FROM screen_groups sg
|
||||
${whereClause}
|
||||
|
|
@ -111,6 +114,7 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) =
|
|||
) 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 screens
|
||||
FROM screen_groups sg
|
||||
WHERE sg.id = $1
|
||||
|
|
@ -1737,7 +1741,9 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
});
|
||||
|
||||
// 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용)
|
||||
// screen_layouts (v1)와 screen_layouts_v2 모두 조회
|
||||
const rightPanelQuery = `
|
||||
-- V1: screen_layouts에서 조회
|
||||
SELECT
|
||||
sd.screen_id,
|
||||
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
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
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]);
|
||||
|
|
@ -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({
|
||||
success: true,
|
||||
data: screenSubTables,
|
||||
globalMainTables: globalMainTables, // 메인 테이블로 분류되어야 하는 테이블 목록
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 서브 테이블 정보 조회 실패:", error);
|
||||
|
|
|
|||
|
|
@ -2344,6 +2344,8 @@ export async function getTableEntityRelations(
|
|||
*
|
||||
* table_type_columns에서 reference_table이 현재 테이블인 레코드를 찾아서
|
||||
* 해당 테이블과 FK 컬럼 정보를 반환합니다.
|
||||
*
|
||||
* 우선순위: 현재 사용자의 company_code > 공통('*')
|
||||
*/
|
||||
export async function getReferencedByTables(
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -2351,9 +2353,11 @@ export async function getReferencedByTables(
|
|||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
// 현재 사용자의 회사 코드 (없으면 '*' 사용)
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
|
||||
logger.info(
|
||||
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===`
|
||||
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 (회사코드: ${userCompanyCode}) ===`
|
||||
);
|
||||
|
||||
if (!tableName) {
|
||||
|
|
@ -2371,23 +2375,41 @@ export async function getReferencedByTables(
|
|||
|
||||
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회
|
||||
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
|
||||
// 우선순위: 현재 사용자의 company_code > 공통('*')
|
||||
// ROW_NUMBER를 사용해서 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택
|
||||
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
|
||||
ttc.table_name,
|
||||
ttc.column_name,
|
||||
ttc.column_label,
|
||||
ttc.reference_table,
|
||||
ttc.reference_column,
|
||||
ttc.display_column,
|
||||
ttc.table_name as table_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.reference_table = $1
|
||||
AND ttc.input_type = 'entity'
|
||||
AND ttc.company_code = '*'
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
table_name,
|
||||
column_name,
|
||||
column_label,
|
||||
reference_table,
|
||||
reference_column,
|
||||
display_column,
|
||||
table_name as table_label
|
||||
FROM ranked
|
||||
WHERE rn = 1
|
||||
ORDER BY table_name, column_name
|
||||
`;
|
||||
|
||||
const result = await query(sqlQuery, [tableName]);
|
||||
const result = await query(sqlQuery, [tableName, userCompanyCode]);
|
||||
|
||||
const referencedByTables = result.map((row: any) => ({
|
||||
tableName: row.table_name,
|
||||
|
|
@ -2400,7 +2422,7 @@ export async function getReferencedByTables(
|
|||
}));
|
||||
|
||||
logger.info(
|
||||
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
|
||||
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})`
|
||||
);
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
generateTempToken,
|
||||
getFileByToken,
|
||||
setRepresentativeFile,
|
||||
getFileInfo,
|
||||
} from "../controllers/fileController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -24,6 +25,20 @@ const router = Router();
|
|||
*/
|
||||
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는 인증 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
|
|
@ -64,12 +79,7 @@ router.get("/linked/:tableName/:recordId", getLinkedFiles);
|
|||
*/
|
||||
router.delete("/:objid", deleteFile);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/preview/:objid
|
||||
* @desc 파일 미리보기 (이미지 등)
|
||||
* @access Private
|
||||
*/
|
||||
router.get("/preview/:objid", previewFile);
|
||||
// preview 라우트는 상단 공개 접근 구역으로 이동됨
|
||||
|
||||
/**
|
||||
* @route GET /api/files/download/:objid
|
||||
|
|
|
|||
|
|
@ -845,6 +845,9 @@ export class NodeFlowExecutionService {
|
|||
logger.info(
|
||||
`📊 컨텍스트 데이터 사용: ${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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -886,8 +886,9 @@ class NumberingRuleService {
|
|||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력)
|
||||
return part.manualConfig?.placeholder || "____";
|
||||
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
|
||||
// placeholder 텍스트는 프론트엔드에서 별도로 표시
|
||||
return "____";
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
|
@ -1014,11 +1015,13 @@ class NumberingRuleService {
|
|||
* @param ruleId 채번 규칙 ID
|
||||
* @param companyCode 회사 코드
|
||||
* @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
|
||||
* @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용)
|
||||
*/
|
||||
async allocateCode(
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
formData?: Record<string, any>
|
||||
formData?: Record<string, any>,
|
||||
userInputCode?: string
|
||||
): Promise<string> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
|
@ -1029,11 +1032,107 @@ class NumberingRuleService {
|
|||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
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
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "";
|
||||
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
|
||||
const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || "";
|
||||
manualPartIndex++;
|
||||
return manualValue;
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
|
@ -1096,6 +1195,68 @@ class NumberingRuleService {
|
|||
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:
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
|
|
|
|||
|
|
@ -119,17 +119,14 @@ export class ScheduleService {
|
|||
companyCode
|
||||
);
|
||||
toCreate.push(...schedules);
|
||||
totalQty += schedules.reduce(
|
||||
(sum, s) => sum + (s.plan_qty || 0),
|
||||
0
|
||||
);
|
||||
totalQty += schedules.reduce((sum, s) => sum + (s.plan_qty || 0), 0);
|
||||
}
|
||||
|
||||
// 3. 기존 스케줄 조회 (삭제 대상)
|
||||
// 그룹 키에서 리소스 ID만 추출 ("리소스ID|날짜" 형식에서 "리소스ID"만)
|
||||
const resourceIds = [...new Set(
|
||||
Object.keys(groupedData).map((key) => key.split("|")[0])
|
||||
)];
|
||||
const resourceIds = [
|
||||
...new Set(Object.keys(groupedData).map((key) => key.split("|")[0])),
|
||||
];
|
||||
const toDelete = await this.getExistingSchedules(
|
||||
config.scheduleType,
|
||||
resourceIds,
|
||||
|
|
@ -369,7 +366,9 @@ export class ScheduleService {
|
|||
let groupKey = resourceId;
|
||||
if (dueDateField && item[dueDateField]) {
|
||||
// 날짜를 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}`;
|
||||
}
|
||||
|
||||
|
|
@ -403,8 +402,7 @@ export class ScheduleService {
|
|||
|
||||
// 그룹 키에서 리소스ID와 기준일 분리
|
||||
const [resourceId, groupDueDate] = groupKey.split("|");
|
||||
const resourceName =
|
||||
items[0]?.[config.resource.nameField] || resourceId;
|
||||
const resourceName = items[0]?.[config.resource.nameField] || resourceId;
|
||||
|
||||
// 총 수량 계산
|
||||
const totalQty = items.reduce((sum, item) => {
|
||||
|
|
@ -469,7 +467,9 @@ export class ScheduleService {
|
|||
plan_qty: totalQty,
|
||||
status: "PLANNED",
|
||||
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,
|
||||
metadata: {
|
||||
sourceCount: items.length,
|
||||
|
|
|
|||
|
|
@ -731,6 +731,14 @@ export class ScreenManagementService {
|
|||
WHERE screen_id = $1 AND is_active = 'Y'`,
|
||||
[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(
|
||||
`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;
|
||||
}
|
||||
|
||||
|
|
@ -5161,20 +5157,16 @@ export class ScreenManagementService {
|
|||
...layoutData
|
||||
};
|
||||
|
||||
// SUPER_ADMIN인 경우 화면 정의의 company_code로 저장 (로드와 일관성 유지)
|
||||
const saveCompanyCode = companyCode === "*" ? existingScreen.company_code : companyCode;
|
||||
console.log(`저장할 company_code: ${saveCompanyCode} (원본: ${companyCode}, 화면 정의: ${existingScreen.company_code})`);
|
||||
|
||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
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 레이아웃 저장 완료`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -322,7 +322,9 @@ export class TableManagementService {
|
|||
});
|
||||
} else {
|
||||
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
|
||||
logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
|
||||
logger.info(
|
||||
"⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"
|
||||
);
|
||||
}
|
||||
} catch (mappingError: any) {
|
||||
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
|
||||
|
|
@ -488,7 +490,10 @@ export class TableManagementService {
|
|||
// table_type_columns에 모든 설정 저장 (멀티테넌시 지원)
|
||||
// detailSettings가 문자열이면 그대로, 객체면 JSON.stringify
|
||||
let detailSettingsStr = settings.detailSettings;
|
||||
if (typeof settings.detailSettings === "object" && settings.detailSettings !== null) {
|
||||
if (
|
||||
typeof settings.detailSettings === "object" &&
|
||||
settings.detailSettings !== null
|
||||
) {
|
||||
detailSettingsStr = JSON.stringify(settings.detailSettings);
|
||||
}
|
||||
|
||||
|
|
@ -734,7 +739,7 @@ export class TableManagementService {
|
|||
inputType?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
|
||||
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
|
||||
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
|
||||
let finalWebType = webType;
|
||||
if (webType === "direct" || webType === "auto") {
|
||||
|
|
@ -749,7 +754,8 @@ export class TableManagementService {
|
|||
);
|
||||
|
||||
// 웹 타입별 기본 상세 설정 생성
|
||||
const defaultDetailSettings = this.generateDefaultDetailSettings(finalWebType);
|
||||
const defaultDetailSettings =
|
||||
this.generateDefaultDetailSettings(finalWebType);
|
||||
|
||||
// 사용자 정의 설정과 기본 설정 병합
|
||||
const finalDetailSettings = {
|
||||
|
|
@ -768,7 +774,12 @@ export class TableManagementService {
|
|||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
updated_date = NOW()`,
|
||||
[tableName, columnName, finalWebType, JSON.stringify(finalDetailSettings)]
|
||||
[
|
||||
tableName,
|
||||
columnName,
|
||||
finalWebType,
|
||||
JSON.stringify(finalDetailSettings),
|
||||
]
|
||||
);
|
||||
logger.info(
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}`
|
||||
|
|
@ -796,7 +807,7 @@ export class TableManagementService {
|
|||
detailSettings?: Record<string, any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
|
||||
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
|
||||
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
|
||||
let finalInputType = inputType;
|
||||
if (inputType === "direct" || inputType === "auto") {
|
||||
|
|
@ -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__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
|
||||
if (
|
||||
actualValue === "__ALL__" ||
|
||||
|
|
@ -3369,14 +3418,16 @@ export class TableManagementService {
|
|||
|
||||
if (options.search) {
|
||||
for (const [key, value] of Object.entries(options.search)) {
|
||||
// 검색값 추출 (객체 형태일 수 있음)
|
||||
// 검색값 및 operator 추출 (객체 형태일 수 있음)
|
||||
let searchValue = value;
|
||||
let operator = "contains"; // 기본값: 부분 일치
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"value" in value
|
||||
) {
|
||||
searchValue = value.value;
|
||||
operator = (value as any).operator || "contains";
|
||||
}
|
||||
|
||||
// 빈 값이면 스킵
|
||||
|
|
@ -3428,15 +3479,49 @@ export class TableManagementService {
|
|||
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
|
||||
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
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})`
|
||||
);
|
||||
|
||||
// 🔧 파이프로 구분된 다중 선택값 처리
|
||||
if (safeValue.includes("|")) {
|
||||
const multiValues = safeValue
|
||||
.split("|")
|
||||
.filter((v: string) => v.trim() !== "");
|
||||
if (multiValues.length > 0) {
|
||||
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") {
|
||||
// writer_dept_code: user_info.dept_code에서 검색
|
||||
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
|
||||
|
|
@ -3473,10 +3558,33 @@ export class TableManagementService {
|
|||
}
|
||||
} else {
|
||||
// 일반 컬럼인 경우: 메인 테이블에서 검색
|
||||
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
|
||||
logger.info(
|
||||
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
|
||||
);
|
||||
// 🔧 파이프로 구분된 다중 선택값 처리
|
||||
if (safeValue.includes("|")) {
|
||||
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}%'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ services:
|
|||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||
- WATCHPACK_POLLING=true
|
||||
- NODE_OPTIONS=--max-old-space-size=4096
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 6G
|
||||
reservations:
|
||||
memory: 2G
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ services:
|
|||
- "9771:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||
- NODE_OPTIONS=--max-old-space-size=8192
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
volumes:
|
||||
- ../../frontend:/app
|
||||
- /app/node_modules
|
||||
|
|
|
|||
|
|
@ -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 | 사용 불가 | **자동 세로 배치** |
|
||||
|
|
@ -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 반응형을 구현할 수 있습니다.
|
||||
|
|
@ -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. 기술 결정
|
||||
|
|
@ -649,6 +805,10 @@ ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2;
|
|||
- [ ] 태블릿 (768px, 1024px) 테스트
|
||||
- [ ] 모바일 (375px, 414px) 테스트
|
||||
- [ ] 분할 패널 화면 테스트
|
||||
- [ ] GridLayout 컴포넌트 포함 화면 테스트
|
||||
- [ ] FlexboxLayout 컴포넌트 포함 화면 테스트
|
||||
- [ ] TabsLayout 컴포넌트 포함 화면 테스트
|
||||
- [ ] 중첩 레이아웃 (GridLayout 안에 컴포넌트) 테스트
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -659,6 +819,8 @@ ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2;
|
|||
| 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 |
|
||||
| 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) |
|
||||
| 디자인 모드 혼란 | 낮음 | position/size 필드 유지 |
|
||||
| GridLayout 내부 깨짐 | 낮음 | 내부는 기존 방식 유지, 외부 배치만 변경 |
|
||||
| 중첩 레이아웃 문제 | 낮음 | 각 레이아웃 컴포넌트는 독립적으로 동작 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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개 정독 후) |
|
||||
|
|
@ -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개 화면 수정 완료** |
|
||||
|
|
@ -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 | 초안 작성 |
|
||||
|
|
@ -23,7 +23,8 @@
|
|||
| 테이블명 | 용도 | 주요 컬럼 |
|
||||
|----------|------|----------|
|
||||
| `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_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` |
|
||||
|
||||
|
|
@ -86,9 +87,17 @@ screen_groups (그룹)
|
|||
│ │
|
||||
│ └─── 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
|
||||
│ ├── parentDataMapping
|
||||
│ ├── columns.mapping
|
||||
|
|
@ -1120,9 +1129,12 @@ screenSubTables[screenId].subTables.push({
|
|||
21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시)
|
||||
22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData)
|
||||
23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입)
|
||||
24. [ ] **선 교차점 이질감 해결** (계획 중)
|
||||
22. [ ] 범례 UI 추가 (선택사항)
|
||||
23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
|
||||
24. [x] **screen_layouts_v2 지원 추가** (rightPanel.relation V2 UNION 쿼리) ✅ 2026-01-30
|
||||
25. [x] **테이블 분류 우선순위 시스템** (메인 > 서브 우선순위 적용) ✅ 2026-01-30
|
||||
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)
|
||||
|
||||
### 개요
|
||||
|
|
@ -1742,4 +1897,6 @@ npm install react-zoom-pan-pinch
|
|||
- [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc)
|
||||
- [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.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 아키텍처
|
||||
|
||||
|
|
|
|||
|
|
@ -467,9 +467,9 @@ V2 전환 롤백 (필요시):
|
|||
- [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28
|
||||
- [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28
|
||||
- [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28
|
||||
- [ ] 단위 테스트 통과
|
||||
- [ ] 통합 테스트 통과
|
||||
- [ ] V2 전용 복제 동작 확인
|
||||
- [x] 단위 테스트 통과 ✅ 2026-01-30
|
||||
- [x] 통합 테스트 통과 ✅ 2026-01-30
|
||||
- [x] V2 전용 복제 동작 확인 ✅ 2026-01-30
|
||||
|
||||
### 9.3 Phase 2 완료 조건
|
||||
|
||||
|
|
@ -522,3 +522,4 @@ V2 전환 롤백 (필요시):
|
|||
| 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude |
|
||||
| 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude |
|
||||
| 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude |
|
||||
| 2026-01-30 | **Phase 1 테스트 완료** - 단위/통합 테스트 통과 확인 | Claude |
|
||||
|
|
@ -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 (부서관리 비밀번호 자동세팅)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -161,7 +161,6 @@ function ScreenViewPage() {
|
|||
// V2 레이아웃: Zod 기반 변환 (기본값 병합)
|
||||
const convertedLayout = convertV2ToLegacy(v2Response);
|
||||
if (convertedLayout) {
|
||||
console.log("📦 V2 레이아웃 로드 (Zod 기반):", v2Response.components?.length || 0, "개 컴포넌트");
|
||||
setLayout({
|
||||
...convertedLayout,
|
||||
screenResolution: v2Response.screenResolution || convertedLayout.screenResolution,
|
||||
|
|
@ -227,7 +226,6 @@ function ScreenViewPage() {
|
|||
);
|
||||
|
||||
if (hasTableWidget) {
|
||||
console.log("📋 테이블 위젯이 있어 자동 로드 건너뜀 (행 선택으로 데이터 로드)");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +236,9 @@ function ScreenViewPage() {
|
|||
compType?.includes("select") ||
|
||||
compType?.includes("textarea") ||
|
||||
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;
|
||||
return isInputType && hasColumnName;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
|||
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -126,6 +127,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
|
||||
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(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
|
|
@ -139,6 +158,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
splitPanelParentData,
|
||||
selectedData: eventSelectedData,
|
||||
selectedIds,
|
||||
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
|
||||
} = event.detail;
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
|
|
@ -162,7 +182,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||
if (editData) {
|
||||
// 🔧 단, isCreateMode가 true이면 (복사 모드) originalData를 설정하지 않음 → 채번 생성 가능
|
||||
if (editData && !isCreateMode) {
|
||||
// 🆕 배열인 경우 두 가지 데이터를 설정:
|
||||
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
|
||||
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
|
||||
|
|
@ -176,6 +197,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
|
||||
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 {
|
||||
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
||||
|
|
@ -322,12 +354,27 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 화면 정보와 레이아웃 데이터 로딩
|
||||
const [screenInfo, layoutData] = await Promise.all([
|
||||
// 화면 정보와 레이아웃 데이터 로딩 (V2 API 사용으로 기본값 병합)
|
||||
const [screenInfo, v2LayoutData] = await Promise.all([
|
||||
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 파라미터 확인 (수정 모드)
|
||||
if (typeof window !== "undefined") {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
|
@ -337,8 +384,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const groupByColumnsParam = urlParams.get("groupByColumns");
|
||||
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
|
||||
|
||||
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn });
|
||||
|
||||
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
||||
if (mode === "edit" && editId && tableName) {
|
||||
try {
|
||||
|
|
@ -363,14 +408,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용)
|
||||
if (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 response = apiResponse.data;
|
||||
|
||||
|
|
@ -483,26 +522,34 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||
needsScroll: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding
|
||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
const dialogGap = 16; // DialogContent gap-4
|
||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
|
||||
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
|
||||
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
|
||||
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 {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
|
||||
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
|
||||
maxHeight: `${maxAvailableHeight}px`,
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
},
|
||||
needsScroll,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -570,7 +617,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
||||
{...(modalStyle.style && { style: modalStyle.style })}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
|
|
@ -585,7 +632,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</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 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -597,30 +646,137 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}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 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
|
||||
// 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동)
|
||||
const compY = parseFloat(component.position?.y?.toString() || "0");
|
||||
const yAdjustment = getYOffset(compY, component.id);
|
||||
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent =
|
||||
offsetX === 0 && offsetY === 0
|
||||
? component
|
||||
: {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
|
|
@ -652,7 +808,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
companyCode={user?.companyCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
|
|
|
|||
|
|
@ -260,22 +260,24 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||
}, [currentRule, maxRules]);
|
||||
|
||||
const handleUpdatePart = useCallback((partId: string, updates: Partial<NumberingRulePart>) => {
|
||||
// partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용)
|
||||
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...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) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...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);
|
||||
try {
|
||||
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
|
||||
|
||||
// 파트별 기본 autoConfig 정의
|
||||
const defaultAutoConfigs: Record<string, any> = {
|
||||
sequence: { sequenceLength: 3, startFrom: 1 },
|
||||
|
|
@ -345,15 +345,30 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
|
||||
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
|
||||
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
|
||||
setSavedRules((prev) => {
|
||||
if (existing) {
|
||||
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? response.data! : r));
|
||||
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
||||
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 {
|
||||
return [...prev, response.data!];
|
||||
// 새 규칙 추가
|
||||
return [...prev, savedData];
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentRule(response.data);
|
||||
setCurrentRule(currentData);
|
||||
setSelectedRuleId(response.data.ruleId);
|
||||
|
||||
await onSave?.(response.data);
|
||||
|
|
@ -366,11 +381,27 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentRule, savedRules, onSave, currentTableName]);
|
||||
}, [currentRule, onSave, currentTableName, menuObjid]);
|
||||
|
||||
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);
|
||||
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}" 규칙을 불러왔습니다`);
|
||||
}, []);
|
||||
|
||||
|
|
@ -595,12 +626,12 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</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">
|
||||
{currentRule.parts.map((part) => (
|
||||
{currentRule.parts.map((part, index) => (
|
||||
<NumberingRuleCard
|
||||
key={part.id}
|
||||
key={`part-${part.order}-${index}`}
|
||||
part={part}
|
||||
onUpdate={(updates) => handleUpdatePart(part.id, updates)}
|
||||
onDelete={() => handleDeletePart(part.id)}
|
||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||
onDelete={() => handleDeletePart(part.order)}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -704,7 +704,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
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);
|
||||
|
||||
// buttonActions의 executeAfterSaveControl 동적 import
|
||||
|
|
@ -762,41 +767,51 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 채번 규칙이 있는 필드에 대해 allocateCode 호출
|
||||
// 채번 규칙이 있는 필드에 대해 allocateCode 호출 (🚀 병렬 처리로 최적화)
|
||||
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");
|
||||
|
||||
let hasAllocationFailure = false;
|
||||
const failedFields: string[] = [];
|
||||
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||
try {
|
||||
// 🚀 Promise.all로 병렬 처리 (여러 채번 필드가 있을 경우 성능 향상)
|
||||
const allocationPromises = Object.entries(fieldsWithNumbering).map(
|
||||
async ([fieldName, ruleId]) => {
|
||||
const userInputCode = dataToSave[fieldName] as string;
|
||||
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||||
const allocateResult = await allocateNumberingCode(ruleId);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
const newCode = allocateResult.data.generatedCode;
|
||||
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]} → ${newCode}`);
|
||||
dataToSave[fieldName] = newCode;
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
|
||||
hasAllocationFailure = true;
|
||||
failedFields.push(fieldName);
|
||||
|
||||
try {
|
||||
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
return { fieldName, success: true, code: allocateResult.data.generatedCode };
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
return { fieldName, success: false, hasExistingValue: !!(dataToSave[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;
|
||||
failedFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const allocationResults = await Promise.all(allocationPromises);
|
||||
|
||||
// 결과 처리
|
||||
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(", ");
|
||||
toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`);
|
||||
console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`);
|
||||
|
|
@ -863,7 +878,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
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);
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
|
@ -936,7 +956,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
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);
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
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 { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
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 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) {
|
||||
// 현재 행의 기본키 값 가져오기
|
||||
|
|
|
|||
|
|
@ -335,13 +335,42 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
// 동적 대화형 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
// 조건부 표시 평가
|
||||
// 조건부 표시 평가 (기존 conditional 시스템)
|
||||
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
|
||||
|
||||
// 조건에 따라 숨김 처리
|
||||
if (!conditionalResult.visible) {
|
||||
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)) {
|
||||
|
|
@ -533,11 +562,31 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
try {
|
||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
||||
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
||||
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]) => {
|
||||
// 배열 데이터는 리피터 데이터이므로 제외
|
||||
if (!Array.isArray(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 {
|
||||
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
}
|
||||
|
|
@ -1018,22 +1067,35 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||
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 = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0,
|
||||
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
|
||||
zIndex: position?.z || 1,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
|
||||
overflow: labelOffset > 0 ? "visible" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
||||
{/* 위젯 렌더링 */}
|
||||
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
||||
{renderInteractiveWidget(component)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@ const WidgetRenderer: React.FC<{
|
|||
tableDisplayData?: any[];
|
||||
[key: string]: any;
|
||||
}> = ({ component, isDesignMode = false, sortBy, sortOrder, tableDisplayData, ...restProps }) => {
|
||||
// 🔧 무조건 로그 (렌더링 확인용)
|
||||
console.log("📦 WidgetRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
|
||||
|
||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||
if (!isWidgetComponent(component)) {
|
||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||
|
|
@ -127,9 +130,6 @@ const WidgetRenderer: React.FC<{
|
|||
const widget = component;
|
||||
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);
|
||||
|
||||
|
|
@ -246,8 +246,17 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
tableDisplayData, // 🆕 화면 표시 데이터
|
||||
...restProps
|
||||
}) => {
|
||||
// 🔧 무조건 로그 - 파일 반영 테스트용 (2024-TEST)
|
||||
console.log("🔷🔷🔷 RealtimePreview 2024:", component.id);
|
||||
|
||||
const { user } = useAuth();
|
||||
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 [actualHeight, setActualHeight] = useState<number | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -741,6 +750,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||
{type === "component" &&
|
||||
(() => {
|
||||
console.log("📦 DynamicComponentRenderer 렌더링:", component.id, "labelDisplay:", component.style?.labelDisplay);
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
|
|
|
|||
|
|
@ -598,12 +598,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
(contentRef as any).current = node;
|
||||
}
|
||||
}}
|
||||
className={`${
|
||||
(component.type === "component" && (component as any).componentType === "flow-widget") ||
|
||||
((component as any).componentType === "conditional-container" && !isDesignMode)
|
||||
? "h-auto"
|
||||
: "h-full"
|
||||
} overflow-visible`}
|
||||
className="h-full overflow-visible"
|
||||
style={{ width: "100%", maxWidth: "100%" }}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
|
|
@ -649,9 +644,9 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
{/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */}
|
||||
{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" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{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);
|
||||
|
||||
// displayName 설정 (디버깅용)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -58,6 +58,7 @@ export interface TableNodeData {
|
|||
label: string;
|
||||
subLabel?: string;
|
||||
isMain?: boolean;
|
||||
isFilterTable?: boolean; // 마스터-디테일의 디테일 테이블인지 (보라색 테두리)
|
||||
isFocused?: boolean; // 포커스된 테이블인지
|
||||
isFaded?: boolean; // 흑백 처리할지
|
||||
columns?: Array<{
|
||||
|
|
@ -448,7 +449,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
|
|||
|
||||
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
|
||||
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 || []);
|
||||
|
|
@ -574,16 +575,19 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
return (
|
||||
<div
|
||||
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
|
||||
// 필터 관련 테이블 (마스터 또는 디테일): 보라색
|
||||
(hasFilterRelation || isFilterSource)
|
||||
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
|
||||
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"
|
||||
// 순수 포커스 (필터 관계 없음): 초록색
|
||||
// 3. 순수 포커스 (필터 관계 없음): 초록색
|
||||
: isFocused
|
||||
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
|
||||
// 흐리게 처리
|
||||
// 4. 흐리게 처리
|
||||
: isFaded
|
||||
? "border-gray-200 opacity-60 bg-card"
|
||||
// 기본
|
||||
// 5. 기본
|
||||
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
|
||||
}`}
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -147,6 +147,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
// 강제 새로고침용 키 (설정 저장 후 시각화 재로딩)
|
||||
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(() => {
|
||||
setFocusedScreenId(null);
|
||||
|
|
@ -170,6 +183,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
|
||||
// 화면별 사용 컬럼 매핑 (화면 ID -> 테이블명 -> 사용 컬럼들)
|
||||
const [screenUsedColumnsMap, setScreenUsedColumnsMap] = useState<Record<number, Record<string, string[]>>>({});
|
||||
|
||||
// 전역 메인 테이블 목록 (우선순위: 메인 > 서브)
|
||||
// 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음
|
||||
const [globalMainTables, setGlobalMainTables] = useState<Set<string>>(new Set());
|
||||
|
||||
// 테이블 컬럼 정보 로드
|
||||
const loadTableColumns = useCallback(
|
||||
|
|
@ -266,24 +283,26 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
const flows = flowsRes.success ? flowsRes.data || [] : [];
|
||||
const relations = relationsRes.success ? relationsRes.data || [] : [];
|
||||
|
||||
// 데이터 흐름에서 연결된 화면들 추가
|
||||
flows.forEach((flow: any) => {
|
||||
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
|
||||
const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
|
||||
if (!exists) {
|
||||
screenList.push({
|
||||
screenId: flow.target_screen_id,
|
||||
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
|
||||
screenCode: "",
|
||||
tableName: "",
|
||||
companyCode: screen.companyCode,
|
||||
isActive: "Y",
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
} as ScreenDefinition);
|
||||
// 데이터 흐름에서 연결된 화면들 추가 (개별 화면 모드에서만 - 그룹 모드에서는 그룹 내 화면만 표시)
|
||||
if (!selectedGroup && screen) {
|
||||
flows.forEach((flow: any) => {
|
||||
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
|
||||
const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
|
||||
if (!exists) {
|
||||
screenList.push({
|
||||
screenId: flow.target_screen_id,
|
||||
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
|
||||
screenCode: "",
|
||||
tableName: "",
|
||||
companyCode: screen.companyCode,
|
||||
isActive: "Y",
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
} as ScreenDefinition);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 화면 레이아웃 요약 정보 로드
|
||||
const screenIds = screenList.map((s) => s.screenId);
|
||||
|
|
@ -305,6 +324,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
subTablesData = subTablesRes.data as Record<number, ScreenSubTablesData>;
|
||||
// 서브 테이블 데이터 저장 (조인 컬럼 정보 포함)
|
||||
setSubTablesDataMap(subTablesData);
|
||||
|
||||
// 전역 메인 테이블 목록 저장 (우선순위 적용용)
|
||||
// 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음
|
||||
const globalMainTablesArr = (subTablesRes as any).globalMainTables as string[] | undefined;
|
||||
if (globalMainTablesArr && Array.isArray(globalMainTablesArr)) {
|
||||
setGlobalMainTables(new Set(globalMainTablesArr));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("레이아웃 요약/서브 테이블 로드 실패:", e);
|
||||
|
|
@ -434,9 +460,27 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
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[]> = {};
|
||||
|
||||
Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => {
|
||||
|
|
@ -444,11 +488,14 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
const subTableNames: string[] = [];
|
||||
|
||||
screenSubData.subTables.forEach((subTable) => {
|
||||
// 메인 테이블에 없는 것만 서브 테이블로 추가
|
||||
if (!mainTableSet.has(subTable.tableName)) {
|
||||
subTableSet.add(subTable.tableName);
|
||||
subTableNames.push(subTable.tableName);
|
||||
// mainTableSet에 있으면 서브 테이블에서 제외 (우선순위: 메인 > 서브)
|
||||
if (mainTableSet.has(subTable.tableName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 조인으로만 연결된 테이블 → 서브 테이블
|
||||
subTableSet.add(subTable.tableName);
|
||||
subTableNames.push(subTable.tableName);
|
||||
});
|
||||
|
||||
if (subTableNames.length > 0) {
|
||||
|
|
@ -539,10 +586,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
|
||||
}));
|
||||
|
||||
// 여러 화면이 같은 테이블 사용하면 "공통 메인 테이블", 아니면 "메인 테이블"
|
||||
const subLabel = linkedScreens.length > 1
|
||||
? `메인 테이블 (${linkedScreens.length}개 화면)`
|
||||
: "메인 테이블";
|
||||
// 테이블 분류에 따른 라벨 결정
|
||||
// 1. 필터 테이블 (마스터-디테일의 디테일): "필터 대상 테이블"
|
||||
// 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({
|
||||
|
|
@ -552,7 +608,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
data: {
|
||||
label: tableName,
|
||||
subLabel: subLabel,
|
||||
isMain: true, // mainTableSet의 모든 테이블은 메인
|
||||
isMain: !isFilterTable, // 필터 테이블은 isMain: false로 설정 (보라색 테두리 표시용)
|
||||
isFilterTable: isFilterTable, // 필터 테이블 여부 표시
|
||||
columns: formattedColumns,
|
||||
// referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정
|
||||
},
|
||||
|
|
|
|||
|
|
@ -51,13 +51,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}) => {
|
||||
// 🔧 component가 없는 경우 방어 처리
|
||||
if (!component) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
컴포넌트 정보를 불러올 수 없습니다.
|
||||
</div>
|
||||
);
|
||||
return <div className="text-muted-foreground p-4 text-sm">컴포넌트 정보를 불러올 수 없습니다.</div>;
|
||||
}
|
||||
|
||||
|
||||
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
||||
const config = component.componentConfig || {};
|
||||
const currentAction = component.componentConfig?.action || {};
|
||||
|
|
@ -122,7 +118,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null);
|
||||
const [modalActionSourceColumns, setModalActionSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalActionTargetColumns, setModalActionTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<Array<{ sourceField: string; targetField: string }>>([]);
|
||||
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<
|
||||
Array<{ sourceField: string; targetField: string }>
|
||||
>([]);
|
||||
const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
|
|
@ -353,7 +351,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
useEffect(() => {
|
||||
const actionType = config.action?.type;
|
||||
if (actionType !== "modal") return;
|
||||
|
||||
|
||||
const autoDetect = config.action?.autoDetectDataSource;
|
||||
if (!autoDetect) {
|
||||
// 데이터 전달이 비활성화되면 상태 초기화
|
||||
|
|
@ -363,19 +361,19 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
setModalActionTargetColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const targetScreenId = config.action?.targetScreenId;
|
||||
if (!targetScreenId) return;
|
||||
|
||||
|
||||
const loadModalActionMappingData = async () => {
|
||||
// 1. 소스 테이블 감지 (현재 화면)
|
||||
let sourceTableName: string | null = currentTableName || null;
|
||||
|
||||
|
||||
// allComponents에서 분할패널/테이블리스트/통합목록 감지
|
||||
for (const comp of allComponents) {
|
||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||
const compConfig = (comp as any).componentConfig || {};
|
||||
|
||||
|
||||
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
||||
sourceTableName = compConfig.leftPanel?.tableName || compConfig.tableName || null;
|
||||
if (sourceTableName) break;
|
||||
|
|
@ -389,9 +387,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
if (sourceTableName) break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setModalActionSourceTable(sourceTableName);
|
||||
|
||||
|
||||
// 2. 대상 화면의 테이블 조회
|
||||
let targetTableName: string | null = null;
|
||||
try {
|
||||
|
|
@ -405,9 +403,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
} catch (error) {
|
||||
console.error("대상 화면 정보 로드 실패:", error);
|
||||
}
|
||||
|
||||
|
||||
setModalActionTargetTable(targetTableName);
|
||||
|
||||
|
||||
// 3. 소스 테이블 컬럼 로드
|
||||
if (sourceTableName) {
|
||||
try {
|
||||
|
|
@ -416,7 +414,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
|
|
@ -429,7 +427,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. 대상 테이블 컬럼 로드
|
||||
if (targetTableName) {
|
||||
try {
|
||||
|
|
@ -438,7 +436,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
|
|
@ -451,7 +449,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
console.error("대상 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 5. 기존 필드 매핑 로드 또는 자동 매핑 생성
|
||||
const existingMappings = config.action?.fieldMappings || [];
|
||||
if (existingMappings.length > 0) {
|
||||
|
|
@ -461,10 +459,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
setModalActionFieldMappings([]); // 빈 배열 = 자동 매핑
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadModalActionMappingData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
config.action?.type,
|
||||
config.action?.autoDetectDataSource,
|
||||
config.action?.targetScreenId,
|
||||
currentTableName,
|
||||
allComponents,
|
||||
]);
|
||||
|
||||
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
|
||||
useEffect(() => {
|
||||
|
|
@ -818,24 +822,26 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="transferData">데이터 전달</SelectItem>
|
||||
|
||||
|
||||
{/* 엑셀 관련 */}
|
||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
|
||||
|
||||
{/* 고급 기능 */}
|
||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
|
||||
|
||||
{/* 특수 기능 (필요 시 사용) */}
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||
|
||||
|
||||
{/* 이벤트 버스 */}
|
||||
<SelectItem value="event">이벤트 발송</SelectItem>
|
||||
|
||||
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
|
||||
|
||||
{/* 복사 */}
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
|
||||
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
|
||||
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
|
||||
<SelectItem value="openModalWithData">(deprecated) 데이터 전달 + 모달 열기</SelectItem>
|
||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||
|
|
@ -983,10 +989,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="auto-detect-data-source" className="text-sm cursor-pointer">
|
||||
<Label htmlFor="auto-detect-data-source" className="cursor-pointer text-sm">
|
||||
선택된 데이터 전달
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
TableList/SplitPanel에서 선택된 데이터를 모달에 자동으로 전달합니다
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -994,11 +1000,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
{/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */}
|
||||
{component.componentConfig?.action?.autoDetectDataSource === true && (
|
||||
<div className="mt-4 space-y-3 rounded-lg border bg-background p-3">
|
||||
<div className="bg-background mt-4 space-y-3 rounded-lg border p-3">
|
||||
{/* 테이블 정보 표시 */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-3 w-3 text-muted-foreground" />
|
||||
<Database className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">소스:</span>
|
||||
<span className="font-medium">{modalActionSourceTable || "감지 중..."}</span>
|
||||
</div>
|
||||
|
|
@ -1010,171 +1016,210 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 테이블이 같으면 자동 매핑 안내 */}
|
||||
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && (
|
||||
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
|
||||
동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다.
|
||||
</div>
|
||||
)}
|
||||
{modalActionSourceTable &&
|
||||
modalActionTargetTable &&
|
||||
modalActionSourceTable === modalActionTargetTable && (
|
||||
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
|
||||
동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블이 다르면 필드 매핑 UI 표시 */}
|
||||
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || []), { sourceField: "", targetField: "" }];
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{/* 소스 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingSourceOpen[index] || false}
|
||||
onOpenChange={(open) => setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.sourceField
|
||||
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingSourceSearch[index] || ""}
|
||||
onValueChange={(val) => setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionSourceColumns
|
||||
.filter((col) =>
|
||||
col.name.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
|
||||
col.label.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase())
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
|
||||
newMappings[index] = { ...newMappings[index], sourceField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", mapping.sourceField === col.name ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
|
||||
{/* 대상 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingTargetOpen[index] || false}
|
||||
onOpenChange={(open) => setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.targetField
|
||||
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||
: "대상 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingTargetSearch[index] || ""}
|
||||
onValueChange={(val) => setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionTargetColumns
|
||||
.filter((col) =>
|
||||
col.name.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
|
||||
col.label.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase())
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
|
||||
newMappings[index] = { ...newMappings[index], targetField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", mapping.targetField === col.name ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{modalActionSourceTable &&
|
||||
modalActionTargetTable &&
|
||||
modalActionSourceTable !== modalActionTargetTable && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter((_: any, i: number) => i !== index);
|
||||
const newMappings = [
|
||||
...(component.componentConfig?.action?.fieldMappings || []),
|
||||
{ sourceField: "", targetField: "" },
|
||||
];
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{/* 소스 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingSourceOpen[index] || false}
|
||||
onOpenChange={(open) =>
|
||||
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.sourceField
|
||||
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label ||
|
||||
mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingSourceSearch[index] || ""}
|
||||
onValueChange={(val) =>
|
||||
setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionSourceColumns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.name
|
||||
.toLowerCase()
|
||||
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
|
||||
col.label
|
||||
.toLowerCase()
|
||||
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()),
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [
|
||||
...(component.componentConfig?.action?.fieldMappings || []),
|
||||
];
|
||||
newMappings[index] = { ...newMappings[index], sourceField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.sourceField === col.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
{/* 대상 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingTargetOpen[index] || false}
|
||||
onOpenChange={(open) =>
|
||||
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.targetField
|
||||
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label ||
|
||||
mapping.targetField
|
||||
: "대상 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingTargetSearch[index] || ""}
|
||||
onValueChange={(val) =>
|
||||
setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionTargetColumns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.name
|
||||
.toLowerCase()
|
||||
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
|
||||
col.label
|
||||
.toLowerCase()
|
||||
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()),
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [
|
||||
...(component.componentConfig?.action?.fieldMappings || []),
|
||||
];
|
||||
newMappings[index] = { ...newMappings[index], targetField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.targetField === col.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0"
|
||||
onClick={() => {
|
||||
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter(
|
||||
(_: any, i: number) => i !== index,
|
||||
);
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1183,9 +1228,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */}
|
||||
{component.componentConfig?.action?.type === "openModalWithData" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-amber-50 p-4 dark:bg-amber-950/20">
|
||||
<h4 className="text-sm font-medium text-foreground">데이터 전달 + 모달 설정</h4>
|
||||
<h4 className="text-foreground text-sm font-medium">데이터 전달 + 모달 설정</h4>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
이 옵션은 "모달 열기" 액션으로 통합되었습니다. 새 개발에서는 "모달 열기" + "선택된 데이터 전달"을 사용하세요.
|
||||
이 옵션은 "모달 열기" 액션으로 통합되었습니다. 새 개발에서는 "모달 열기" + "선택된 데이터 전달"을
|
||||
사용하세요.
|
||||
</p>
|
||||
|
||||
{/* 🆕 블록 기반 제목 빌더 */}
|
||||
|
|
@ -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">
|
||||
<h4 className="text-foreground text-sm font-medium">이벤트 발송 설정</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다.
|
||||
다른 컴포넌트나 서비스에서 이 이벤트를 수신하여 처리할 수 있습니다.
|
||||
버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다. 다른 컴포넌트나 서비스에서 이 이벤트를 수신하여
|
||||
처리할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
|
|
@ -3595,11 +3641,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
type="number"
|
||||
className="h-8 text-xs"
|
||||
placeholder="3"
|
||||
value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3}
|
||||
value={
|
||||
component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3
|
||||
}
|
||||
onChange={(e) => {
|
||||
onUpdateProperty(
|
||||
"componentConfig.action.eventConfig.eventPayload.config.scheduling.leadTimeDays",
|
||||
parseInt(e.target.value) || 3
|
||||
parseInt(e.target.value) || 3,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -3611,11 +3659,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
type="number"
|
||||
className="h-8 text-xs"
|
||||
placeholder="100"
|
||||
value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.maxDailyCapacity || 100}
|
||||
value={
|
||||
component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling
|
||||
?.maxDailyCapacity || 100
|
||||
}
|
||||
onChange={(e) => {
|
||||
onUpdateProperty(
|
||||
"componentConfig.action.eventConfig.eventPayload.config.scheduling.maxDailyCapacity",
|
||||
parseInt(e.target.value) || 100
|
||||
parseInt(e.target.value) || 100,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -3623,8 +3674,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
<div className="rounded-md bg-blue-50 p-2 dark:bg-blue-950/20">
|
||||
<p className="text-xs text-blue-800 dark:text-blue-200">
|
||||
<strong>동작 방식:</strong> 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다.
|
||||
생성 전 미리보기 확인 다이얼로그가 표시됩니다.
|
||||
<strong>동작 방식:</strong> 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다. 생성 전
|
||||
미리보기 확인 다이얼로그가 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||
...dataflowConfig,
|
||||
// 🔧 controlMode 설정 (플로우 제어가 있으면 "flow", 없으면 "none")
|
||||
controlMode: firstValidControl ? "flow" : "none",
|
||||
// 기존 형식 (하위 호환성)
|
||||
selectedDiagramId: firstValidControl?.flowId || null,
|
||||
selectedRelationshipId: null,
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export function ComponentsPanel({
|
|||
"textarea-basic",
|
||||
// V2 컴포넌트로 대체됨
|
||||
"image-widget", // → V2Media (image)
|
||||
"file-upload", // → V2Media (file)
|
||||
// "file-upload", // 🆕 레거시 컴포넌트 노출 (안정적인 파일 업로드)
|
||||
"entity-search-input", // → V2Select (entity 모드)
|
||||
"autocomplete-search-input", // → V2Select (autocomplete 모드)
|
||||
// DataFlow 전용 (일반 화면에서 불필요)
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
definitionName: definition.name,
|
||||
hasConfigPanel: !!definition.configPanel,
|
||||
currentConfig,
|
||||
defaultSort: currentConfig?.defaultSort, // 🔍 defaultSort 확인
|
||||
});
|
||||
|
||||
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
||||
|
|
@ -822,8 +823,12 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨 텍스트</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelText || selectedComponent.label || ""}
|
||||
onChange={(e) => handleUpdate("style.labelText", e.target.value)}
|
||||
value={selectedComponent.style?.labelText !== undefined ? selectedComponent.style.labelText : (selectedComponent.label || selectedComponent.componentConfig?.label || "")}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -857,8 +862,23 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
</div>
|
||||
<div className="flex items-center space-x-2 pt-5">
|
||||
<Checkbox
|
||||
checked={selectedComponent.style?.labelDisplay !== false}
|
||||
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
|
||||
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
|
||||
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"
|
||||
/>
|
||||
<Label className="text-xs">표시</Label>
|
||||
|
|
@ -868,9 +888,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{widget.required !== undefined && (
|
||||
{(isInputField || widget.required !== undefined) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={widget.required === true || selectedComponent.componentConfig?.required === true}
|
||||
|
|
@ -883,7 +903,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
<Label className="text-xs">필수</Label>
|
||||
</div>
|
||||
)}
|
||||
{widget.readonly !== undefined && (
|
||||
{(isInputField || widget.readonly !== undefined) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
||||
|
|
@ -896,7 +916,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
<Label className="text-xs">읽기전용</Label>
|
||||
</div>
|
||||
)}
|
||||
{/* 숨김 옵션 */}
|
||||
{/* 숨김 옵션 - 모든 컴포넌트에서 표시 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
|
||||
|
|
@ -1055,8 +1075,15 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||
onChange={(newConfig) => {
|
||||
console.log("🔧 [V2PropertiesPanel] DynamicConfigPanel onChange:", {
|
||||
componentId: selectedComponent.id,
|
||||
newConfigKeys: Object.keys(newConfig),
|
||||
defaultSort: newConfig.defaultSort,
|
||||
newConfig,
|
||||
});
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
console.log(` -> handleUpdate: componentConfig.${key} =`, value);
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { SelectTypeConfig } from "@/types/screen";
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
placeholder: "",
|
||||
allowClear: false,
|
||||
maxSelections: undefined,
|
||||
defaultValue: "",
|
||||
...config,
|
||||
};
|
||||
|
||||
|
|
@ -32,6 +34,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
placeholder: safeConfig.placeholder,
|
||||
allowClear: safeConfig.allowClear,
|
||||
maxSelections: safeConfig.maxSelections?.toString() || "",
|
||||
defaultValue: safeConfig.defaultValue || "",
|
||||
});
|
||||
|
||||
const [newOption, setNewOption] = useState({ label: "", value: "" });
|
||||
|
|
@ -53,6 +56,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
placeholder: safeConfig.placeholder,
|
||||
allowClear: safeConfig.allowClear,
|
||||
maxSelections: safeConfig.maxSelections?.toString() || "",
|
||||
defaultValue: safeConfig.defaultValue || "",
|
||||
});
|
||||
|
||||
setLocalOptions(
|
||||
|
|
@ -68,6 +72,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
safeConfig.placeholder,
|
||||
safeConfig.allowClear,
|
||||
safeConfig.maxSelections,
|
||||
safeConfig.defaultValue,
|
||||
JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지
|
||||
]);
|
||||
|
||||
|
|
@ -174,6 +179,30 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
/>
|
||||
</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">
|
||||
<Label htmlFor="multiple" className="text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -65,7 +65,34 @@ export function TabsWidget({
|
|||
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
||||
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
|
||||
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
||||
|
||||
|
||||
// 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트
|
||||
useEffect(() => {
|
||||
// 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택
|
||||
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 기반 화면 로드 상태
|
||||
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -82,23 +109,28 @@ export function TabsWidget({
|
|||
for (const tab of visibleTabs) {
|
||||
const extTab = tab as ExtendedTabItem;
|
||||
// screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드
|
||||
if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) {
|
||||
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true }));
|
||||
if (
|
||||
extTab.screenId &&
|
||||
!screenLayouts[tab.id] &&
|
||||
!screenLoadingStates[tab.id] &&
|
||||
(!extTab.components || extTab.components.length === 0)
|
||||
) {
|
||||
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
|
||||
try {
|
||||
const layoutData = await screenApi.getLayout(extTab.screenId);
|
||||
if (layoutData && layoutData.components) {
|
||||
setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components }));
|
||||
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
|
||||
setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
||||
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
||||
} finally {
|
||||
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false }));
|
||||
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadScreenLayouts();
|
||||
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
||||
|
||||
|
|
@ -153,11 +185,7 @@ export function TabsWidget({
|
|||
const getTabsListClass = () => {
|
||||
const baseClass = orientation === "vertical" ? "flex-col" : "";
|
||||
const variantClass =
|
||||
variant === "pills"
|
||||
? "bg-muted p-1 rounded-lg"
|
||||
: variant === "underline"
|
||||
? "border-b"
|
||||
: "bg-muted p-1";
|
||||
variant === "pills" ? "bg-muted p-1 rounded-lg" : variant === "underline" ? "border-b" : "bg-muted p-1";
|
||||
return `${baseClass} ${variantClass}`;
|
||||
};
|
||||
|
||||
|
|
@ -165,47 +193,47 @@ export function TabsWidget({
|
|||
const renderTabContent = (tab: ExtendedTabItem) => {
|
||||
const extTab = tab as ExtendedTabItem;
|
||||
const inlineComponents = tab.components || [];
|
||||
|
||||
|
||||
// 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식
|
||||
if (extTab.screenId && inlineComponents.length === 0) {
|
||||
// 로딩 중
|
||||
if (screenLoadingStates[tab.id]) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-2 text-muted-foreground">화면을 불러오는 중...</span>
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2">화면을 불러오는 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 에러 발생
|
||||
if (screenErrors[tab.id]) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-destructive/50 bg-destructive/5">
|
||||
<div className="border-destructive/50 bg-destructive/5 flex h-full w-full items-center justify-center rounded border-2 border-dashed">
|
||||
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 화면 레이아웃이 로드된 경우
|
||||
const loadedComponents = screenLayouts[tab.id];
|
||||
if (loadedComponents && loadedComponents.length > 0) {
|
||||
return renderScreenComponents(loadedComponents);
|
||||
}
|
||||
|
||||
|
||||
// 아직 로드되지 않은 경우
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식
|
||||
if (inlineComponents.length > 0) {
|
||||
return renderInlineComponents(tab, inlineComponents);
|
||||
}
|
||||
|
||||
|
||||
// 3. 둘 다 없는 경우
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
|
|
@ -219,22 +247,17 @@ export function TabsWidget({
|
|||
// screenId로 로드한 화면 컴포넌트 렌더링
|
||||
const renderScreenComponents = (components: ComponentData[]) => {
|
||||
// InteractiveScreenViewerDynamic 동적 로드
|
||||
const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
|
||||
|
||||
const InteractiveScreenViewerDynamic =
|
||||
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
|
||||
|
||||
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||
const maxBottom = Math.max(
|
||||
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||
300
|
||||
);
|
||||
const maxRight = Math.max(
|
||||
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
||||
400
|
||||
);
|
||||
|
||||
const maxBottom = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 300);
|
||||
const maxRight = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400);
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto"
|
||||
style={{
|
||||
style={{
|
||||
minHeight: maxBottom + 20,
|
||||
minWidth: maxRight + 20,
|
||||
}}
|
||||
|
|
@ -268,17 +291,17 @@ export function TabsWidget({
|
|||
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||
const maxBottom = Math.max(
|
||||
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||
300 // 최소 높이
|
||||
300, // 최소 높이
|
||||
);
|
||||
const maxRight = Math.max(
|
||||
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
||||
400 // 최소 너비
|
||||
400, // 최소 너비
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
minHeight: maxBottom + 20,
|
||||
minWidth: maxRight + 20,
|
||||
}}
|
||||
|
|
@ -292,7 +315,7 @@ export function TabsWidget({
|
|||
className={cn(
|
||||
"absolute",
|
||||
isDesignMode && "cursor-move",
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2"
|
||||
isDesignMode && isSelected && "ring-primary ring-2 ring-offset-2",
|
||||
)}
|
||||
style={{
|
||||
left: comp.position?.x || 0,
|
||||
|
|
@ -353,9 +376,7 @@ export function TabsWidget({
|
|||
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
||||
{tab.label}
|
||||
{tab.components && tab.components.length > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({tab.components.length})
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1 text-xs">({tab.components.length})</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
{allowCloseable && (
|
||||
|
|
@ -363,7 +384,7 @@ export function TabsWidget({
|
|||
onClick={(e) => handleCloseTab(tab.id, e)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-destructive/10"
|
||||
className="hover:bg-destructive/10 absolute top-1/2 right-1 h-5 w-5 -translate-y-1/2 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { WidgetComponent } from "@/types/screen";
|
|||
import { toast } from "sonner";
|
||||
import { apiClient, getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: number; height?: number }; style?: React.CSSProperties }> = ({
|
||||
component,
|
||||
value,
|
||||
onChange,
|
||||
export const ImageWidget: React.FC<
|
||||
WebTypeComponentProps & { size?: { width?: number; height?: number }; style?: React.CSSProperties }
|
||||
> = ({
|
||||
component,
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
isDesignMode = false, // 디자인 모드 여부
|
||||
size, // props로 전달된 size
|
||||
|
|
@ -134,27 +136,23 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
|
|||
{imageUrl ? (
|
||||
// 이미지 표시 모드
|
||||
<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}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="업로드된 이미지"
|
||||
className="h-full w-full object-contain"
|
||||
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";
|
||||
}}
|
||||
/>
|
||||
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="업로드된 이미지"
|
||||
className="h-full w-full object-contain"
|
||||
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";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 호버 시 제거 버튼 */}
|
||||
{!readonly && !isDesignMode && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleRemove}
|
||||
className="gap-2"
|
||||
>
|
||||
<Button size="sm" variant="destructive" onClick={handleRemove} className="gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
이미지 제거
|
||||
</Button>
|
||||
|
|
@ -164,9 +162,9 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
|
|||
) : (
|
||||
// 업로드 영역
|
||||
<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 ${
|
||||
isDesignMode
|
||||
? "cursor-default border-gray-200 bg-gray-50"
|
||||
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
|
||||
? "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"
|
||||
}`}
|
||||
onClick={handleFileSelect}
|
||||
|
|
@ -199,9 +197,7 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
|
|||
/>
|
||||
|
||||
{/* 필수 필드 경고 */}
|
||||
{required && !imageUrl && (
|
||||
<div className="text-xs text-red-500">* 이미지를 업로드해야 합니다</div>
|
||||
)}
|
||||
{required && !imageUrl && <div className="text-xs text-red-500">* 이미지를 업로드해야 합니다</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,18 +22,25 @@ function SelectTrigger({
|
|||
className,
|
||||
size = "xs",
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "xs" | "sm" | "default";
|
||||
}) {
|
||||
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
|
||||
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height;
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
data-size={hasCustomHeight ? undefined : size}
|
||||
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",
|
||||
// 커스텀 높이일 때 기본 패딩 적용
|
||||
hasCustomHeight && "px-2 py-1",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
||||
*
|
||||
* RepeaterTable 및 ItemSelectionModal 재사용
|
||||
*
|
||||
*
|
||||
* 데이터 전달 인터페이스:
|
||||
* - DataProvidable: 선택된 데이터 제공
|
||||
* - DataReceivable: 외부에서 데이터 수신
|
||||
|
|
@ -124,83 +124,91 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
// DataProvidable 인터페이스 구현
|
||||
// 다른 컴포넌트에서 이 리피터의 데이터를 가져갈 수 있게 함
|
||||
// ============================================================
|
||||
const dataProvider: DataProvidable = useMemo(() => ({
|
||||
componentId: parentId || config.fieldName || "unified-repeater",
|
||||
componentType: "unified-repeater",
|
||||
|
||||
// 선택된 행 데이터 반환
|
||||
getSelectedData: () => {
|
||||
return Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean);
|
||||
},
|
||||
|
||||
// 전체 데이터 반환
|
||||
getAllData: () => {
|
||||
return [...data];
|
||||
},
|
||||
|
||||
// 선택 초기화
|
||||
clearSelection: () => {
|
||||
setSelectedRows(new Set());
|
||||
},
|
||||
}), [parentId, config.fieldName, data, selectedRows]);
|
||||
const dataProvider: DataProvidable = useMemo(
|
||||
() => ({
|
||||
componentId: parentId || config.fieldName || "unified-repeater",
|
||||
componentType: "unified-repeater",
|
||||
|
||||
// 선택된 행 데이터 반환
|
||||
getSelectedData: () => {
|
||||
return Array.from(selectedRows)
|
||||
.map((idx) => data[idx])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
// 전체 데이터 반환
|
||||
getAllData: () => {
|
||||
return [...data];
|
||||
},
|
||||
|
||||
// 선택 초기화
|
||||
clearSelection: () => {
|
||||
setSelectedRows(new Set());
|
||||
},
|
||||
}),
|
||||
[parentId, config.fieldName, data, selectedRows],
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// DataReceivable 인터페이스 구현
|
||||
// 외부에서 이 리피터로 데이터를 전달받을 수 있게 함
|
||||
// ============================================================
|
||||
const dataReceiver: DataReceivable = useMemo(() => ({
|
||||
componentId: parentId || config.fieldName || "unified-repeater",
|
||||
componentType: "repeater",
|
||||
|
||||
// 데이터 수신 (append, replace, merge 모드 지원)
|
||||
receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => {
|
||||
if (!incomingData || incomingData.length === 0) return;
|
||||
const dataReceiver: DataReceivable = useMemo(
|
||||
() => ({
|
||||
componentId: parentId || config.fieldName || "unified-repeater",
|
||||
componentType: "repeater",
|
||||
|
||||
// 매핑 규칙 적용
|
||||
const mappedData = incomingData.map((item, index) => {
|
||||
const newRow: any = { _id: `received_${Date.now()}_${index}` };
|
||||
|
||||
if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) {
|
||||
receiverConfig.mappingRules.forEach((rule) => {
|
||||
const sourceValue = item[rule.sourceField];
|
||||
newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue;
|
||||
});
|
||||
} else {
|
||||
// 매핑 규칙 없으면 그대로 복사
|
||||
Object.assign(newRow, item);
|
||||
// 데이터 수신 (append, replace, merge 모드 지원)
|
||||
receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => {
|
||||
if (!incomingData || incomingData.length === 0) return;
|
||||
|
||||
// 매핑 규칙 적용
|
||||
const mappedData = incomingData.map((item, index) => {
|
||||
const newRow: any = { _id: `received_${Date.now()}_${index}` };
|
||||
|
||||
if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) {
|
||||
receiverConfig.mappingRules.forEach((rule) => {
|
||||
const sourceValue = item[rule.sourceField];
|
||||
newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue;
|
||||
});
|
||||
} else {
|
||||
// 매핑 규칙 없으면 그대로 복사
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
// 모드에 따라 데이터 처리
|
||||
switch (receiverConfig.mode) {
|
||||
case "replace":
|
||||
setData(mappedData);
|
||||
onDataChange?.(mappedData);
|
||||
break;
|
||||
case "merge":
|
||||
// 중복 제거 후 병합 (id 또는 _id 기준)
|
||||
const existingIds = new Set(data.map((row) => row.id || row._id));
|
||||
const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id));
|
||||
const mergedData = [...data, ...newItems];
|
||||
setData(mergedData);
|
||||
onDataChange?.(mergedData);
|
||||
break;
|
||||
case "append":
|
||||
default:
|
||||
const appendedData = [...data, ...mappedData];
|
||||
setData(appendedData);
|
||||
onDataChange?.(appendedData);
|
||||
break;
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
},
|
||||
|
||||
// 모드에 따라 데이터 처리
|
||||
switch (receiverConfig.mode) {
|
||||
case "replace":
|
||||
setData(mappedData);
|
||||
onDataChange?.(mappedData);
|
||||
break;
|
||||
case "merge":
|
||||
// 중복 제거 후 병합 (id 또는 _id 기준)
|
||||
const existingIds = new Set(data.map((row) => row.id || row._id));
|
||||
const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id));
|
||||
const mergedData = [...data, ...newItems];
|
||||
setData(mergedData);
|
||||
onDataChange?.(mergedData);
|
||||
break;
|
||||
case "append":
|
||||
default:
|
||||
const appendedData = [...data, ...mappedData];
|
||||
setData(appendedData);
|
||||
onDataChange?.(appendedData);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
// 현재 데이터 반환
|
||||
getData: () => {
|
||||
return [...data];
|
||||
},
|
||||
}), [parentId, config.fieldName, data, onDataChange]);
|
||||
// 현재 데이터 반환
|
||||
getData: () => {
|
||||
return [...data];
|
||||
},
|
||||
}),
|
||||
[parentId, config.fieldName, data, onDataChange],
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// ScreenContext에 DataProvider/DataReceiver 등록
|
||||
|
|
@ -208,7 +216,7 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
useEffect(() => {
|
||||
if (screenContext && (parentId || config.fieldName)) {
|
||||
const componentId = parentId || config.fieldName || "unified-repeater";
|
||||
|
||||
|
||||
screenContext.registerDataProvider(componentId, dataProvider);
|
||||
screenContext.registerDataReceiver(componentId, dataReceiver);
|
||||
|
||||
|
|
@ -231,7 +239,9 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
componentId: parentId || config.fieldName || "unified-repeater",
|
||||
tableName: config.dataSource?.tableName || "",
|
||||
data: data,
|
||||
selectedData: Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean),
|
||||
selectedData: Array.from(selectedRows)
|
||||
.map((idx) => data[idx])
|
||||
.filter(Boolean),
|
||||
});
|
||||
prevDataLengthRef.current = data.length;
|
||||
}
|
||||
|
|
@ -700,19 +710,23 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
);
|
||||
|
||||
// 🆕 채번 API 호출 (비동기)
|
||||
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
|
||||
try {
|
||||
const result = await allocateNumberingCode(ruleId);
|
||||
if (result.success && result.data?.generatedCode) {
|
||||
return result.data.generatedCode;
|
||||
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
|
||||
const generateNumberingCode = useCallback(
|
||||
async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
|
||||
try {
|
||||
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
|
||||
if (result.success && result.data?.generatedCode) {
|
||||
return result.data.generatedCode;
|
||||
}
|
||||
console.error("채번 실패:", result.error);
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error("채번 API 호출 실패:", error);
|
||||
return "";
|
||||
}
|
||||
console.error("채번 실패:", result.error);
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error("채번 API 호출 실패:", error);
|
||||
return "";
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
||||
const handleAddRow = useCallback(async () => {
|
||||
|
|
@ -831,7 +845,8 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
|||
if (match) {
|
||||
const ruleId = match[1];
|
||||
try {
|
||||
const result = await allocateNumberingCode(ruleId);
|
||||
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
|
||||
const result = await allocateNumberingCode(ruleId, undefined, newRow);
|
||||
if (result.success && result.data?.generatedCode) {
|
||||
newRow[key] = result.data.generatedCode;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -311,31 +311,39 @@ export const V2Biz = forwardRef<HTMLDivElement, V2BizProps>(
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="h-full w-full">
|
||||
{renderBiz()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,10 +75,11 @@ const SingleDatePicker = forwardRef<
|
|||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
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,
|
||||
) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -87,6 +88,16 @@ const SingleDatePicker = forwardRef<
|
|||
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, 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(
|
||||
(selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
|
|
@ -115,13 +126,13 @@ const SingleDatePicker = forwardRef<
|
|||
variant="outline"
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"h-10 w-full justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
"h-full w-full justify-start text-left font-normal",
|
||||
!displayText && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value || "날짜 선택"}
|
||||
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
{displayText || placeholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
|
|
@ -211,14 +222,14 @@ const RangeDatePicker = forwardRef<
|
|||
);
|
||||
|
||||
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}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
{value[0] || "시작일"}
|
||||
|
|
@ -248,7 +259,7 @@ const RangeDatePicker = forwardRef<
|
|||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
{value[1] || "종료일"}
|
||||
|
|
@ -290,7 +301,7 @@ const TimePicker = forwardRef<
|
|||
}
|
||||
>(({ value, onChange, disabled, readonly, className }, ref) => {
|
||||
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" />
|
||||
<Input
|
||||
ref={ref}
|
||||
|
|
@ -299,7 +310,7 @@ const TimePicker = forwardRef<
|
|||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
className="h-10 pl-10"
|
||||
className="h-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -346,8 +357,8 @@ const DateTimePicker = forwardRef<
|
|||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex gap-2", className)}>
|
||||
<div className="flex-1">
|
||||
<div ref={ref} className={cn("flex gap-2 h-full", className)}>
|
||||
<div className="flex-1 h-full">
|
||||
<SingleDatePicker
|
||||
value={datePart}
|
||||
onChange={handleDateChange}
|
||||
|
|
@ -358,7 +369,7 @@ const DateTimePicker = forwardRef<
|
|||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<div className="w-1/3 min-w-[100px] h-full">
|
||||
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -409,6 +420,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
maxDate={config.maxDate}
|
||||
disabled={isDisabled}
|
||||
readonly={readonly}
|
||||
placeholder={config.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -444,6 +456,7 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
showToday={config.showToday}
|
||||
disabled={isDisabled}
|
||||
readonly={readonly}
|
||||
placeholder={config.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -453,32 +466,42 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{renderDatePicker()}</div>
|
||||
<div className="h-full w-full">
|
||||
{renderDatePicker()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
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 type {
|
||||
FormStatus,
|
||||
|
|
@ -89,6 +89,12 @@ export interface V2FormContextValue {
|
|||
addRepeaterRow: (fieldName: string, row: Record<string, unknown>) => void;
|
||||
updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void;
|
||||
deleteRepeaterRow: (fieldName: string, index: number) => void;
|
||||
|
||||
// 조건부 레이어 시스템
|
||||
layers: LayerConfig[];
|
||||
setLayers: (layers: LayerConfig[]) => void;
|
||||
evaluateLayer: (layer: LayerConfig) => boolean;
|
||||
isComponentVisible: (componentId: string) => boolean;
|
||||
}
|
||||
|
||||
// ===== Context 생성 =====
|
||||
|
|
|
|||
|
|
@ -462,32 +462,40 @@ export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="h-full w-full">
|
||||
{renderHierarchy()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ const TextareaInput = forwardRef<
|
|||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
|
|
@ -360,9 +360,22 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
// 채번 타입 자동생성 상태
|
||||
const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false);
|
||||
const hasGeneratedNumberingRef = useRef(false);
|
||||
|
||||
// formData를 ref로 관리하여 closure 문제 해결 (채번 코드 생성 시 최신 값 사용)
|
||||
const formDataRef = useRef(formData);
|
||||
formDataRef.current = formData;
|
||||
|
||||
// tableName 추출 (props에서 전달받거나 config에서)
|
||||
const tableName = (props as any).tableName || (config as any).tableName;
|
||||
// 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;
|
||||
|
|
@ -445,8 +458,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
|
||||
// formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시)
|
||||
// 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지)
|
||||
// inputType을 여러 소스에서 확인
|
||||
const propsInputType = (props as any).inputType;
|
||||
const categoryValuesForNumbering = useMemo(() => {
|
||||
const inputType = config.inputType || config.type || "text";
|
||||
const inputType = propsInputType || config.inputType || config.type || "text";
|
||||
if (inputType !== "numbering") return "";
|
||||
// formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외)
|
||||
const categoryFields: Record<string, string> = {};
|
||||
|
|
@ -458,12 +473,13 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
}
|
||||
}
|
||||
return JSON.stringify(categoryFields);
|
||||
}, [config.inputType, config.type, formData, columnName]);
|
||||
}, [propsInputType, config.inputType, config.type, formData, columnName]);
|
||||
|
||||
// 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용)
|
||||
useEffect(() => {
|
||||
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 타입이 아니면 스킵
|
||||
if (inputType !== "numbering") {
|
||||
|
|
@ -524,18 +540,17 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
}
|
||||
|
||||
// detailSettings에서 numberingRuleId 추출
|
||||
if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") {
|
||||
if (targetColumn.detailSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(targetColumn.detailSettings);
|
||||
// 문자열이면 파싱, 객체면 그대로 사용
|
||||
const parsed = typeof targetColumn.detailSettings === "string"
|
||||
? JSON.parse(targetColumn.detailSettings)
|
||||
: targetColumn.detailSettings;
|
||||
numberingRuleIdRef.current = parsed.numberingRuleId || null;
|
||||
|
||||
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
|
||||
if (parsed.numberingRuleId && onFormDataChange && columnName) {
|
||||
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
|
||||
console.log("🔧 채번 규칙 ID를 formData에 저장:", {
|
||||
key: `${columnName}_numberingRuleId`,
|
||||
value: parsed.numberingRuleId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// JSON 파싱 실패
|
||||
|
|
@ -550,8 +565,9 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
return;
|
||||
}
|
||||
|
||||
// 채번 코드 생성 (formData 전달하여 카테고리 값 기반 생성)
|
||||
const previewResponse = await previewNumberingCode(numberingRuleId, formData);
|
||||
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
|
||||
const currentFormData = formDataRef.current;
|
||||
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
|
||||
|
||||
if (previewResponse.success && 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
|
||||
}, [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)
|
||||
const displayValue = autoGeneratedValue ?? value;
|
||||
|
||||
|
|
@ -618,7 +663,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
|
||||
// 타입별 입력 컴포넌트 렌더링
|
||||
const renderInput = () => {
|
||||
const inputType = config.inputType || config.type || "text";
|
||||
const inputType = propsInputType || config.inputType || config.type || "text";
|
||||
switch (inputType) {
|
||||
case "text":
|
||||
return (
|
||||
|
|
@ -754,7 +799,19 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
const newValue = templatePrefix + newUserInput + templateSuffix;
|
||||
userEditedNumberingRef.current = true;
|
||||
setAutoGeneratedValue(newValue);
|
||||
|
||||
// 모든 방법으로 formData 업데이트 시도
|
||||
onChange?.(newValue);
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, newValue);
|
||||
}
|
||||
|
||||
// 커스텀 이벤트로도 전달 (최후의 보루)
|
||||
if (typeof window !== "undefined" && columnName) {
|
||||
window.dispatchEvent(new CustomEvent("numberingValueChanged", {
|
||||
detail: { columnName, value: newValue }
|
||||
}));
|
||||
}
|
||||
}}
|
||||
placeholder="입력"
|
||||
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에서 가져옴
|
||||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{renderInput()}</div>
|
||||
<div className="h-full w-full">
|
||||
{renderInput()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
style={{
|
||||
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>
|
||||
|
|
@ -149,7 +149,7 @@ export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
|
|||
className="flex flex-col overflow-auto"
|
||||
style={{
|
||||
width: size?.width || style?.width || "100%",
|
||||
height: size?.height || style?.height || 400,
|
||||
height: size?.height || style?.height || "100%",
|
||||
}}
|
||||
>
|
||||
<TableListComponent
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,7 +6,7 @@
|
|||
* 렌더링 모드:
|
||||
* - inline: 현재 테이블 컬럼 직접 입력
|
||||
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
||||
*
|
||||
*
|
||||
* RepeaterTable 및 ItemSelectionModal 재사용
|
||||
*/
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
|
||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||
|
||||
|
||||
// 소스 테이블 컬럼 라벨 매핑
|
||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||
|
||||
|
|
@ -72,10 +72,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
|
||||
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||
|
||||
|
||||
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
||||
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
||||
|
||||
|
||||
// 동적 데이터 소스 상태
|
||||
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
||||
|
||||
|
|
@ -88,10 +88,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 전역 리피터 등록
|
||||
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
|
||||
useEffect(() => {
|
||||
const targetTableName = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
|
||||
const targetTableName =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
|
||||
if (targetTableName) {
|
||||
if (!window.__v2RepeaterInstances) {
|
||||
window.__v2RepeaterInstances = new Set();
|
||||
|
|
@ -110,22 +109,21 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
useEffect(() => {
|
||||
const handleSaveEvent = async (event: CustomEvent) => {
|
||||
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
|
||||
const tableName = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
const tableName =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
const eventParentId = event.detail?.parentId;
|
||||
const mainFormData = event.detail?.mainFormData;
|
||||
|
||||
|
||||
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
|
||||
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
||||
|
||||
|
||||
if (!tableName || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// V2Repeater 저장 시작
|
||||
const saveInfo = {
|
||||
tableName,
|
||||
const saveInfo = {
|
||||
tableName,
|
||||
useCustomTable: config.useCustomTable,
|
||||
mainTableName: config.mainTableName,
|
||||
foreignKeyColumn: config.foreignKeyColumn,
|
||||
|
|
@ -145,10 +143,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
} catch {
|
||||
console.warn("테이블 컬럼 정보 조회 실패");
|
||||
}
|
||||
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
|
||||
|
||||
// 내부 필드 제거
|
||||
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
||||
|
||||
|
|
@ -157,14 +155,14 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
if (config.useCustomTable && config.mainTableName) {
|
||||
// 커스텀 테이블: 리피터 데이터만 저장
|
||||
mergedData = { ...cleanRow };
|
||||
|
||||
|
||||
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
|
||||
if (config.foreignKeyColumn) {
|
||||
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
|
||||
// 없으면 마스터 레코드 ID 사용 (기존 동작)
|
||||
const sourceColumn = config.foreignKeySourceColumn;
|
||||
let fkValue: any;
|
||||
|
||||
|
||||
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
|
||||
// mainFormData에서 참조 컬럼 값 가져오기
|
||||
fkValue = mainFormData[sourceColumn];
|
||||
|
|
@ -172,18 +170,18 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 기본: 마스터 레코드 ID 사용
|
||||
fkValue = masterRecordId;
|
||||
}
|
||||
|
||||
|
||||
if (fkValue !== undefined && fkValue !== null) {
|
||||
mergedData[config.foreignKeyColumn] = fkValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 기존 방식: 메인 폼 데이터 병합
|
||||
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
|
||||
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
|
||||
mergedData = {
|
||||
...mainFormDataWithoutId,
|
||||
...cleanRow,
|
||||
};
|
||||
...mainFormDataWithoutId,
|
||||
...cleanRow,
|
||||
};
|
||||
}
|
||||
|
||||
// 유효하지 않은 컬럼 제거
|
||||
|
|
@ -193,10 +191,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
filteredData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ V2Repeater 저장 실패:", error);
|
||||
throw error;
|
||||
|
|
@ -207,14 +204,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.REPEATER_SAVE,
|
||||
async (payload) => {
|
||||
const tableName = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
const tableName =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
if (payload.tableName === tableName) {
|
||||
await handleSaveEvent({ detail: payload } as CustomEvent);
|
||||
}
|
||||
},
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
|
|
@ -223,7 +219,14 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
unsubscribe();
|
||||
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
||||
};
|
||||
}, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]);
|
||||
}, [
|
||||
data,
|
||||
config.dataSource?.tableName,
|
||||
config.useCustomTable,
|
||||
config.mainTableName,
|
||||
config.foreignKeyColumn,
|
||||
parentId,
|
||||
]);
|
||||
|
||||
// 현재 테이블 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -234,7 +237,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
||||
|
||||
|
||||
const columnMap: Record<string, any> = {};
|
||||
columns.forEach((col: any) => {
|
||||
const name = col.columnName || col.column_name || col.name;
|
||||
|
|
@ -320,7 +323,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
|
||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
||||
|
||||
|
||||
const labels: Record<string, string> = {};
|
||||
const categoryCols: string[] = [];
|
||||
|
||||
|
|
@ -364,13 +367,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
calculated: true,
|
||||
width: col.width === "auto" ? undefined : col.width,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 일반 입력 컬럼
|
||||
let type: "text" | "number" | "date" | "select" | "category" = "text";
|
||||
if (inputType === "number" || inputType === "decimal") type = "number";
|
||||
else if (inputType === "date" || inputType === "datetime") type = "date";
|
||||
else if (inputType === "code") type = "select";
|
||||
if (inputType === "number" || inputType === "decimal") type = "number";
|
||||
else if (inputType === "date" || inputType === "datetime") type = "date";
|
||||
else if (inputType === "code") type = "select";
|
||||
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
||||
|
||||
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
|
||||
|
|
@ -383,19 +386,19 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
categoryRef = `${tableName}.${col.key}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
field: col.key,
|
||||
label: col.title || colInfo?.displayName || col.key,
|
||||
type,
|
||||
editable: col.editable !== false,
|
||||
width: col.width === "auto" ? undefined : col.width,
|
||||
required: false,
|
||||
|
||||
return {
|
||||
field: col.key,
|
||||
label: col.title || colInfo?.displayName || col.key,
|
||||
type,
|
||||
editable: col.editable !== false,
|
||||
width: col.width === "auto" ? undefined : col.width,
|
||||
required: false,
|
||||
categoryRef, // 🆕 카테고리 참조 ID 전달
|
||||
hidden: col.hidden, // 🆕 히든 처리
|
||||
autoFill: col.autoFill, // 🆕 자동 입력 설정
|
||||
};
|
||||
});
|
||||
};
|
||||
});
|
||||
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
||||
|
||||
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
||||
|
|
@ -451,26 +454,25 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 데이터 변경 핸들러
|
||||
const handleDataChange = useCallback(
|
||||
(newData: any[]) => {
|
||||
setData(newData);
|
||||
|
||||
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
|
||||
if (onDataChange) {
|
||||
const targetTable = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
|
||||
if (targetTable) {
|
||||
// 각 행에 _targetTable 추가
|
||||
const dataWithTarget = newData.map(row => ({
|
||||
...row,
|
||||
_targetTable: targetTable,
|
||||
}));
|
||||
onDataChange(dataWithTarget);
|
||||
} else {
|
||||
onDataChange(newData);
|
||||
setData(newData);
|
||||
|
||||
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
|
||||
if (onDataChange) {
|
||||
const targetTable =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
|
||||
if (targetTable) {
|
||||
// 각 행에 _targetTable 추가
|
||||
const dataWithTarget = newData.map((row) => ({
|
||||
...row,
|
||||
_targetTable: targetTable,
|
||||
}));
|
||||
onDataChange(dataWithTarget);
|
||||
} else {
|
||||
onDataChange(newData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
||||
setAutoWidthTrigger((prev) => prev + 1);
|
||||
},
|
||||
|
|
@ -480,26 +482,25 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 행 변경 핸들러
|
||||
const handleRowChange = useCallback(
|
||||
(index: number, newRow: any) => {
|
||||
const newData = [...data];
|
||||
newData[index] = newRow;
|
||||
setData(newData);
|
||||
|
||||
// 🆕 _targetTable 메타데이터 포함
|
||||
if (onDataChange) {
|
||||
const targetTable = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
|
||||
if (targetTable) {
|
||||
const dataWithTarget = newData.map(row => ({
|
||||
...row,
|
||||
_targetTable: targetTable,
|
||||
}));
|
||||
onDataChange(dataWithTarget);
|
||||
} else {
|
||||
onDataChange(newData);
|
||||
const newData = [...data];
|
||||
newData[index] = newRow;
|
||||
setData(newData);
|
||||
|
||||
// 🆕 _targetTable 메타데이터 포함
|
||||
if (onDataChange) {
|
||||
const targetTable =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
|
||||
if (targetTable) {
|
||||
const dataWithTarget = newData.map((row) => ({
|
||||
...row,
|
||||
_targetTable: targetTable,
|
||||
}));
|
||||
onDataChange(dataWithTarget);
|
||||
} else {
|
||||
onDataChange(newData);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
||||
);
|
||||
|
|
@ -507,16 +508,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 행 삭제 핸들러
|
||||
const handleRowDelete = useCallback(
|
||||
(index: number) => {
|
||||
const newData = data.filter((_, i) => i !== index);
|
||||
const newData = data.filter((_, i) => i !== index);
|
||||
handleDataChange(newData); // 🆕 handleDataChange 사용
|
||||
|
||||
// 선택 상태 업데이트
|
||||
const newSelected = new Set<number>();
|
||||
selectedRows.forEach((i) => {
|
||||
if (i < index) newSelected.add(i);
|
||||
else if (i > index) newSelected.add(i - 1);
|
||||
});
|
||||
setSelectedRows(newSelected);
|
||||
|
||||
// 선택 상태 업데이트
|
||||
const newSelected = new Set<number>();
|
||||
selectedRows.forEach((i) => {
|
||||
if (i < index) newSelected.add(i);
|
||||
else if (i > index) newSelected.add(i - 1);
|
||||
});
|
||||
setSelectedRows(newSelected);
|
||||
},
|
||||
[data, selectedRows, handleDataChange],
|
||||
);
|
||||
|
|
@ -535,30 +536,30 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
if (!col.autoFill || col.autoFill.type === "none") return undefined;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
|
||||
switch (col.autoFill.type) {
|
||||
case "currentDate":
|
||||
return now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
|
||||
|
||||
case "currentDateTime":
|
||||
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
|
||||
|
||||
|
||||
case "sequence":
|
||||
return rowIndex + 1; // 1부터 시작하는 순번
|
||||
|
||||
|
||||
case "numbering":
|
||||
// 채번은 별도 비동기 처리 필요
|
||||
return null; // null 반환하여 비동기 처리 필요함을 표시
|
||||
|
||||
|
||||
case "fromMainForm":
|
||||
if (col.autoFill.sourceField && mainFormData) {
|
||||
return mainFormData[col.autoFill.sourceField];
|
||||
}
|
||||
return "";
|
||||
|
||||
|
||||
case "fixed":
|
||||
return col.autoFill.fixedValue ?? "";
|
||||
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -567,19 +568,23 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
);
|
||||
|
||||
// 🆕 채번 API 호출 (비동기)
|
||||
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
|
||||
try {
|
||||
const result = await allocateNumberingCode(ruleId);
|
||||
if (result.success && result.data?.generatedCode) {
|
||||
return result.data.generatedCode;
|
||||
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
|
||||
const generateNumberingCode = useCallback(
|
||||
async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
|
||||
try {
|
||||
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
|
||||
if (result.success && result.data?.generatedCode) {
|
||||
return result.data.generatedCode;
|
||||
}
|
||||
console.error("채번 실패:", result.error);
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error("채번 API 호출 실패:", error);
|
||||
return "";
|
||||
}
|
||||
console.error("채번 실패:", result.error);
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error("채번 API 호출 실패:", error);
|
||||
return "";
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
||||
const handleAddRow = useCallback(async () => {
|
||||
|
|
@ -588,7 +593,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
} else {
|
||||
const newRow: any = { _id: `new_${Date.now()}` };
|
||||
const currentRowCount = data.length;
|
||||
|
||||
|
||||
// 먼저 동기적 자동 입력 값 적용
|
||||
for (const col of config.columns) {
|
||||
const autoValue = generateAutoFillValueSync(col, currentRowCount);
|
||||
|
|
@ -598,10 +603,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
} else if (autoValue !== undefined) {
|
||||
newRow[col.key] = autoValue;
|
||||
} else {
|
||||
newRow[col.key] = "";
|
||||
newRow[col.key] = "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const newData = [...data, newRow];
|
||||
handleDataChange(newData);
|
||||
}
|
||||
|
|
@ -610,23 +615,23 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 모달에서 항목 선택 - 비동기로 변경
|
||||
const handleSelectItems = useCallback(
|
||||
async (items: Record<string, unknown>[]) => {
|
||||
const fkColumn = config.dataSource?.foreignKey;
|
||||
const fkColumn = config.dataSource?.foreignKey;
|
||||
const currentRowCount = data.length;
|
||||
|
||||
// 채번이 필요한 컬럼 찾기
|
||||
const numberingColumns = config.columns.filter(
|
||||
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId
|
||||
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId,
|
||||
);
|
||||
|
||||
|
||||
const newRows = await Promise.all(
|
||||
items.map(async (item, index) => {
|
||||
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
|
||||
|
||||
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
|
||||
|
||||
// FK 값 저장 (resolvedReferenceKey 사용)
|
||||
if (fkColumn && item[resolvedReferenceKey]) {
|
||||
row[fkColumn] = item[resolvedReferenceKey];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 모든 컬럼 처리 (순서대로)
|
||||
for (const col of config.columns) {
|
||||
if (col.isSourceDisplay) {
|
||||
|
|
@ -642,20 +647,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
row[col.key] = autoValue;
|
||||
} else if (row[col.key] === undefined) {
|
||||
// 입력 컬럼: 빈 값으로 초기화
|
||||
row[col.key] = "";
|
||||
}
|
||||
row[col.key] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
})
|
||||
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
|
||||
const newData = [...data, ...newRows];
|
||||
|
||||
const newData = [...data, ...newRows];
|
||||
handleDataChange(newData);
|
||||
setModalOpen(false);
|
||||
setModalOpen(false);
|
||||
},
|
||||
[config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode],
|
||||
[
|
||||
config.dataSource?.foreignKey,
|
||||
resolvedReferenceKey,
|
||||
config.columns,
|
||||
data,
|
||||
handleDataChange,
|
||||
generateAutoFillValueSync,
|
||||
generateNumberingCode,
|
||||
],
|
||||
);
|
||||
|
||||
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
|
||||
|
|
@ -669,19 +682,19 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeFormSave = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const formData = customEvent.detail?.formData;
|
||||
|
||||
|
||||
if (!formData || !dataRef.current.length) return;
|
||||
|
||||
// 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환
|
||||
const processedData = await Promise.all(
|
||||
dataRef.current.map(async (row) => {
|
||||
const newRow = { ...row };
|
||||
|
||||
|
||||
for (const key of Object.keys(newRow)) {
|
||||
const value = newRow[key];
|
||||
if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) {
|
||||
|
|
@ -690,7 +703,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
if (match) {
|
||||
const ruleId = match[1];
|
||||
try {
|
||||
const result = await allocateNumberingCode(ruleId);
|
||||
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
|
||||
const result = await allocateNumberingCode(ruleId, undefined, newRow);
|
||||
if (result.success && result.data?.generatedCode) {
|
||||
newRow[key] = result.data.generatedCode;
|
||||
} else {
|
||||
|
|
@ -704,16 +718,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return newRow;
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
// 처리된 데이터를 formData에 추가
|
||||
const fieldName = config.fieldName || "repeaterData";
|
||||
formData[fieldName] = processedData;
|
||||
};
|
||||
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.FORM_SAVE_COLLECT,
|
||||
|
|
@ -724,12 +738,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
} as CustomEvent;
|
||||
await handleBeforeFormSave(fakeEvent);
|
||||
},
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
|
|
@ -742,20 +756,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const handleComponentDataTransfer = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {};
|
||||
|
||||
|
||||
// 이 컴포넌트가 대상인지 확인
|
||||
if (targetComponentId !== parentId && targetComponentId !== config.fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!transferData || transferData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
// 매핑 규칙이 있으면 적용
|
||||
mappingRules.forEach((rule: any) => {
|
||||
|
|
@ -765,10 +779,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 매핑 규칙 없으면 그대로 복사
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
|
|
@ -782,20 +796,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
handleDataChange([...data, ...mappedData]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달
|
||||
const handleSplitPanelDataTransfer = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
|
||||
|
||||
|
||||
if (!transferData || transferData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
mappingRules.forEach((rule: any) => {
|
||||
newRow[rule.targetField] = item[rule.sourceField];
|
||||
|
|
@ -803,10 +817,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
} else {
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
|
|
@ -814,7 +828,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
handleDataChange([...data, ...mappedData]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribeComponent = v2EventBus.subscribe(
|
||||
V2_EVENTS.COMPONENT_DATA_TRANSFER,
|
||||
|
|
@ -829,7 +843,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
} as CustomEvent;
|
||||
handleComponentDataTransfer(fakeEvent);
|
||||
},
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
||||
);
|
||||
|
||||
const unsubscribeSplitPanel = v2EventBus.subscribe(
|
||||
|
|
@ -844,13 +858,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
} as CustomEvent;
|
||||
handleSplitPanelDataTransfer(fakeEvent);
|
||||
},
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
|
||||
|
||||
return () => {
|
||||
unsubscribeComponent();
|
||||
unsubscribeSplitPanel();
|
||||
|
|
@ -926,11 +940,7 @@ V2Repeater.displayName = "V2Repeater";
|
|||
// V2ErrorBoundary로 래핑된 안전한 버전 export
|
||||
export const SafeV2Repeater: React.FC<V2RepeaterProps> = (props) => {
|
||||
return (
|
||||
<V2ErrorBoundary
|
||||
componentId={props.parentId || "v2-repeater"}
|
||||
componentType="V2Repeater"
|
||||
fallbackStyle="compact"
|
||||
>
|
||||
<V2ErrorBoundary componentId={props.parentId || "v2-repeater"} componentType="V2Repeater" fallbackStyle="compact">
|
||||
<V2Repeater {...props} />
|
||||
</V2ErrorBoundary>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
allowClear?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}>(({
|
||||
options,
|
||||
value,
|
||||
|
|
@ -52,7 +53,8 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
maxSelect,
|
||||
allowClear = true,
|
||||
disabled,
|
||||
className
|
||||
className,
|
||||
style,
|
||||
}, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
|
@ -64,7 +66,8 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
onValueChange={(v) => onChange?.(v)}
|
||||
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} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -112,13 +115,15 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{/* Button에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
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">
|
||||
{selectedLabels.length > 0
|
||||
|
|
@ -368,9 +373,9 @@ const SwapSelect = forwardRef<HTMLDivElement, {
|
|||
return (
|
||||
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
|
||||
{/* 왼쪽: 선택 가능 */}
|
||||
<div className="flex-1 border rounded-md">
|
||||
<div className="p-2 bg-muted text-xs font-medium border-b">선택 가능</div>
|
||||
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
||||
<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 shrink-0">선택 가능</div>
|
||||
<div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
|
||||
{available.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
|
|
@ -412,9 +417,9 @@ const SwapSelect = forwardRef<HTMLDivElement, {
|
|||
</div>
|
||||
|
||||
{/* 오른쪽: 선택됨 */}
|
||||
<div className="flex-1 border rounded-md">
|
||||
<div className="p-2 bg-primary/10 text-xs font-medium border-b">선택됨</div>
|
||||
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
||||
<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 shrink-0">선택됨</div>
|
||||
<div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
|
||||
{selected.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
|
|
@ -654,24 +659,31 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
// 모드별 컴포넌트 렌더링
|
||||
const renderSelect = () => {
|
||||
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;
|
||||
|
||||
// 명시적 높이가 있을 때만 style 전달, 없으면 undefined (기본 높이 h-6 사용)
|
||||
const heightStyle: React.CSSProperties | undefined = componentHeight
|
||||
? { height: componentHeight }
|
||||
: undefined;
|
||||
|
||||
switch (config.mode) {
|
||||
case "dropdown":
|
||||
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
|
||||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="선택"
|
||||
searchable={config.searchable}
|
||||
searchable={config.mode === "combobox" ? true : config.searchable}
|
||||
multiple={config.multiple}
|
||||
maxSelect={config.maxSelect}
|
||||
allowClear={config.allowClear}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -686,6 +698,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
);
|
||||
|
||||
case "check":
|
||||
case "checkbox": // 🔧 기존 저장된 값 호환
|
||||
return (
|
||||
<CheckSelect
|
||||
options={options}
|
||||
|
|
@ -735,6 +748,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -744,32 +758,40 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
className="relative"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
position: "absolute",
|
||||
top: `-${estimatedLabelHeight}px`,
|
||||
left: 0,
|
||||
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}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="h-full w-full">
|
||||
{renderSelect()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,20 @@ export const V2DateConfigPanel: React.FC<V2DateConfigPanelProps> = ({
|
|||
|
||||
<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">
|
||||
<Label className="text-xs font-medium">표시 형식</Label>
|
||||
|
|
|
|||
|
|
@ -27,11 +27,7 @@ interface V2SelectConfigPanelProps {
|
|||
inputType?: string;
|
||||
}
|
||||
|
||||
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
inputType,
|
||||
}) => {
|
||||
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config, onChange, inputType }) => {
|
||||
// 엔티티 타입인지 확인
|
||||
const isEntityType = inputType === "entity";
|
||||
// 엔티티 테이블의 컬럼 목록
|
||||
|
|
@ -55,18 +51,18 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`);
|
||||
const data = response.data.data || response.data;
|
||||
const columns = data.columns || data || [];
|
||||
|
||||
|
||||
const columnOptions: ColumnOption[] = columns.map((col: any) => {
|
||||
const name = col.columnName || col.column_name || col.name;
|
||||
// displayName 우선 사용
|
||||
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
|
||||
|
||||
|
||||
return {
|
||||
columnName: name,
|
||||
columnLabel: label,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
setEntityColumns(columnOptions);
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
|
|
@ -85,7 +81,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
|
||||
// 정적 옵션 관리
|
||||
const options = config.options || [];
|
||||
|
||||
|
||||
const addOption = () => {
|
||||
const newOptions = [...options, { value: "", label: "" }];
|
||||
updateConfig("options", newOptions);
|
||||
|
|
@ -107,10 +103,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
{/* 선택 모드 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">선택 모드</Label>
|
||||
<Select
|
||||
value={config.mode || "dropdown"}
|
||||
onValueChange={(value) => updateConfig("mode", value)}
|
||||
>
|
||||
<Select value={config.mode || "dropdown"} onValueChange={(value) => updateConfig("mode", value)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="모드 선택" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -130,10 +123,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
{/* 데이터 소스 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||
<Select
|
||||
value={config.source || "static"}
|
||||
onValueChange={(value) => updateConfig("source", value)}
|
||||
>
|
||||
<Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 선택" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -151,49 +141,65 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">옵션 목록</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOption} className="h-6 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</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) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={option.value || ""}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-7 text-xs flex-1"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.label || ""}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="표시 텍스트"
|
||||
className="h-7 text-xs flex-1"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{options.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">
|
||||
옵션을 추가해주세요
|
||||
</p>
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">옵션을 추가해주세요</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
@ -202,16 +208,13 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">코드 그룹</Label>
|
||||
{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>
|
||||
<p className="text-xs text-amber-600">테이블 타입 관리에서 코드 그룹을 설정해주세요</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 엔티티(참조 테이블) 설정 */}
|
||||
{config.source === "entity" && (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -222,16 +225,16 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
readOnly
|
||||
disabled
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 로딩 중 표시 */}
|
||||
{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" />
|
||||
컬럼 목록 로딩 중...
|
||||
</div>
|
||||
|
|
@ -265,7 +268,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground">저장될 값</p>
|
||||
<p className="text-muted-foreground text-[10px]">저장될 값</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 컬럼</Label>
|
||||
|
|
@ -293,7 +296,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground">화면에 표시될 값</p>
|
||||
<p className="text-muted-foreground text-[10px]">화면에 표시될 값</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -311,14 +314,16 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
{/* 추가 옵션 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">추가 옵션</Label>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="multiple"
|
||||
checked={config.multiple || false}
|
||||
onCheckedChange={(checked) => updateConfig("multiple", checked)}
|
||||
/>
|
||||
<label htmlFor="multiple" className="text-xs">다중 선택 허용</label>
|
||||
<label htmlFor="multiple" className="text-xs">
|
||||
다중 선택 허용
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -327,7 +332,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
checked={config.searchable || false}
|
||||
onCheckedChange={(checked) => updateConfig("searchable", checked)}
|
||||
/>
|
||||
<label htmlFor="searchable" className="text-xs">검색 기능</label>
|
||||
<label htmlFor="searchable" className="text-xs">
|
||||
검색 기능
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -336,7 +343,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
|||
checked={config.allowClear !== false}
|
||||
onCheckedChange={(checked) => updateConfig("allowClear", checked)}
|
||||
/>
|
||||
<label htmlFor="allowClear" className="text-xs">값 초기화 허용</label>
|
||||
<label htmlFor="allowClear" className="text-xs">
|
||||
값 초기화 허용
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -298,3 +298,31 @@ export const setRepresentativeFile = async (objid: string): Promise<{
|
|||
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: "파일 정보 조회에 실패했습니다.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,13 +26,9 @@ export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConf
|
|||
* @param menuObjid 현재 메뉴의 objid (선택)
|
||||
* @returns 사용 가능한 채번 규칙 목록
|
||||
*/
|
||||
export async function getAvailableNumberingRules(
|
||||
menuObjid?: number
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
export async function getAvailableNumberingRules(menuObjid?: number): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
const url = menuObjid
|
||||
? `/numbering-rules/available/${menuObjid}`
|
||||
: "/numbering-rules/available";
|
||||
const url = menuObjid ? `/numbering-rules/available/${menuObjid}` : "/numbering-rules/available";
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
|
|
@ -46,7 +42,7 @@ export async function getAvailableNumberingRules(
|
|||
* @returns 해당 테이블의 채번 규칙 목록
|
||||
*/
|
||||
export async function getAvailableNumberingRulesForScreen(
|
||||
tableName: string
|
||||
tableName: string,
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
const response = await apiClient.get("/numbering-rules/available-for-screen", {
|
||||
|
|
@ -70,9 +66,7 @@ export async function getNumberingRuleById(ruleId: string): Promise<ApiResponse<
|
|||
}
|
||||
}
|
||||
|
||||
export async function createNumberingRule(
|
||||
config: NumberingRuleConfig
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
export async function createNumberingRule(config: NumberingRuleConfig): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.post("/numbering-rules", config);
|
||||
return response.data;
|
||||
|
|
@ -83,7 +77,7 @@ export async function createNumberingRule(
|
|||
|
||||
export async function updateNumberingRule(
|
||||
ruleId: string,
|
||||
config: Partial<NumberingRuleConfig>
|
||||
config: Partial<NumberingRuleConfig>,
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/numbering-rules/${ruleId}`, config);
|
||||
|
|
@ -110,7 +104,7 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
|
|||
*/
|
||||
export async function previewNumberingCode(
|
||||
ruleId: string,
|
||||
formData?: Record<string, unknown>
|
||||
formData?: Record<string, unknown>,
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
// ruleId 유효성 검사
|
||||
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
|
||||
|
|
@ -127,11 +121,8 @@ export async function previewNumberingCode(
|
|||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string; message?: string } }; message?: string };
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"코드 미리보기 실패";
|
||||
const errorMessage =
|
||||
err.response?.data?.error || err.response?.data?.message || err.message || "코드 미리보기 실패";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
|
@ -139,12 +130,20 @@ export async function previewNumberingCode(
|
|||
/**
|
||||
* 코드 할당 (저장 시점에 실제 순번 증가)
|
||||
* 실제 저장할 때만 호출
|
||||
* @param ruleId 채번 규칙 ID
|
||||
* @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용)
|
||||
* @param formData 폼 데이터 (카테고리/날짜 기반 채번용)
|
||||
*/
|
||||
export async function allocateNumberingCode(
|
||||
ruleId: string
|
||||
ruleId: string,
|
||||
userInputCode?: string,
|
||||
formData?: Record<string, any>,
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
try {
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`);
|
||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/allocate`, {
|
||||
userInputCode,
|
||||
formData,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || "코드 할당 실패" };
|
||||
|
|
@ -154,9 +153,7 @@ export async function allocateNumberingCode(
|
|||
/**
|
||||
* @deprecated 기존 generateNumberingCode는 previewNumberingCode를 사용하세요
|
||||
*/
|
||||
export async function generateNumberingCode(
|
||||
ruleId: string
|
||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
export async function generateNumberingCode(ruleId: string): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||
console.warn("generateNumberingCode는 deprecated. previewNumberingCode 사용 권장");
|
||||
return previewNumberingCode(ruleId);
|
||||
}
|
||||
|
|
@ -180,13 +177,9 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
|
|||
* numbering_rules 테이블 사용
|
||||
* @param menuObjid 메뉴 OBJID (선택) - 필터링용
|
||||
*/
|
||||
export async function getNumberingRulesFromTest(
|
||||
menuObjid?: number
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
export async function getNumberingRulesFromTest(menuObjid?: number): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
const url = menuObjid
|
||||
? `/numbering-rules/test/list/${menuObjid}`
|
||||
: "/numbering-rules/test/list";
|
||||
const url = menuObjid ? `/numbering-rules/test/list/${menuObjid}` : "/numbering-rules/test/list";
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
|
|
@ -203,7 +196,7 @@ export async function getNumberingRulesFromTest(
|
|||
*/
|
||||
export async function getNumberingRuleByColumn(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
columnName: string,
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.get("/numbering-rules/test/by-column", {
|
||||
|
|
@ -222,9 +215,7 @@ export async function getNumberingRuleByColumn(
|
|||
* [테스트] 테스트 테이블에 채번규칙 저장
|
||||
* numbering_rules 테이블 사용
|
||||
*/
|
||||
export async function saveNumberingRuleToTest(
|
||||
config: NumberingRuleConfig
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
export async function saveNumberingRuleToTest(config: NumberingRuleConfig): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.post("/numbering-rules/test/save", config);
|
||||
return response.data;
|
||||
|
|
@ -240,9 +231,7 @@ export async function saveNumberingRuleToTest(
|
|||
* [테스트] 테스트 테이블에서 채번규칙 삭제
|
||||
* numbering_rules 테이블 사용
|
||||
*/
|
||||
export async function deleteNumberingRuleFromTest(
|
||||
ruleId: string
|
||||
): Promise<ApiResponse<void>> {
|
||||
export async function deleteNumberingRuleFromTest(ruleId: string): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/numbering-rules/test/${ruleId}`);
|
||||
return response.data;
|
||||
|
|
@ -262,7 +251,7 @@ export async function getNumberingRuleByColumnWithCategory(
|
|||
tableName: string,
|
||||
columnName: string,
|
||||
categoryColumn?: string,
|
||||
categoryValueId?: number
|
||||
categoryValueId?: number,
|
||||
): Promise<ApiResponse<NumberingRuleConfig>> {
|
||||
try {
|
||||
const response = await apiClient.get("/numbering-rules/test/by-column-with-category", {
|
||||
|
|
@ -282,7 +271,7 @@ export async function getNumberingRuleByColumnWithCategory(
|
|||
*/
|
||||
export async function getRulesByTableColumn(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
columnName: string,
|
||||
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
||||
try {
|
||||
const response = await apiClient.get("/numbering-rules/test/rules-by-table-column", {
|
||||
|
|
@ -296,4 +285,3 @@ export async function getRulesByTableColumn(
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,10 +31,7 @@ export class ComponentRegistry {
|
|||
throw new Error(`컴포넌트 등록 실패 (${definition.id}): ${validation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
// 중복 등록 체크
|
||||
if (this.components.has(definition.id)) {
|
||||
console.warn(`⚠️ 컴포넌트 중복 등록: ${definition.id} - 기존 정의를 덮어씁니다.`);
|
||||
}
|
||||
// 중복 등록 체크 (기존 정의를 덮어씀)
|
||||
|
||||
// 타임스탬프 추가
|
||||
const enhancedDefinition = {
|
||||
|
|
@ -64,7 +61,6 @@ export class ComponentRegistry {
|
|||
static unregisterComponent(id: string): void {
|
||||
const definition = this.components.get(id);
|
||||
if (!definition) {
|
||||
console.warn(`⚠️ 등록되지 않은 컴포넌트 해제 시도: ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -76,8 +72,6 @@ export class ComponentRegistry {
|
|||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
console.log(`🗑️ 컴포넌트 해제: ${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -355,7 +349,6 @@ export class ComponentRegistry {
|
|||
},
|
||||
force: async () => {
|
||||
// hotReload 기능 비활성화 (불필요)
|
||||
console.log("⚠️ 강제 Hot Reload는 더 이상 필요하지 않습니다");
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
|
||||
|
||||
|
|
@ -217,14 +299,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const columnName = (component as any).columnName;
|
||||
|
||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원)
|
||||
// ⚠️ 단, componentType이 "select-basic" 또는 "v2-select"인 경우는 ComponentRegistry로 처리
|
||||
// (다중선택, 체크박스, 라디오 등 고급 모드 지원)
|
||||
if (
|
||||
(inputType === "category" || webType === "category") &&
|
||||
tableName &&
|
||||
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) {
|
||||
try {
|
||||
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 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 (
|
||||
<CategorySelectComponent
|
||||
tableName={tableName}
|
||||
|
|
@ -252,6 +341,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
disabled={isFieldDisabled}
|
||||
readonly={isFieldReadonly}
|
||||
className="w-full"
|
||||
size={categorySize}
|
||||
style={categoryStyle}
|
||||
label={categoryLabel}
|
||||
id={categoryId}
|
||||
isDesignMode={props.isDesignMode}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
@ -343,7 +437,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const safeProps = filterDOMProps(restProps);
|
||||
|
||||
// 컴포넌트의 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;
|
||||
|
|
@ -412,10 +519,26 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
};
|
||||
|
||||
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블)
|
||||
// 🆕 v2-input도 포함 (채번 규칙 조회 시 tableName 필요)
|
||||
const useConfigTableName =
|
||||
componentType === "entity-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 = {
|
||||
component,
|
||||
|
|
@ -425,14 +548,29 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onDragEnd,
|
||||
size: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
style: finalStyle, // size를 포함한 최종 style
|
||||
config: component.componentConfig,
|
||||
componentConfig: component.componentConfig,
|
||||
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
||||
...(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에서 추출한 현재 값 전달
|
||||
// 새로운 기능들 전달
|
||||
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,
|
||||
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
|
||||
isInteractive,
|
||||
|
|
@ -440,7 +578,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onFormDataChange,
|
||||
onChange: handleChange, // 개선된 onChange 핸들러 전달
|
||||
// 🆕 엔티티 검색 컴포넌트는 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
|
||||
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
|
||||
selectedScreen, // 🆕 화면 정보
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
|
@ -23,6 +24,20 @@ interface CategorySelectComponentProps {
|
|||
readonly?: boolean;
|
||||
tableName?: 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,
|
||||
tableName: propTableName,
|
||||
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[]>(
|
||||
[]
|
||||
);
|
||||
|
|
@ -97,12 +132,49 @@ export const CategorySelectComponent: React.FC<
|
|||
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) {
|
||||
return (
|
||||
<div className="flex h-10 w-full items-center justify-center rounded-md border bg-background px-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
로딩 중...
|
||||
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -110,8 +182,14 @@ export const CategorySelectComponent: React.FC<
|
|||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-10 w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 text-sm text-destructive">
|
||||
⚠️ {error}
|
||||
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -119,33 +197,44 @@ export const CategorySelectComponent: React.FC<
|
|||
// 카테고리 값이 없음
|
||||
if (categoryValues.length === 0) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled || readonly}
|
||||
required={required}
|
||||
>
|
||||
<SelectTrigger className={`w-full ${className}`}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryValues.map((categoryValue) => (
|
||||
<SelectItem
|
||||
key={categoryValue.valueId}
|
||||
value={categoryValue.valueCode}
|
||||
>
|
||||
{categoryValue.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div id={id} className={`relative ${designModeClass}`} style={wrapperStyle}>
|
||||
{renderLabel()}
|
||||
<div className="h-full w-full">
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled || readonly}
|
||||
required={required}
|
||||
>
|
||||
<SelectTrigger className={`w-full h-full ${className}`} style={heightStyle}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryValues.map((categoryValue) => (
|
||||
<SelectItem
|
||||
key={categoryValue.valueId}
|
||||
value={categoryValue.valueCode}
|
||||
>
|
||||
{categoryValue.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,22 +14,23 @@ import { FileUploadConfig } from "./types";
|
|||
*/
|
||||
export const FileUploadDefinition = createComponentDefinition({
|
||||
id: "file-upload",
|
||||
name: "파일 업로드",
|
||||
nameEng: "FileUpload Component",
|
||||
description: "파일 업로드를 위한 파일 선택 컴포넌트",
|
||||
name: "파일 업로드 (레거시)",
|
||||
nameEng: "FileUpload Component (Legacy)",
|
||||
description: "파일 업로드를 위한 파일 선택 컴포넌트 (레거시)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "file",
|
||||
component: FileUploadComponent,
|
||||
defaultConfig: {
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
defaultSize: { width: 350, height: 240 }, // 40 * 6 (파일 선택 + 목록 표시)
|
||||
defaultSize: { width: 350, height: 240 },
|
||||
configPanel: FileUploadConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/file-upload",
|
||||
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import { ImageWidgetConfigPanel } from "./ImageWidgetConfigPanel";
|
|||
*/
|
||||
export const ImageWidgetDefinition = createComponentDefinition({
|
||||
id: "image-widget",
|
||||
name: "이미지 위젯",
|
||||
nameEng: "Image Widget",
|
||||
description: "이미지 표시 및 업로드",
|
||||
name: "이미지 위젯 (레거시)",
|
||||
nameEng: "Image Widget (Legacy)",
|
||||
description: "이미지 표시 및 업로드 (레거시)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "image",
|
||||
component: ImageWidget,
|
||||
|
|
@ -32,6 +32,7 @@ export const ImageWidgetDefinition = createComponentDefinition({
|
|||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/image-widget",
|
||||
hidden: true, // v2-file-upload 사용으로 패널에서 숨김
|
||||
});
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스
|
|||
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -220,8 +220,8 @@ export function RepeaterTable({
|
|||
columns
|
||||
.filter((col) => !col.hidden)
|
||||
.forEach((col) => {
|
||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||
});
|
||||
widths[col.field] = col.width ? parseInt(col.width) : 120;
|
||||
});
|
||||
return widths;
|
||||
});
|
||||
|
||||
|
|
@ -404,10 +404,10 @@ export function RepeaterTable({
|
|||
// 데이터가 있으면 데이터 기반 자동 맞춤, 없으면 균등 분배
|
||||
const timer = setTimeout(() => {
|
||||
if (data.length > 0) {
|
||||
applyAutoFitWidths();
|
||||
} else {
|
||||
applyEqualizeWidths();
|
||||
}
|
||||
applyAutoFitWidths();
|
||||
} else {
|
||||
applyEqualizeWidths();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
|
|
@ -654,11 +654,17 @@ export function RepeaterTable({
|
|||
<thead className="sticky top-0 z-20 bg-gray-50">
|
||||
<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>
|
||||
</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
|
||||
checked={isAllSelected}
|
||||
// @ts-expect-error - indeterminate는 HTML 속성
|
||||
|
|
@ -667,7 +673,7 @@ export function RepeaterTable({
|
|||
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map((col) => {
|
||||
{visibleColumns.map((col, colIndex) => {
|
||||
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
||||
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
||||
const activeOption = hasDynamicSource
|
||||
|
|
@ -677,7 +683,7 @@ export function RepeaterTable({
|
|||
|
||||
return (
|
||||
<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"
|
||||
style={{ width: `${columnWidths[col.field]}px` }}
|
||||
onDoubleClick={() => handleDoubleClick(col.field)}
|
||||
|
|
@ -765,8 +771,9 @@ export function RepeaterTable({
|
|||
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
||||
<tbody className="bg-white">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<tr key="empty-row">
|
||||
<td
|
||||
key="empty-cell"
|
||||
colSpan={visibleColumns.length + 2}
|
||||
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
|
||||
>
|
||||
|
|
@ -787,8 +794,9 @@ export function RepeaterTable({
|
|||
<>
|
||||
{/* 드래그 핸들 - 좌측 고정 */}
|
||||
<td
|
||||
key={`drag-${rowIndex}`}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
|
@ -806,8 +814,9 @@ export function RepeaterTable({
|
|||
</td>
|
||||
{/* 체크박스 - 좌측 고정 */}
|
||||
<td
|
||||
key={`check-${rowIndex}`}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
|
@ -818,9 +827,9 @@ export function RepeaterTable({
|
|||
/>
|
||||
</td>
|
||||
{/* 데이터 컬럼들 */}
|
||||
{visibleColumns.map((col) => (
|
||||
{visibleColumns.map((col, colIndex) => (
|
||||
<td
|
||||
key={col.field}
|
||||
key={`${rowIndex}-${col.field || colIndex}`}
|
||||
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
|
||||
style={{
|
||||
width: `${columnWidths[col.field]}px`,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -90,7 +90,7 @@ export function SimpleRepeaterTableComponent({
|
|||
const newRowDefaults = componentConfig?.newRowDefaults || {};
|
||||
const summaryConfig = componentConfig?.summaryConfig;
|
||||
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
|
||||
|
||||
|
||||
// 🆕 컴포넌트 레벨의 저장 테이블 설정
|
||||
const componentTargetTable = componentConfig?.targetTable || componentConfig?.saveTable;
|
||||
const componentFkColumn = componentConfig?.fkColumn;
|
||||
|
|
@ -149,14 +149,11 @@ export function SimpleRepeaterTableComponent({
|
|||
}
|
||||
|
||||
// API 호출
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${initialConfig.sourceTable}/data`,
|
||||
{
|
||||
search: filters,
|
||||
page: 1,
|
||||
size: 1000, // 대량 조회
|
||||
}
|
||||
);
|
||||
const response = await apiClient.post(`/table-management/tables/${initialConfig.sourceTable}/data`, {
|
||||
search: filters,
|
||||
page: 1,
|
||||
size: 1000, // 대량 조회
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.data?.data) {
|
||||
const loadedData = response.data.data.data;
|
||||
|
|
@ -182,7 +179,7 @@ export function SimpleRepeaterTableComponent({
|
|||
|
||||
// 2. 조인 데이터 처리
|
||||
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) {
|
||||
|
|
@ -208,25 +205,20 @@ export function SimpleRepeaterTableComponent({
|
|||
const [tableName] = groupKey.split(":");
|
||||
|
||||
// 조인 키 값 수집 (중복 제거)
|
||||
const keyValues = Array.from(new Set(
|
||||
baseMappedData
|
||||
.map((row: any) => row[key])
|
||||
.filter((v: any) => v !== undefined && v !== null)
|
||||
));
|
||||
const keyValues = Array.from(
|
||||
new Set(baseMappedData.map((row: any) => row[key]).filter((v: any) => v !== undefined && v !== null)),
|
||||
);
|
||||
|
||||
if (keyValues.length === 0) return;
|
||||
|
||||
try {
|
||||
// 조인 테이블 조회
|
||||
// refKey(타겟 테이블 컬럼)로 검색
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
|
||||
page: 1,
|
||||
size: 1000,
|
||||
}
|
||||
);
|
||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
|
||||
page: 1,
|
||||
size: 1000,
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.data?.data) {
|
||||
const joinedRows = response.data.data.data;
|
||||
|
|
@ -251,7 +243,7 @@ export function SimpleRepeaterTableComponent({
|
|||
console.error(`조인 실패 (${tableName}):`, error);
|
||||
// 실패 시 무시하고 진행 (값은 undefined)
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -296,7 +288,7 @@ export function SimpleRepeaterTableComponent({
|
|||
// 🆕 컴포넌트 레벨의 targetTable이 설정되어 있으면 우선 사용
|
||||
if (componentTargetTable) {
|
||||
console.log("✅ [SimpleRepeaterTable] 컴포넌트 레벨 저장 테이블 사용:", componentTargetTable);
|
||||
|
||||
|
||||
// 모든 행을 해당 테이블에 저장
|
||||
const dataToSave = value.map((row: any) => {
|
||||
// 메타데이터 필드 제외 (_, _rowIndex 등)
|
||||
|
|
@ -399,9 +391,12 @@ export function SimpleRepeaterTableComponent({
|
|||
// 기존 onFormDataChange도 호출 (호환성)
|
||||
if (onFormDataChange && columnName) {
|
||||
// 테이블별 데이터를 통합하여 전달
|
||||
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
|
||||
rows.map((row: any) => ({ ...row, _targetTable: table }))
|
||||
));
|
||||
onFormDataChange(
|
||||
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;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddRow}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddRow} className="h-8 text-xs">
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCell = (
|
||||
row: any,
|
||||
column: SimpleRepeaterColumnConfig,
|
||||
rowIndex: number
|
||||
) => {
|
||||
const renderCell = (row: any, column: SimpleRepeaterColumnConfig, rowIndex: number) => {
|
||||
const cellValue = row[column.field];
|
||||
|
||||
// 계산 필드는 편집 불가
|
||||
|
|
@ -583,9 +568,7 @@ export function SimpleRepeaterTableComponent({
|
|||
<Input
|
||||
type="number"
|
||||
value={cellValue || ""}
|
||||
onChange={(e) =>
|
||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
||||
}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
|
|
@ -604,19 +587,19 @@ export function SimpleRepeaterTableComponent({
|
|||
return (
|
||||
<Select
|
||||
value={cellValue || ""}
|
||||
onValueChange={(newValue) =>
|
||||
handleCellEdit(rowIndex, column.field, newValue)
|
||||
}
|
||||
onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{column.selectOptions
|
||||
?.filter((option) => option.value && option.value !== "")
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
|
@ -636,11 +619,11 @@ export function SimpleRepeaterTableComponent({
|
|||
// 로딩 중일 때
|
||||
if (isLoading) {
|
||||
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="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
<Loader2 className="text-primary mx-auto mb-2 h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -650,14 +633,14 @@ export function SimpleRepeaterTableComponent({
|
|||
// 에러 발생 시
|
||||
if (loadError) {
|
||||
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="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
|
||||
<X className="h-6 w-6 text-destructive" />
|
||||
<div className="bg-destructive/10 mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<X className="text-destructive h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-destructive mb-1">데이터 로드 실패</p>
|
||||
<p className="text-xs text-muted-foreground">{loadError}</p>
|
||||
<p className="text-destructive mb-1 text-sm font-medium">데이터 로드 실패</p>
|
||||
<p className="text-muted-foreground text-xs">{loadError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -668,30 +651,27 @@ export function SimpleRepeaterTableComponent({
|
|||
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
|
||||
|
||||
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" && (
|
||||
<div className="p-2 border-b bg-muted/50">
|
||||
<div className="bg-muted/50 border-b p-2">
|
||||
<AddRowButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="overflow-x-auto overflow-y-auto"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight }}>
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
{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>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
key={`header-${col.field}`}
|
||||
className="text-muted-foreground px-4 py-2 text-left font-medium"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.label}
|
||||
|
|
@ -699,7 +679,7 @@ export function SimpleRepeaterTableComponent({
|
|||
</th>
|
||||
))}
|
||||
{!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>
|
||||
)}
|
||||
|
|
@ -707,11 +687,8 @@ export function SimpleRepeaterTableComponent({
|
|||
</thead>
|
||||
<tbody className="bg-background">
|
||||
{value.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={totalColumns}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
<tr key="empty-row">
|
||||
<td key="empty-cell" colSpan={totalColumns} className="text-muted-foreground px-4 py-8 text-center">
|
||||
{allowAdd ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span>표시할 데이터가 없습니다</span>
|
||||
|
|
@ -724,25 +701,25 @@ export function SimpleRepeaterTableComponent({
|
|||
</tr>
|
||||
) : (
|
||||
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 && (
|
||||
<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}
|
||||
</td>
|
||||
)}
|
||||
{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)}
|
||||
</td>
|
||||
))}
|
||||
{!readOnly && allowDelete && (
|
||||
<td className="px-4 py-2 text-center">
|
||||
<td key={`delete-${rowIndex}`} className="px-4 py-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRowDelete(rowIndex)}
|
||||
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" />
|
||||
</Button>
|
||||
|
|
@ -757,35 +734,29 @@ export function SimpleRepeaterTableComponent({
|
|||
|
||||
{/* 합계 표시 */}
|
||||
{summaryConfig?.enabled && summaryValues && (
|
||||
<div className={cn(
|
||||
"border-t bg-muted/30 p-3",
|
||||
summaryConfig.position === "bottom-right" && "flex justify-end"
|
||||
)}>
|
||||
<div className={cn(
|
||||
summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full"
|
||||
)}>
|
||||
<div
|
||||
className={cn("bg-muted/30 border-t p-3", summaryConfig.position === "bottom-right" && "flex justify-end")}
|
||||
>
|
||||
<div className={cn(summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full")}>
|
||||
{summaryConfig.title && (
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{summaryConfig.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">{summaryConfig.title}</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
"grid gap-2",
|
||||
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"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) => (
|
||||
<div
|
||||
key={field.field}
|
||||
className={cn(
|
||||
"flex justify-between items-center px-3 py-1.5 rounded",
|
||||
field.highlight ? "bg-primary/10 font-semibold" : "bg-background"
|
||||
"flex items-center justify-between rounded px-3 py-1.5",
|
||||
field.highlight ? "bg-primary/10 font-semibold" : "bg-background",
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">{field.label}</span>
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
field.highlight && "text-primary"
|
||||
)}>
|
||||
<span className="text-muted-foreground text-xs">{field.label}</span>
|
||||
<span className={cn("text-sm font-medium", field.highlight && "text-primary")}>
|
||||
{formatSummaryValue(field, summaryValues[field.field] || 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -797,10 +768,10 @@ export function SimpleRepeaterTableComponent({
|
|||
|
||||
{/* 하단 행 추가 버튼 */}
|
||||
{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 />
|
||||
{maxRows !== Infinity && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{value.length} / {maxRows}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -809,4 +780,3 @@ export function SimpleRepeaterTableComponent({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,14 +115,14 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
|
||||
className="absolute top-0 right-0 h-full px-2 hover:bg-transparent"
|
||||
onClick={() => !isDisabled && setOpen(!open)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronsUpDown className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -149,12 +149,7 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
@ -437,19 +432,19 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
|
||||
// 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블),
|
||||
// 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블),
|
||||
// handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
// 싱글/더블 언더스코어 모두 처리
|
||||
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
|
||||
// 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대)
|
||||
const normalizedKey = key.startsWith("__tableSection_")
|
||||
? key.replace("__tableSection_", "_tableSection_")
|
||||
const normalizedKey = key.startsWith("__tableSection_")
|
||||
? key.replace("__tableSection_", "_tableSection_")
|
||||
: key;
|
||||
event.detail.formData[normalizedKey] = value;
|
||||
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`);
|
||||
}
|
||||
|
||||
|
||||
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
|
||||
if (key.startsWith("_originalTableSectionData_") && Array.isArray(value)) {
|
||||
event.detail.formData[key] = value;
|
||||
|
|
@ -948,13 +943,17 @@ export function UniversalFormModalComponent({
|
|||
// 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장)
|
||||
const originalTableSectionKey = `_originalTableSectionData_${section.id}`;
|
||||
newFormData[originalTableSectionKey] = JSON.parse(JSON.stringify(items));
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`);
|
||||
|
||||
console.log(
|
||||
`[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`,
|
||||
);
|
||||
|
||||
// 기존 originalGroupedData에도 추가 (하위 호환성)
|
||||
if (!groupedDataInitializedRef.current) {
|
||||
setOriginalGroupedData((prev) => {
|
||||
const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))];
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`);
|
||||
console.log(
|
||||
`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`,
|
||||
);
|
||||
return newOriginal;
|
||||
});
|
||||
}
|
||||
|
|
@ -1443,8 +1442,9 @@ export function UniversalFormModalComponent({
|
|||
|
||||
if (isNewRecord || hasNoValue) {
|
||||
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) {
|
||||
mainData[field.columnName] = response.data.generatedCode;
|
||||
}
|
||||
|
|
@ -1638,12 +1638,12 @@ export function UniversalFormModalComponent({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 🆕 연쇄 드롭다운 처리 (selectOptions.type === "cascading" 방식)
|
||||
if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField) {
|
||||
const cascadingOpts = field.selectOptions.cascading;
|
||||
const parentValue = formData[cascadingOpts.parentField];
|
||||
|
||||
|
||||
// selectOptions 기반 cascading config를 CascadingDropdownConfig 형태로 변환
|
||||
const cascadingConfig: CascadingDropdownConfig = {
|
||||
enabled: true,
|
||||
|
|
@ -2392,7 +2392,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value || "");
|
||||
|
||||
|
||||
const allowCustomInput = optionConfig?.allowCustomInput || false;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -2432,14 +2432,14 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
|
||||
className="absolute top-0 right-0 h-full px-2 hover:bg-transparent"
|
||||
onClick={() => !disabled && !loading && setOpen(!open)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronsUpDown className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -2462,12 +2462,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -555,13 +555,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
|
||||
// 스타일 계산
|
||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
// 🔧 사용자가 설정한 크기가 있으면 그대로 사용
|
||||
const componentStyle: React.CSSProperties = {
|
||||
...component.style,
|
||||
...style,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||
|
|
@ -641,19 +638,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
|
||||
// 성공한 경우에만 성공 토스트 표시
|
||||
// edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리
|
||||
// (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시)
|
||||
const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
if (!silentSuccessActions.includes(actionConfig.type)) {
|
||||
// save, delete, submit 액션에서만 성공 메시지 표시
|
||||
// 그 외 액션은 조용히 처리 (불필요한 "완료되었습니다" 토스트 방지)
|
||||
const successToastActions = ["save", "delete", "submit"];
|
||||
if (successToastActions.includes(actionConfig.type)) {
|
||||
// 기본 성공 메시지 결정
|
||||
const defaultSuccessMessage =
|
||||
actionConfig.type === "save"
|
||||
? "저장되었습니다."
|
||||
: actionConfig.type === "delete"
|
||||
? "삭제되었습니다."
|
||||
: actionConfig.type === "submit"
|
||||
? "제출되었습니다."
|
||||
: "완료되었습니다.";
|
||||
: "제출되었습니다.";
|
||||
|
||||
// 커스텀 메시지 사용 조건:
|
||||
// 1. 커스텀 메시지가 있고
|
||||
|
|
@ -1273,19 +1268,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
||||
|
||||
// 공통 버튼 스타일
|
||||
// 🔧 component.style에서 background/backgroundColor 충돌 방지
|
||||
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
|
||||
const userStyle = component.style
|
||||
? Object.fromEntries(
|
||||
Object.entries(component.style).filter(
|
||||
([key]) => !["width", "height", "background", "backgroundColor"].includes(key),
|
||||
),
|
||||
Object.entries(component.style).filter(([key]) => !["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 = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "40px",
|
||||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
|
||||
|
|
@ -1308,7 +1305,31 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
...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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -115,31 +115,36 @@ export function V2CategoryManagerComponent({
|
|||
}, []);
|
||||
|
||||
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 && (
|
||||
<>
|
||||
<div style={{ width: `${leftWidth}%` }} className="pr-3">
|
||||
<CategoryColumnList
|
||||
tableName={effectiveTableName}
|
||||
selectedColumn={selectedColumn?.uniqueKey || null}
|
||||
onColumnSelect={handleColumnSelect}
|
||||
menuObjid={effectiveMenuObjid}
|
||||
/>
|
||||
<div
|
||||
style={{ width: `${leftWidth}%` }}
|
||||
className="flex h-full flex-col overflow-hidden pr-3"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<CategoryColumnList
|
||||
tableName={effectiveTableName}
|
||||
selectedColumn={selectedColumn?.uniqueKey || null}
|
||||
onColumnSelect={handleColumnSelect}
|
||||
menuObjid={effectiveMenuObjid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
<div
|
||||
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" />
|
||||
</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 && (
|
||||
<div className="mb-2 flex items-center justify-end gap-1">
|
||||
|
|
@ -167,8 +172,8 @@ export function V2CategoryManagerComponent({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 값 관리 컴포넌트 */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{/* 카테고리 값 관리 컴포넌트 - 스크롤 가능 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{selectedColumn ? (
|
||||
viewMode === "tree" ? (
|
||||
<CategoryValueManagerTree
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<V2Date
|
||||
id={component.id}
|
||||
label={component.label}
|
||||
label={effectiveLabel}
|
||||
required={component.required}
|
||||
readonly={config.readonly || component.readonly}
|
||||
disabled={config.disabled || component.disabled}
|
||||
|
|
@ -41,7 +46,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
|
|||
config={{
|
||||
dateType: config.dateType || config.webType || "date",
|
||||
format: config.format || "YYYY-MM-DD",
|
||||
placeholder: config.placeholder || "날짜 선택",
|
||||
placeholder: config.placeholder || style.placeholder || "날짜 선택",
|
||||
showTime: config.showTime || false,
|
||||
use24Hours: config.use24Hours ?? true,
|
||||
minDate: config.minDate,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -25,15 +25,29 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
|||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = (value: any) => {
|
||||
console.log("🔄 [V2InputRenderer] handleChange 호출:", {
|
||||
columnName,
|
||||
value,
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
});
|
||||
if (isInteractive && onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value);
|
||||
} else {
|
||||
console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", {
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
columnName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 라벨: style.labelText 우선, 없으면 component.label 사용
|
||||
// style.labelDisplay가 false면 라벨 숨김
|
||||
// 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 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 (
|
||||
<V2Input
|
||||
|
|
|
|||
|
|
@ -3,65 +3,92 @@
|
|||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2MediaDefinition } from "./index";
|
||||
import { V2Media } from "@/components/v2/V2Media";
|
||||
import FileUploadComponent from "../file-upload/FileUploadComponent";
|
||||
|
||||
/**
|
||||
* V2Media 렌더러
|
||||
* 파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입을 지원
|
||||
* 레거시 FileUploadComponent를 사용하여 안정적인 파일 업로드 기능 제공
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2MediaDefinition;
|
||||
|
||||
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 columnName = component.columnName;
|
||||
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,
|
||||
});
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = (value: any) => {
|
||||
if (isInteractive && onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value);
|
||||
}
|
||||
};
|
||||
|
||||
// V1 file-upload, image-widget에서 넘어온 설정 매핑
|
||||
// V1 file-upload에서 사용하는 형태로 설정 매핑
|
||||
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
|
||||
|
||||
// maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용)
|
||||
// maxSize: MB → bytes 변환
|
||||
const maxSizeBytes = config.maxSize
|
||||
? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024)
|
||||
: 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 (
|
||||
<V2Media
|
||||
id={component.id}
|
||||
label={component.label}
|
||||
required={component.required}
|
||||
readonly={config.readonly || component.readonly}
|
||||
disabled={config.disabled || component.disabled}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
config={{
|
||||
type: mediaType,
|
||||
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}
|
||||
<FileUploadComponent
|
||||
component={legacyComponent}
|
||||
componentConfig={legacyComponentConfig}
|
||||
componentStyle={component.style || {}}
|
||||
className=""
|
||||
isInteractive={isInteractive ?? true}
|
||||
isDesignMode={isDesignMode ?? false}
|
||||
formData={formData || {}}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,26 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
const columnName = component.columnName;
|
||||
const tableName = component.tableName || this.props.tableName;
|
||||
|
||||
// formData에서 현재 값 가져오기
|
||||
const currentValue = formData?.[columnName] ?? component.value ?? "";
|
||||
// formData에서 현재 값 가져오기 (기본값 지원)
|
||||
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) => {
|
||||
|
|
@ -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 (
|
||||
<V2Select
|
||||
id={component.id}
|
||||
|
|
@ -51,12 +88,12 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
entityLabelColumn: config.entityLabelColumn,
|
||||
entityValueColumn: config.entityValueColumn,
|
||||
}}
|
||||
style={component.style}
|
||||
size={component.size}
|
||||
tableName={tableName}
|
||||
columnName={columnName}
|
||||
formData={formData}
|
||||
{...restProps}
|
||||
{...restPropsClean}
|
||||
style={effectiveStyle}
|
||||
size={effectiveSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ export const V2SelectDefinition = createComponentDefinition({
|
|||
{ value: "dropdown", label: "드롭다운" },
|
||||
{ value: "combobox", label: "콤보박스 (검색)" },
|
||||
{ value: "radio", label: "라디오 버튼" },
|
||||
{ value: "checkbox", label: "체크박스" },
|
||||
{ value: "check", label: "체크박스" },
|
||||
{ value: "tag", label: "태그" },
|
||||
{ value: "toggle", label: "토글" },
|
||||
{ value: "swap", label: "스왑 (좌우 이동)" },
|
||||
],
|
||||
},
|
||||
source: {
|
||||
|
|
|
|||
|
|
@ -1676,7 +1676,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 커스텀 모달 화면 열기
|
||||
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
||||
|
||||
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
|
||||
// Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드)
|
||||
let primaryKeyName = "id";
|
||||
let primaryKeyValue: any;
|
||||
|
||||
|
|
@ -1686,11 +1686,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
} else if (item.ID !== undefined && item.ID !== null) {
|
||||
primaryKeyName = "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 {
|
||||
// 첫 번째 필드를 Primary Key로 간주
|
||||
const firstKey = Object.keys(item)[0];
|
||||
primaryKeyName = firstKey;
|
||||
primaryKeyValue = item[firstKey];
|
||||
// 테이블명_id 패턴 확인 (예: dept_id, item_id 등)
|
||||
const tableIdKey = rightTableName ? `${rightTableName.replace(/_info$/, "")}_id` : "";
|
||||
if (tableIdKey && item[tableIdKey] !== undefined && item[tableIdKey] !== null) {
|
||||
primaryKeyName = tableIdKey;
|
||||
primaryKeyValue = item[tableIdKey];
|
||||
} else {
|
||||
// 마지막으로 첫 번째 필드를 Primary Key로 간주
|
||||
const firstKey = Object.keys(item)[0];
|
||||
primaryKeyName = firstKey;
|
||||
primaryKeyValue = item[firstKey];
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ 수정 모달 열기:", {
|
||||
|
|
|
|||
|
|
@ -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 TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||
|
||||
|
|
@ -459,6 +459,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
|
||||
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
||||
|
||||
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
|
||||
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
||||
|
||||
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
|
||||
const filteredData = useMemo(() => {
|
||||
|
|
@ -473,14 +476,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용)
|
||||
// 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함)
|
||||
if (Object.keys(headerFilters).length > 0) {
|
||||
result = result.filter((row) => {
|
||||
return Object.entries(headerFilters).every(([columnName, values]) => {
|
||||
if (values.size === 0) return true;
|
||||
|
||||
// 여러 가능한 컬럼명 시도
|
||||
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
|
||||
// joinColumnMapping을 사용하여 조인된 컬럼명 확인
|
||||
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) : "";
|
||||
|
||||
return values.has(cellStr);
|
||||
|
|
@ -541,7 +547,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
|
||||
return result;
|
||||
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]);
|
||||
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
|
@ -554,7 +560,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [tableLabel, setTableLabel] = useState<string>("");
|
||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
||||
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
||||
const [columnMeta, setColumnMeta] = useState<
|
||||
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
||||
>({});
|
||||
|
|
@ -1005,7 +1010,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// unregisterTable 함수는 의존성이 없어 안정적임
|
||||
]);
|
||||
|
||||
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
|
||||
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 (없으면 defaultSort 적용)
|
||||
useEffect(() => {
|
||||
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
|
||||
|
||||
|
|
@ -1019,12 +1024,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setSortColumn(column);
|
||||
setSortDirection(direction);
|
||||
hasInitializedSort.current = true;
|
||||
return;
|
||||
}
|
||||
} 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에서 컬럼 순서 불러오기
|
||||
useEffect(() => {
|
||||
|
|
@ -1465,8 +1478,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
try {
|
||||
const page = tableConfig.pagination?.currentPage || currentPage;
|
||||
const pageSize = localPageSize;
|
||||
const sortBy = sortColumn || undefined;
|
||||
const sortOrder = sortDirection;
|
||||
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
|
||||
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
|
||||
const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection);
|
||||
const search = searchTerm || undefined;
|
||||
|
||||
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
||||
|
|
@ -4047,18 +4061,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const inputType = meta?.inputType || column.inputType;
|
||||
|
||||
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
||||
if (inputType === "image" && value && typeof value === "string") {
|
||||
const imageUrl = getFullImageUrl(value);
|
||||
if (inputType === "image" && 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 (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="이미지"
|
||||
className="h-10 w-10 rounded object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src =
|
||||
"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";
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -319,7 +319,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
|
||||
const handleChange = (key: keyof TableListConfig, value: any) => {
|
||||
// 기존 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) => {
|
||||
|
|
@ -884,6 +886,67 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</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">오름차순 (A→Z, 1→9)</option>
|
||||
<option value="desc">내림차순 (Z→A, 9→1)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가로 스크롤 및 컬럼 고정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -278,6 +278,12 @@ export interface TableListConfig extends ComponentConfig {
|
|||
autoLoad: boolean;
|
||||
refreshInterval?: number; // 초 단위
|
||||
|
||||
// 🆕 기본 정렬 설정
|
||||
defaultSort?: {
|
||||
columnName: string; // 정렬할 컬럼명
|
||||
direction: "asc" | "desc"; // 정렬 방향
|
||||
};
|
||||
|
||||
// 🆕 툴바 버튼 표시 설정
|
||||
toolbar?: ToolbarConfig;
|
||||
|
||||
|
|
|
|||
|
|
@ -475,9 +475,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
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 {
|
||||
...filter,
|
||||
value: filterValue || "",
|
||||
operator, // operator 추가
|
||||
};
|
||||
})
|
||||
.filter((f) => {
|
||||
|
|
|
|||
|
|
@ -84,23 +84,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* v2-text-display는 텍스트 표시 전용이므로 별도 라벨 불필요 */}
|
||||
<div style={textStyle} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{componentConfig.text || "텍스트를 입력하세요"}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,32 +5,10 @@ import { Label } from "@/components/ui/label";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
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 { Select, SelectContent, SelectItem, 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 { cn } from "@/lib/utils";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
|
@ -52,10 +30,7 @@ interface ColumnInfo {
|
|||
displayName: string;
|
||||
}
|
||||
|
||||
export function TimelineSchedulerConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
}: TimelineSchedulerConfigPanelProps) {
|
||||
export function TimelineSchedulerConfigPanel({ config, onChange }: TimelineSchedulerConfigPanelProps) {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
|
||||
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
|
||||
|
|
@ -74,7 +49,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
tableList.map((t: any) => ({
|
||||
tableName: t.table_name || t.tableName,
|
||||
displayName: t.display_name || t.displayName || t.table_name || t.tableName,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -100,7 +75,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -125,7 +100,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -168,11 +143,9 @@ export function TimelineSchedulerConfigPanel({
|
|||
<Accordion type="multiple" defaultValue={["source", "resource", "display"]}>
|
||||
{/* 소스 데이터 설정 (스케줄 생성 기준) */}
|
||||
<AccordionItem value="source">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
스케줄 생성 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionTrigger className="text-sm font-medium">스케줄 생성 설정</AccordionTrigger>
|
||||
<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)
|
||||
</p>
|
||||
|
||||
|
|
@ -208,20 +181,14 @@ export function TimelineSchedulerConfigPanel({
|
|||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{config.sourceConfig?.tableName ? (
|
||||
tables.find((t) => t.tableName === config.sourceConfig?.tableName)
|
||||
?.displayName || config.sourceConfig.tableName
|
||||
) : (
|
||||
"소스 테이블 선택..."
|
||||
)}
|
||||
{config.sourceConfig?.tableName
|
||||
? tables.find((t) => t.tableName === config.sourceConfig?.tableName)?.displayName ||
|
||||
config.sourceConfig.tableName
|
||||
: "소스 테이블 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
|
|
@ -233,9 +200,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
|
|
@ -250,16 +215,12 @@ export function TimelineSchedulerConfigPanel({
|
|||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.sourceConfig?.tableName === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
config.sourceConfig?.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{table.tableName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
@ -272,11 +233,11 @@ export function TimelineSchedulerConfigPanel({
|
|||
|
||||
{/* 소스 필드 매핑 */}
|
||||
{config.sourceConfig?.tableName && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="mt-2 space-y-2">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<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>
|
||||
<Select
|
||||
value={config.sourceConfig?.dueDateField || ""}
|
||||
|
|
@ -293,9 +254,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
스케줄 종료일로 사용됩니다
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[10px]">스케줄 종료일로 사용됩니다</p>
|
||||
</div>
|
||||
|
||||
{/* 수량 필드 */}
|
||||
|
|
@ -339,7 +298,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
</div>
|
||||
|
||||
{/* 그룹명 필드 */}
|
||||
<div className="space-y-1 col-span-2">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-[10px]">그룹명 필드 (품목명)</Label>
|
||||
<Select
|
||||
value={config.sourceConfig?.groupNameField || ""}
|
||||
|
|
@ -365,21 +324,14 @@ export function TimelineSchedulerConfigPanel({
|
|||
|
||||
{/* 리소스 설정 */}
|
||||
<AccordionItem value="resource">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
리소스 설정 (설비/작업자)
|
||||
</AccordionTrigger>
|
||||
<AccordionTrigger className="text-sm font-medium">리소스 설정 (설비/작업자)</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
타임라인 Y축에 표시할 리소스 (설비, 작업자 등)
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-2 text-[10px]">타임라인 Y축에 표시할 리소스 (설비, 작업자 등)</p>
|
||||
|
||||
{/* 리소스 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">리소스 테이블</Label>
|
||||
<Popover
|
||||
open={resourceTableSelectOpen}
|
||||
onOpenChange={setResourceTableSelectOpen}
|
||||
>
|
||||
<Popover open={resourceTableSelectOpen} onOpenChange={setResourceTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -388,20 +340,13 @@ export function TimelineSchedulerConfigPanel({
|
|||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{config.resourceTable ? (
|
||||
tables.find((t) => t.tableName === config.resourceTable)
|
||||
?.displayName || config.resourceTable
|
||||
) : (
|
||||
"리소스 테이블 선택..."
|
||||
)}
|
||||
{config.resourceTable
|
||||
? tables.find((t) => t.tableName === config.resourceTable)?.displayName || config.resourceTable
|
||||
: "리소스 테이블 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
|
|
@ -413,9 +358,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
|
|
@ -430,16 +373,12 @@ export function TimelineSchedulerConfigPanel({
|
|||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.resourceTable === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
config.resourceTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{table.tableName}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
@ -452,7 +391,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
|
||||
{/* 리소스 필드 매핑 */}
|
||||
{config.resourceTable && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="mt-2 space-y-2">
|
||||
<Label className="text-xs font-medium">리소스 필드</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* ID 필드 */}
|
||||
|
|
@ -502,18 +441,14 @@ export function TimelineSchedulerConfigPanel({
|
|||
|
||||
{/* 표시 설정 */}
|
||||
<AccordionItem value="display">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
표시 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionTrigger className="text-sm font-medium">표시 설정</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
{/* 기본 줌 레벨 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">기본 줌 레벨</Label>
|
||||
<Select
|
||||
value={config.defaultZoomLevel || "day"}
|
||||
onValueChange={(v) =>
|
||||
updateConfig({ defaultZoomLevel: v as any })
|
||||
}
|
||||
onValueChange={(v) => updateConfig({ defaultZoomLevel: v as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
|
|
@ -534,9 +469,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
<Input
|
||||
type="number"
|
||||
value={config.height || 500}
|
||||
onChange={(e) =>
|
||||
updateConfig({ height: parseInt(e.target.value) || 500 })
|
||||
}
|
||||
onChange={(e) => updateConfig({ height: parseInt(e.target.value) || 500 })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -547,9 +480,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
<Input
|
||||
type="number"
|
||||
value={config.rowHeight || 50}
|
||||
onChange={(e) =>
|
||||
updateConfig({ rowHeight: parseInt(e.target.value) || 50 })
|
||||
}
|
||||
onChange={(e) => updateConfig({ rowHeight: parseInt(e.target.value) || 50 })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -558,26 +489,17 @@ export function TimelineSchedulerConfigPanel({
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">편집 가능</Label>
|
||||
<Switch
|
||||
checked={config.editable ?? true}
|
||||
onCheckedChange={(v) => updateConfig({ editable: v })}
|
||||
/>
|
||||
<Switch checked={config.editable ?? true} onCheckedChange={(v) => updateConfig({ editable: v })} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">드래그 이동</Label>
|
||||
<Switch
|
||||
checked={config.draggable ?? true}
|
||||
onCheckedChange={(v) => updateConfig({ draggable: v })}
|
||||
/>
|
||||
<Switch checked={config.draggable ?? true} onCheckedChange={(v) => updateConfig({ draggable: v })} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">리사이즈</Label>
|
||||
<Switch
|
||||
checked={config.resizable ?? true}
|
||||
onCheckedChange={(v) => updateConfig({ resizable: v })}
|
||||
/>
|
||||
<Switch checked={config.resizable ?? true} onCheckedChange={(v) => updateConfig({ resizable: v })} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -3,13 +3,7 @@
|
|||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
import {
|
||||
TimelineSchedulerConfig,
|
||||
ScheduleItem,
|
||||
Resource,
|
||||
ZoomLevel,
|
||||
UseTimelineDataResult,
|
||||
} from "../types";
|
||||
import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types";
|
||||
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
||||
|
||||
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
|
||||
|
|
@ -37,16 +31,14 @@ const addDays = (date: Date, days: number): Date => {
|
|||
export function useTimelineData(
|
||||
config: TimelineSchedulerConfig,
|
||||
externalSchedules?: ScheduleItem[],
|
||||
externalResources?: Resource[]
|
||||
externalResources?: Resource[],
|
||||
): UseTimelineDataResult {
|
||||
// 상태
|
||||
const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(
|
||||
config.defaultZoomLevel || "day"
|
||||
);
|
||||
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(config.defaultZoomLevel || "day");
|
||||
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
|
||||
if (config.initialDate) {
|
||||
return new Date(config.initialDate);
|
||||
|
|
@ -69,9 +61,7 @@ export function useTimelineData(
|
|||
}, [viewStartDate, zoomLevel]);
|
||||
|
||||
// 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용
|
||||
const tableName = config.useCustomTable && config.customTableName
|
||||
? config.customTableName
|
||||
: SCHEDULE_TABLE;
|
||||
const tableName = config.useCustomTable && config.customTableName ? config.customTableName : SCHEDULE_TABLE;
|
||||
|
||||
const resourceTableName = config.resourceTable;
|
||||
|
||||
|
|
@ -88,7 +78,7 @@ export function useTimelineData(
|
|||
const fieldMapping = useMemo(() => {
|
||||
const mapping = config.fieldMapping;
|
||||
if (!mapping) return defaultTimelineSchedulerConfig.fieldMapping!;
|
||||
|
||||
|
||||
return {
|
||||
id: mapping.id || mapping.idField || "id",
|
||||
resourceId: mapping.resourceId || mapping.resourceIdField || "resource_id",
|
||||
|
|
@ -134,17 +124,13 @@ export function useTimelineData(
|
|||
sourceKeys: currentSourceKeys,
|
||||
});
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 10000,
|
||||
autoFilter: true,
|
||||
}
|
||||
);
|
||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||
page: 1,
|
||||
size: 10000,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
const responseData =
|
||||
response.data?.data?.data || response.data?.data || [];
|
||||
const responseData = response.data?.data?.data || response.data?.data || [];
|
||||
let rawData = Array.isArray(responseData) ? responseData : [];
|
||||
|
||||
// 클라이언트 측 필터링 적용 (schedule_mng 테이블인 경우)
|
||||
|
|
@ -156,9 +142,7 @@ export function useTimelineData(
|
|||
|
||||
// 선택된 품목 필터 (source_group_key 기준)
|
||||
if (currentSourceKeys.length > 0) {
|
||||
rawData = rawData.filter((row: any) =>
|
||||
currentSourceKeys.includes(row.source_group_key)
|
||||
);
|
||||
rawData = rawData.filter((row: any) => currentSourceKeys.includes(row.source_group_key));
|
||||
}
|
||||
|
||||
console.log("[useTimelineData] 필터링 후 스케줄:", rawData.length, "건");
|
||||
|
|
@ -194,9 +178,7 @@ export function useTimelineData(
|
|||
title: String(row[effectiveMapping.title] || ""),
|
||||
startDate: row[effectiveMapping.startDate] || "",
|
||||
endDate: row[effectiveMapping.endDate] || "",
|
||||
status: effectiveMapping.status
|
||||
? row[effectiveMapping.status] || "planned"
|
||||
: "planned",
|
||||
status: effectiveMapping.status ? row[effectiveMapping.status] || "planned" : "planned",
|
||||
progress,
|
||||
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
|
||||
data: row,
|
||||
|
|
@ -228,26 +210,20 @@ export function useTimelineData(
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${resourceTableName}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
}
|
||||
);
|
||||
const response = await apiClient.post(`/table-management/tables/${resourceTableName}/data`, {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
const responseData =
|
||||
response.data?.data?.data || response.data?.data || [];
|
||||
const responseData = response.data?.data?.data || response.data?.data || [];
|
||||
const rawData = Array.isArray(responseData) ? responseData : [];
|
||||
|
||||
// 데이터를 Resource 형태로 변환
|
||||
const mappedResources: Resource[] = rawData.map((row: any) => ({
|
||||
id: String(row[resourceFieldMapping.id] || ""),
|
||||
name: String(row[resourceFieldMapping.name] || ""),
|
||||
group: resourceFieldMapping.group
|
||||
? row[resourceFieldMapping.group]
|
||||
: undefined,
|
||||
group: resourceFieldMapping.group ? row[resourceFieldMapping.group] : undefined,
|
||||
}));
|
||||
|
||||
setResources(mappedResources);
|
||||
|
|
@ -270,44 +246,41 @@ export function useTimelineData(
|
|||
|
||||
// 이벤트 버스 리스너 - 테이블 선택 변경 (품목 선택 시 해당 스케줄만 표시)
|
||||
useEffect(() => {
|
||||
const unsubscribeSelection = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_SELECTION_CHANGE,
|
||||
(payload) => {
|
||||
console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", {
|
||||
tableName: payload.tableName,
|
||||
selectedCount: payload.selectedCount,
|
||||
});
|
||||
const unsubscribeSelection = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
|
||||
console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", {
|
||||
tableName: payload.tableName,
|
||||
selectedCount: payload.selectedCount,
|
||||
});
|
||||
|
||||
// 설정된 그룹 필드명 사용 (없으면 기본값들 fallback)
|
||||
const groupByField = config.sourceConfig?.groupByField;
|
||||
// 설정된 그룹 필드명 사용 (없으면 기본값들 fallback)
|
||||
const groupByField = config.sourceConfig?.groupByField;
|
||||
|
||||
// 선택된 데이터에서 source_group_key 추출
|
||||
const sourceKeys: string[] = [];
|
||||
for (const row of payload.selectedRows || []) {
|
||||
// 설정된 필드명 우선, 없으면 일반적인 필드명 fallback
|
||||
let key: string | undefined;
|
||||
if (groupByField && row[groupByField]) {
|
||||
key = row[groupByField];
|
||||
} else {
|
||||
// fallback: 일반적으로 사용되는 필드명들
|
||||
key = row.part_code || row.source_group_key || row.item_code;
|
||||
}
|
||||
|
||||
if (key && !sourceKeys.includes(key)) {
|
||||
sourceKeys.push(key);
|
||||
}
|
||||
// 선택된 데이터에서 source_group_key 추출
|
||||
const sourceKeys: string[] = [];
|
||||
for (const row of payload.selectedRows || []) {
|
||||
// 설정된 필드명 우선, 없으면 일반적인 필드명 fallback
|
||||
let key: string | undefined;
|
||||
if (groupByField && row[groupByField]) {
|
||||
key = row[groupByField];
|
||||
} else {
|
||||
// fallback: 일반적으로 사용되는 필드명들
|
||||
key = row.part_code || row.source_group_key || row.item_code;
|
||||
}
|
||||
|
||||
console.log("[useTimelineData] 선택된 그룹 키:", {
|
||||
groupByField,
|
||||
keys: sourceKeys,
|
||||
});
|
||||
|
||||
// 상태 업데이트 및 ref 동기화
|
||||
selectedSourceKeysRef.current = sourceKeys;
|
||||
setSelectedSourceKeys(sourceKeys);
|
||||
if (key && !sourceKeys.includes(key)) {
|
||||
sourceKeys.push(key);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log("[useTimelineData] 선택된 그룹 키:", {
|
||||
groupByField,
|
||||
keys: sourceKeys,
|
||||
});
|
||||
|
||||
// 상태 업데이트 및 ref 동기화
|
||||
selectedSourceKeysRef.current = sourceKeys;
|
||||
setSelectedSourceKeys(sourceKeys);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeSelection();
|
||||
|
|
@ -325,27 +298,21 @@ export function useTimelineData(
|
|||
// 이벤트 버스 리스너 - 스케줄 생성 완료 및 테이블 새로고침
|
||||
useEffect(() => {
|
||||
// TABLE_REFRESH 이벤트 수신 - 스케줄 새로고침
|
||||
const unsubscribeRefresh = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_REFRESH,
|
||||
(payload) => {
|
||||
// schedule_mng 또는 해당 테이블에 대한 새로고침
|
||||
if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) {
|
||||
console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
|
||||
fetchSchedules();
|
||||
}
|
||||
const unsubscribeRefresh = v2EventBus.subscribe(V2_EVENTS.TABLE_REFRESH, (payload) => {
|
||||
// schedule_mng 또는 해당 테이블에 대한 새로고침
|
||||
if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) {
|
||||
console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
|
||||
fetchSchedules();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// SCHEDULE_GENERATE_COMPLETE 이벤트 수신 - 스케줄 자동 생성 완료 시 새로고침
|
||||
const unsubscribeComplete = v2EventBus.subscribe(
|
||||
V2_EVENTS.SCHEDULE_GENERATE_COMPLETE,
|
||||
(payload) => {
|
||||
if (payload.success) {
|
||||
console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
|
||||
fetchSchedules();
|
||||
}
|
||||
const unsubscribeComplete = v2EventBus.subscribe(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, (payload) => {
|
||||
if (payload.success) {
|
||||
console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
|
||||
fetchSchedules();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeRefresh();
|
||||
|
|
@ -390,23 +357,20 @@ export function useTimelineData(
|
|||
if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate;
|
||||
if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId;
|
||||
if (updates.title) updateData[fieldMapping.title] = updates.title;
|
||||
if (updates.status && fieldMapping.status)
|
||||
updateData[fieldMapping.status] = updates.status;
|
||||
if (updates.status && fieldMapping.status) updateData[fieldMapping.status] = updates.status;
|
||||
if (updates.progress !== undefined && fieldMapping.progress)
|
||||
updateData[fieldMapping.progress] = updates.progress;
|
||||
|
||||
await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData);
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setSchedules((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
|
||||
);
|
||||
setSchedules((prev) => prev.map((s) => (s.id === id ? { ...s, ...updates } : s)));
|
||||
} catch (err: any) {
|
||||
console.error("스케줄 업데이트 오류:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[tableName, fieldMapping, config.editable]
|
||||
[tableName, fieldMapping, config.editable],
|
||||
);
|
||||
|
||||
// 스케줄 추가
|
||||
|
|
@ -427,10 +391,7 @@ export function useTimelineData(
|
|||
if (fieldMapping.progress && schedule.progress !== undefined)
|
||||
insertData[fieldMapping.progress] = schedule.progress;
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
insertData
|
||||
);
|
||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, insertData);
|
||||
|
||||
const newId = response.data?.data?.id || Date.now().toString();
|
||||
|
||||
|
|
@ -441,7 +402,7 @@ export function useTimelineData(
|
|||
throw err;
|
||||
}
|
||||
},
|
||||
[tableName, fieldMapping, config.editable]
|
||||
[tableName, fieldMapping, config.editable],
|
||||
);
|
||||
|
||||
// 스케줄 삭제
|
||||
|
|
@ -459,7 +420,7 @@ export function useTimelineData(
|
|||
throw err;
|
||||
}
|
||||
},
|
||||
[tableName, config.editable]
|
||||
[tableName, config.editable],
|
||||
);
|
||||
|
||||
// 새로고침
|
||||
|
|
|
|||
|
|
@ -10,12 +10,7 @@ export type ZoomLevel = "day" | "week" | "month";
|
|||
/**
|
||||
* 스케줄 상태
|
||||
*/
|
||||
export type ScheduleStatus =
|
||||
| "planned"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "delayed"
|
||||
| "cancelled";
|
||||
export type ScheduleStatus = "planned" | "in_progress" | "completed" | "delayed" | "cancelled";
|
||||
|
||||
/**
|
||||
* 스케줄 항목 (간트 바)
|
||||
|
|
@ -107,10 +102,10 @@ export interface ResourceFieldMapping {
|
|||
* 스케줄 타입 (schedule_mng.schedule_type)
|
||||
*/
|
||||
export type ScheduleType =
|
||||
| "PRODUCTION" // 생산계획
|
||||
| "MAINTENANCE" // 정비계획
|
||||
| "SHIPPING" // 배차계획
|
||||
| "WORK_ASSIGN"; // 작업배정
|
||||
| "PRODUCTION" // 생산계획
|
||||
| "MAINTENANCE" // 정비계획
|
||||
| "SHIPPING" // 배차계획
|
||||
| "WORK_ASSIGN"; // 작업배정
|
||||
|
||||
/**
|
||||
* 소스 데이터 설정 (스케줄 생성 기준이 되는 원본 데이터)
|
||||
|
|
|
|||
|
|
@ -484,6 +484,15 @@ export class ButtonActionExecutor {
|
|||
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;
|
||||
|
||||
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
|
||||
|
|
@ -524,6 +533,14 @@ export class ButtonActionExecutor {
|
|||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
|
||||
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음
|
||||
|
||||
// 🔧 디버그: beforeFormSave 이벤트 전 formData 확인
|
||||
console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", {
|
||||
keys: Object.keys(context.formData || {}),
|
||||
hasCompanyImage: "company_image" in (context.formData || {}),
|
||||
companyImageValue: context.formData?.company_image,
|
||||
});
|
||||
|
||||
const beforeSaveEventDetail = {
|
||||
formData: context.formData,
|
||||
skipDefaultSave: false,
|
||||
|
|
@ -539,6 +556,13 @@ export class ButtonActionExecutor {
|
|||
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
|
||||
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) {
|
||||
console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors);
|
||||
|
|
@ -668,6 +692,10 @@ export class ButtonActionExecutor {
|
|||
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -709,7 +737,9 @@ export class ButtonActionExecutor {
|
|||
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
|
||||
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) {
|
||||
const newCode = allocateResult.data.generatedCode;
|
||||
|
|
@ -1002,7 +1032,9 @@ export class ButtonActionExecutor {
|
|||
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||
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) {
|
||||
const newCode = allocateResult.data.generatedCode;
|
||||
|
|
@ -1588,7 +1620,18 @@ export class ButtonActionExecutor {
|
|||
if (config.enableDataflowControl && config.dataflowConfig) {
|
||||
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 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[] = [];
|
||||
|
||||
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
|
||||
|
|
@ -2026,7 +2069,9 @@ export class ButtonActionExecutor {
|
|||
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||
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) {
|
||||
const newCode = allocateResult.data.generatedCode;
|
||||
|
|
@ -2881,8 +2926,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
if (v2ListComponent) {
|
||||
dataSourceId =
|
||||
v2ListComponent.componentConfig.dataSource?.table ||
|
||||
v2ListComponent.componentConfig.tableName;
|
||||
v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.tableName;
|
||||
console.log("✨ V2List 자동 감지:", {
|
||||
componentId: v2ListComponent.id,
|
||||
tableName: dataSourceId,
|
||||
|
|
@ -3015,9 +3059,13 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
// 4. 모달 열기 이벤트 발생
|
||||
// passSelectedData가 true이면 editData로 전달 (수정 모드처럼 모든 필드 표시)
|
||||
// 🔧 수정: openModalWithData는 "신규 등록 + 연결 데이터 전달"용이므로
|
||||
// editData가 아닌 splitPanelParentData로 전달해야 채번 등이 정상 작동함
|
||||
const isPassDataMode = passSelectedData && selectedData.length > 0;
|
||||
|
||||
// 🔧 isEditMode 옵션이 명시적으로 true인 경우에만 수정 모드로 처리
|
||||
const useAsEditData = config.isEditMode === true;
|
||||
|
||||
const modalEvent = new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: config.targetScreenId,
|
||||
|
|
@ -3026,19 +3074,18 @@ export class ButtonActionExecutor {
|
|||
size: config.modalSize || "md",
|
||||
selectedData: selectedData,
|
||||
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
||||
// 🆕 데이터 전달 모드일 때는 editData로 전달하여 모든 필드가 표시되도록 함
|
||||
editData: isPassDataMode ? parentData : undefined,
|
||||
splitPanelParentData: isPassDataMode ? undefined : parentData,
|
||||
// 🔧 수정: isEditMode가 명시적으로 true인 경우에만 editData로 전달
|
||||
// 기본적으로는 splitPanelParentData로 전달하여 신규 등록 + 연결 데이터 모드
|
||||
editData: useAsEditData && isPassDataMode ? parentData : undefined,
|
||||
splitPanelParentData: isPassDataMode ? parentData : undefined,
|
||||
urlParams: dataSourceId ? { dataSourceId } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(modalEvent);
|
||||
|
||||
// 성공 메시지 (autoDetectDataSource 모드에서만)
|
||||
if (autoDetectDataSource && config.successMessage) {
|
||||
toast.success(config.successMessage);
|
||||
}
|
||||
// 모달 열기는 UI 전환이므로 성공 토스트를 표시하지 않음
|
||||
// (저장 등 실제 액션 완료 시에만 토스트 표시)
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -3227,8 +3274,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
window.dispatchEvent(modalEvent);
|
||||
|
||||
// 성공 메시지 (간단하게)
|
||||
toast.success(config.successMessage || "다음 단계로 진행합니다.");
|
||||
// 모달 열기는 UI 전환이므로 성공 토스트를 표시하지 않음
|
||||
|
||||
return true;
|
||||
} else {
|
||||
|
|
@ -3455,10 +3501,13 @@ export class ButtonActionExecutor {
|
|||
const screenModalEvent = new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: config.targetScreenId,
|
||||
title: config.editModalTitle || "데이터 수정",
|
||||
title: isCreateMode ? config.editModalTitle || "데이터 복사" : config.editModalTitle || "데이터 수정",
|
||||
description: description,
|
||||
size: config.modalSize || "lg",
|
||||
editData: rowData, // 🆕 수정 데이터 전달
|
||||
// 🔧 복사 모드에서는 editData 대신 splitPanelParentData로 전달하여 채번이 생성되도록 함
|
||||
editData: isCreateMode ? undefined : rowData,
|
||||
splitPanelParentData: isCreateMode ? rowData : undefined,
|
||||
isCreateMode: isCreateMode, // 🆕 복사 모드 플래그 전달
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(screenModalEvent);
|
||||
|
|
@ -3986,16 +4035,27 @@ export class ButtonActionExecutor {
|
|||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||
|
||||
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||||
// 우선순위: selectedRowsData > savedData > formData
|
||||
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
||||
// - savedData: 저장 API 응답 데이터
|
||||
// - formData: 폼에 입력된 데이터
|
||||
// 🔧 저장 후 제어: savedData > formData > selectedRowsData
|
||||
// - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요!
|
||||
// - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위
|
||||
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;
|
||||
console.log("📦 [executeAfterSaveControl] selectedRowsData 사용:", sourceData);
|
||||
} else {
|
||||
const savedData = context.savedData || context.formData || {};
|
||||
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
||||
sourceData = [];
|
||||
console.warn("⚠️ [executeAfterSaveControl] 데이터 소스 없음!");
|
||||
}
|
||||
|
||||
let allSuccess = true;
|
||||
|
|
@ -4095,16 +4155,25 @@ export class ButtonActionExecutor {
|
|||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||
|
||||
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||||
// 우선순위: selectedRowsData > savedData > formData
|
||||
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
||||
// - savedData: 저장 API 응답 데이터
|
||||
// - formData: 폼에 입력된 데이터
|
||||
// 🔧 저장 후 제어: savedData > formData > selectedRowsData
|
||||
// - 저장 후 제어에서는 방금 저장된 데이터(savedData)가 가장 중요!
|
||||
// - selectedRowsData는 왼쪽 패널 선택 데이터일 수 있으므로 마지막 순위
|
||||
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;
|
||||
console.log("📦 [executeSingleFlowControl] selectedRowsData 사용:", sourceData);
|
||||
} else {
|
||||
const savedData = context.savedData || context.formData || {};
|
||||
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
||||
sourceData = [];
|
||||
console.warn("⚠️ [executeSingleFlowControl] 데이터 소스 없음!");
|
||||
}
|
||||
|
||||
// repeat-screen-modal 데이터가 있으면 병합
|
||||
|
|
@ -7094,7 +7163,7 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
modalSize: "md",
|
||||
passSelectedData: true,
|
||||
autoDetectDataSource: true,
|
||||
successMessage: "다음 단계로 진행합니다.",
|
||||
// 모달 열기는 UI 전환이므로 successMessage 제거
|
||||
},
|
||||
modal: {
|
||||
type: "modal",
|
||||
|
|
|
|||
|
|
@ -43,13 +43,20 @@ export interface ButtonExecutionResult {
|
|||
}
|
||||
|
||||
interface ControlConfig {
|
||||
type: "relationship";
|
||||
relationshipConfig: {
|
||||
type: "relationship" | "flow";
|
||||
relationshipConfig?: {
|
||||
relationshipId: string;
|
||||
relationshipName: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
// 🆕 플로우 기반 제어 설정
|
||||
flowConfig?: {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionPlan {
|
||||
|
|
@ -163,15 +170,22 @@ export class ImprovedButtonActionExecutor {
|
|||
return plan;
|
||||
}
|
||||
|
||||
// enableDataflowControl 체크를 제거하고 dataflowConfig만 있으면 실행
|
||||
// 🔧 controlMode가 없으면 flowConfig/relationshipConfig 존재 여부로 자동 판단
|
||||
const effectiveControlMode = dataflowConfig.controlMode
|
||||
|| (dataflowConfig.flowConfig ? "flow" : null)
|
||||
|| (dataflowConfig.relationshipConfig ? "relationship" : null)
|
||||
|| "none";
|
||||
|
||||
console.log("📋 실행 계획 생성:", {
|
||||
controlMode: dataflowConfig.controlMode,
|
||||
effectiveControlMode,
|
||||
hasFlowConfig: !!dataflowConfig.flowConfig,
|
||||
hasRelationshipConfig: !!dataflowConfig.relationshipConfig,
|
||||
enableDataflowControl: buttonConfig.enableDataflowControl,
|
||||
});
|
||||
|
||||
// 관계 기반 제어만 지원
|
||||
if (dataflowConfig.controlMode === "relationship" && dataflowConfig.relationshipConfig) {
|
||||
// 관계 기반 제어
|
||||
if (effectiveControlMode === "relationship" && dataflowConfig.relationshipConfig) {
|
||||
const control: ControlConfig = {
|
||||
type: "relationship",
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 제어 실행 (관계 또는 외부호출)
|
||||
* 🔥 제어 실행 (관계 또는 플로우)
|
||||
*/
|
||||
private static async executeControls(
|
||||
controls: ControlConfig[],
|
||||
|
|
@ -206,8 +243,16 @@ export class ImprovedButtonActionExecutor {
|
|||
|
||||
for (const control of controls) {
|
||||
try {
|
||||
// 관계 실행만 지원
|
||||
const result = await this.executeRelationship(control.relationshipConfig, formData, context);
|
||||
let result: ExecutionResult;
|
||||
|
||||
// 🆕 제어 타입에 따라 분기 처리
|
||||
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);
|
||||
|
||||
|
|
@ -215,7 +260,7 @@ export class ImprovedButtonActionExecutor {
|
|||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`제어 실행 실패 (${control.type}):`, error);
|
||||
results.push({
|
||||
success: false,
|
||||
|
|
@ -230,6 +275,61 @@ export class ImprovedButtonActionExecutor {
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 관계 실행
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -38,19 +38,19 @@ interface LegacyLayoutData {
|
|||
// ============================================
|
||||
function applyDefaultsToNestedComponents(components: any[]): any[] {
|
||||
if (!Array.isArray(components)) return components;
|
||||
|
||||
|
||||
return components.map((nestedComp: any) => {
|
||||
if (!nestedComp) return nestedComp;
|
||||
|
||||
|
||||
// 중첩 컴포넌트의 타입 확인 (componentType 또는 url에서 추출)
|
||||
let nestedComponentType = nestedComp.componentType;
|
||||
if (!nestedComponentType && nestedComp.url) {
|
||||
nestedComponentType = getComponentTypeFromUrl(nestedComp.url);
|
||||
}
|
||||
|
||||
|
||||
// 결과 객체 초기화 (원본 복사)
|
||||
let result = { ...nestedComp };
|
||||
|
||||
const result = { ...nestedComp };
|
||||
|
||||
// 🆕 탭 위젯인 경우 재귀적으로 탭 내부 컴포넌트도 처리
|
||||
if (nestedComponentType === "v2-tabs-widget") {
|
||||
const config = result.componentConfig || {};
|
||||
|
|
@ -69,31 +69,35 @@ function applyDefaultsToNestedComponents(components: any[]): any[] {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 🆕 분할 패널인 경우 재귀적으로 내부 컴포넌트도 처리
|
||||
if (nestedComponentType === "v2-split-panel-layout") {
|
||||
const config = result.componentConfig || {};
|
||||
result.componentConfig = {
|
||||
...config,
|
||||
leftPanel: config.leftPanel ? {
|
||||
...config.leftPanel,
|
||||
components: applyDefaultsToNestedComponents(config.leftPanel.components || []),
|
||||
} : config.leftPanel,
|
||||
rightPanel: config.rightPanel ? {
|
||||
...config.rightPanel,
|
||||
components: applyDefaultsToNestedComponents(config.rightPanel.components || []),
|
||||
} : config.rightPanel,
|
||||
leftPanel: config.leftPanel
|
||||
? {
|
||||
...config.leftPanel,
|
||||
components: applyDefaultsToNestedComponents(config.leftPanel.components || []),
|
||||
}
|
||||
: config.leftPanel,
|
||||
rightPanel: config.rightPanel
|
||||
? {
|
||||
...config.rightPanel,
|
||||
components: applyDefaultsToNestedComponents(config.rightPanel.components || []),
|
||||
}
|
||||
: config.rightPanel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// 컴포넌트 타입이 없으면 그대로 반환
|
||||
if (!nestedComponentType) {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// 중첩 컴포넌트의 기본값 가져오기
|
||||
const nestedDefaults = getDefaultsByUrl(`registry://${nestedComponentType}`);
|
||||
|
||||
|
||||
// componentConfig가 있으면 기본값과 병합
|
||||
if (result.componentConfig && Object.keys(nestedDefaults).length > 0) {
|
||||
const mergedNestedConfig = mergeComponentConfig(nestedDefaults, result.componentConfig);
|
||||
|
|
@ -102,7 +106,7 @@ function applyDefaultsToNestedComponents(components: any[]): any[] {
|
|||
componentConfig: mergedNestedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
|
@ -112,7 +116,7 @@ function applyDefaultsToNestedComponents(components: any[]): any[] {
|
|||
// ============================================
|
||||
function applyDefaultsToSplitPanelComponents(mergedConfig: Record<string, any>): Record<string, any> {
|
||||
const result = { ...mergedConfig };
|
||||
|
||||
|
||||
// leftPanel.components 처리
|
||||
if (result.leftPanel?.components) {
|
||||
result.leftPanel = {
|
||||
|
|
@ -120,7 +124,7 @@ function applyDefaultsToSplitPanelComponents(mergedConfig: Record<string, any>):
|
|||
components: applyDefaultsToNestedComponents(result.leftPanel.components),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// rightPanel.components 처리
|
||||
if (result.rightPanel?.components) {
|
||||
result.rightPanel = {
|
||||
|
|
@ -128,7 +132,7 @@ function applyDefaultsToSplitPanelComponents(mergedConfig: Record<string, any>):
|
|||
components: applyDefaultsToNestedComponents(result.rightPanel.components),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +153,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
|||
if (componentType === "v2-split-panel-layout") {
|
||||
mergedConfig = applyDefaultsToSplitPanelComponents(mergedConfig);
|
||||
}
|
||||
|
||||
|
||||
// 🆕 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용
|
||||
if (componentType === "v2-tabs-widget" && mergedConfig.tabs) {
|
||||
mergedConfig = {
|
||||
|
|
@ -183,13 +187,17 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
|||
label: overrides.label || mergedConfig.label || "", // 라벨이 없으면 빈 문자열
|
||||
required: overrides.required,
|
||||
readonly: overrides.readonly,
|
||||
hidden: overrides.hidden, // 🆕 숨김 설정 복원
|
||||
codeCategory: overrides.codeCategory,
|
||||
inputType: overrides.inputType,
|
||||
webType: overrides.webType,
|
||||
// 🆕 autoFill 설정 복원 (자동 입력 기능)
|
||||
autoFill: overrides.autoFill,
|
||||
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
|
||||
style: overrides.style || {},
|
||||
// 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등)
|
||||
webTypeConfig: overrides.webTypeConfig || {},
|
||||
// 기존 구조 호환을 위한 추가 필드
|
||||
style: {},
|
||||
parentId: null,
|
||||
gridColumns: 12,
|
||||
gridRowIndex: 0,
|
||||
|
|
@ -231,21 +239,59 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
|||
const topLevelProps: Record<string, any> = {};
|
||||
if (comp.tableName) topLevelProps.tableName = comp.tableName;
|
||||
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.readonly !== undefined) topLevelProps.readonly = comp.readonly;
|
||||
if (comp.hidden !== undefined) topLevelProps.hidden = comp.hidden; // 🆕 숨김 설정 저장
|
||||
if (comp.codeCategory) topLevelProps.codeCategory = comp.codeCategory;
|
||||
if (comp.inputType) topLevelProps.inputType = comp.inputType;
|
||||
if (comp.webType) topLevelProps.webType = comp.webType;
|
||||
// 🆕 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 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 병합
|
||||
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 {
|
||||
id: comp.id,
|
||||
|
|
|
|||
|
|
@ -107,18 +107,18 @@ export const WEB_TYPE_V2_MAPPING: Record<string, V2ComponentMapping> = {
|
|||
config: { mode: "dropdown", source: "category" },
|
||||
},
|
||||
|
||||
// 파일/이미지 → V2Media
|
||||
// 파일/이미지 → V2 파일 업로드
|
||||
file: {
|
||||
componentType: "v2-media",
|
||||
config: { type: "file", multiple: false },
|
||||
componentType: "v2-file-upload",
|
||||
config: { multiple: true, accept: "*/*", maxFiles: 10 },
|
||||
},
|
||||
image: {
|
||||
componentType: "v2-media",
|
||||
config: { type: "image", showPreview: true },
|
||||
componentType: "v2-file-upload",
|
||||
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
|
||||
},
|
||||
img: {
|
||||
componentType: "v2-media",
|
||||
config: { type: "image", showPreview: true },
|
||||
componentType: "v2-file-upload",
|
||||
config: { multiple: false, accept: "image/*", maxFiles: 1, showPreview: true },
|
||||
},
|
||||
|
||||
// 버튼은 V2 컴포넌트에서 제외 (기존 버튼 시스템 사용)
|
||||
|
|
@ -157,9 +157,9 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
|||
code: "v2-select",
|
||||
entity: "v2-select",
|
||||
category: "v2-select",
|
||||
file: "v2-media",
|
||||
image: "v2-media",
|
||||
img: "v2-media",
|
||||
file: "v2-file-upload",
|
||||
image: "v2-file-upload",
|
||||
img: "v2-file-upload",
|
||||
button: "button-primary",
|
||||
label: "v2-input",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { v2EventBus } from "../events/EventBus";
|
||||
import { V2_EVENTS } from "../events/types";
|
||||
import type {
|
||||
ScheduleType,
|
||||
V2ScheduleGenerateRequestEvent,
|
||||
V2ScheduleGenerateApplyEvent,
|
||||
} from "../events/types";
|
||||
import type { ScheduleType, V2ScheduleGenerateRequestEvent, V2ScheduleGenerateApplyEvent } from "../events/types";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -122,13 +118,10 @@ function getDefaultPeriod(): { start: string; end: string } {
|
|||
* const { showConfirmDialog, previewResult, handleConfirm } = useScheduleGenerator(config);
|
||||
* ```
|
||||
*/
|
||||
export function useScheduleGenerator(
|
||||
scheduleConfig?: ScheduleGenerationConfig | null
|
||||
): UseScheduleGeneratorReturn {
|
||||
export function useScheduleGenerator(scheduleConfig?: ScheduleGenerationConfig | null): UseScheduleGeneratorReturn {
|
||||
// 상태
|
||||
const [selectedData, setSelectedData] = useState<any[]>([]);
|
||||
const [previewResult, setPreviewResult] =
|
||||
useState<SchedulePreviewResult | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<SchedulePreviewResult | null>(null);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentRequestIdRef = useRef<string>("");
|
||||
|
|
@ -136,57 +129,53 @@ export function useScheduleGenerator(
|
|||
|
||||
// 1. 테이블 선택 데이터 추적 (TABLE_SELECTION_CHANGE 이벤트 수신)
|
||||
useEffect(() => {
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_SELECTION_CHANGE,
|
||||
(payload) => {
|
||||
// scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장
|
||||
if (scheduleConfig?.source?.tableName) {
|
||||
if (payload.tableName === scheduleConfig.source.tableName) {
|
||||
setSelectedData(payload.selectedRows);
|
||||
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (특정 테이블):", payload.selectedCount, "건");
|
||||
}
|
||||
} else {
|
||||
// scheduleConfig가 없으면 모든 테이블의 선택 데이터를 저장
|
||||
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
|
||||
// scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장
|
||||
if (scheduleConfig?.source?.tableName) {
|
||||
if (payload.tableName === scheduleConfig.source.tableName) {
|
||||
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;
|
||||
}, [scheduleConfig?.source?.tableName]);
|
||||
|
||||
// 2. 스케줄 생성 요청 처리 (SCHEDULE_GENERATE_REQUEST 수신)
|
||||
useEffect(() => {
|
||||
console.log("[useScheduleGenerator] 이벤트 구독 시작");
|
||||
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.SCHEDULE_GENERATE_REQUEST,
|
||||
async (payload: V2ScheduleGenerateRequestEvent) => {
|
||||
console.log("[useScheduleGenerator] SCHEDULE_GENERATE_REQUEST 수신:", payload);
|
||||
|
||||
// 이벤트에서 config가 오면 사용, 없으면 기존 scheduleConfig 또는 기본 config 사용
|
||||
const configToUse = (payload as any).config || scheduleConfig || {
|
||||
// 기본 설정 (생산계획 화면용)
|
||||
scheduleType: payload.scheduleType || "PRODUCTION",
|
||||
source: {
|
||||
tableName: "sales_order_mng",
|
||||
groupByField: "part_code",
|
||||
quantityField: "balance_qty",
|
||||
dueDateField: "delivery_date", // 기준일 필드 (납기일)
|
||||
},
|
||||
resource: {
|
||||
type: "ITEM",
|
||||
idField: "part_code",
|
||||
nameField: "part_name",
|
||||
},
|
||||
rules: {
|
||||
leadTimeDays: 3,
|
||||
dailyCapacity: 100,
|
||||
},
|
||||
target: {
|
||||
tableName: "schedule_mng",
|
||||
},
|
||||
};
|
||||
const configToUse = (payload as any).config ||
|
||||
scheduleConfig || {
|
||||
// 기본 설정 (생산계획 화면용)
|
||||
scheduleType: payload.scheduleType || "PRODUCTION",
|
||||
source: {
|
||||
tableName: "sales_order_mng",
|
||||
groupByField: "part_code",
|
||||
quantityField: "balance_qty",
|
||||
dueDateField: "delivery_date", // 기준일 필드 (납기일)
|
||||
},
|
||||
resource: {
|
||||
type: "ITEM",
|
||||
idField: "part_code",
|
||||
nameField: "part_name",
|
||||
},
|
||||
rules: {
|
||||
leadTimeDays: 3,
|
||||
dailyCapacity: 100,
|
||||
},
|
||||
target: {
|
||||
tableName: "schedule_mng",
|
||||
},
|
||||
};
|
||||
|
||||
console.log("[useScheduleGenerator] 사용할 config:", configToUse);
|
||||
|
||||
|
|
@ -250,7 +239,7 @@ export function useScheduleGenerator(
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return unsubscribe;
|
||||
}, [selectedData, scheduleConfig]);
|
||||
|
|
@ -299,10 +288,9 @@ export function useScheduleGenerator(
|
|||
tableName: configToUse?.target?.tableName || "schedule_mng",
|
||||
});
|
||||
|
||||
toast.success(
|
||||
`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`,
|
||||
{ id: "schedule-apply" }
|
||||
);
|
||||
toast.success(`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`, {
|
||||
id: "schedule-apply",
|
||||
});
|
||||
setShowConfirmDialog(false);
|
||||
setPreviewResult(null);
|
||||
} catch (error: any) {
|
||||
|
|
@ -311,7 +299,7 @@ export function useScheduleGenerator(
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return unsubscribe;
|
||||
}, [previewResult, scheduleConfig]);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const nextConfig = {
|
|||
|
||||
// 실험적 기능 활성화
|
||||
experimental: {
|
||||
outputFileTracingRoot: undefined,
|
||||
// 메모리 사용량 최적화 (Next.js 15+)
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
|
||||
// API 프록시 설정 - 백엔드로 요청 전달
|
||||
|
|
|
|||
|
|
@ -232,13 +232,27 @@ export interface V2MediaConfig {
|
|||
maxSize?: number;
|
||||
preview?: boolean;
|
||||
uploadEndpoint?: string;
|
||||
// 레거시 FileUpload 호환 설정
|
||||
docType?: string;
|
||||
docTypeName?: string;
|
||||
showFileList?: boolean;
|
||||
dragDrop?: boolean;
|
||||
}
|
||||
|
||||
export interface V2MediaProps extends V2BaseProps {
|
||||
v2Type: "V2Media";
|
||||
config: V2MediaConfig;
|
||||
v2Type?: "V2Media";
|
||||
config?: V2MediaConfig;
|
||||
value?: string | string[]; // 파일 URL 또는 배열
|
||||
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 =====
|
||||
|
|
@ -530,3 +544,28 @@ export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
|
|||
// Button (Input의 버튼 모드)
|
||||
"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; // 항상 표시 (조건 무시)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,12 +26,14 @@ if %errorlevel% neq 0 (
|
|||
echo [OK] Docker Desktop이 실행 중입니다.
|
||||
echo.
|
||||
|
||||
REM 기존 컨테이너 정리
|
||||
echo [2/5] 기존 컨테이너 정리 중...
|
||||
REM 기존 컨테이너 및 이미지 정리
|
||||
echo [2/5] 기존 컨테이너 및 이미지 정리 중...
|
||||
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 create pms-network 2>nul
|
||||
echo [OK] 컨테이너 정리 완료
|
||||
docker system prune -f >nul 2>&1
|
||||
echo [OK] 컨테이너 및 이미지 정리 완료
|
||||
echo.
|
||||
|
||||
REM 병렬 빌드 (docker-compose 자체가 병렬 처리)
|
||||
|
|
@ -39,8 +41,8 @@ echo [3/5] 이미지 빌드 중... (백엔드 + 프론트엔드 병렬)
|
|||
echo 이 작업은 시간이 걸릴 수 있습니다...
|
||||
echo.
|
||||
|
||||
REM 백엔드 빌드
|
||||
docker-compose -f docker-compose.backend.win.yml build
|
||||
REM 백엔드 빌드 (캐시 없이 완전 재빌드)
|
||||
docker-compose -f docker-compose.backend.win.yml build --no-cache
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] 백엔드 빌드 실패!
|
||||
pause
|
||||
|
|
@ -49,8 +51,8 @@ if %errorlevel% neq 0 (
|
|||
echo [OK] 백엔드 빌드 완료
|
||||
echo.
|
||||
|
||||
REM 프론트엔드 빌드
|
||||
docker-compose -f docker-compose.frontend.win.yml build
|
||||
REM 프론트엔드 빌드 (캐시 없이 완전 재빌드)
|
||||
docker-compose -f docker-compose.frontend.win.yml build --no-cache
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] 프론트엔드 빌드 실패!
|
||||
pause
|
||||
|
|
|
|||
Loading…
Reference in New Issue