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

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

View File

@ -1044,6 +1044,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"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"

View File

@ -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개 파일

View File

@ -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;

View File

@ -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);

View File

@ -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> = {

View File

@ -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

View File

@ -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;
}

View File

@ -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 "";

View File

@ -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,

View File

@ -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 레이아웃 저장 완료`);
}
}

View File

@ -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}%'`
);
}
}
}
}

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,8 @@
| 테이블명 | 용도 | 주요 컬럼 |
|----------|------|----------|
| `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` |
| `screen_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 아키텍처

View File

@ -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 |

View File

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

View File

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

View File

@ -161,7 +161,6 @@ function ScreenViewPage() {
// V2 레이아웃: Zod 기반 변환 (기본값 병합)
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;
});

View File

@ -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>

View File

@ -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}
/>
))}

View File

@ -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");

View File

@ -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) {
// 현재 행의 기본키 값 가져오기

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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={{

View File

@ -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에서 포커스 상태에 따라 동적으로 설정
},

View File

@ -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>

View File

@ -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,

View File

@ -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 전용 (일반 화면에서 불필요)

View File

@ -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);
});
}}

View File

@ -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">

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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}

View File

@ -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 {

View File

@ -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>

View File

@ -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>
);
});

View File

@ -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 생성 =====

View File

@ -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>

View File

@ -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>
);
});

View File

@ -134,7 +134,7 @@ export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
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

View File

@ -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>
);

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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: "파일 정보 조회에 실패했습니다.",
};
}
};

View File

@ -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(
};
}
}

View File

@ -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는 더 이상 필요하지 않습니다");
},
},

View File

@ -207,6 +207,88 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트 타입 변환 완료
// 🆕 조건부 렌더링 체크 (conditionalConfig)
// componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교
const conditionalConfig = (component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig;
// 디버그: 조건부 렌더링 설정 확인
if (conditionalConfig?.enabled) {
console.log(`🔍 [조건부 렌더링] ${component.id}:`, {
conditionalConfig,
formData: props.formData,
hasFormData: !!props.formData
});
}
if (conditionalConfig?.enabled && props.formData) {
const { field, operator, value, action } = conditionalConfig;
const fieldValue = props.formData[field];
console.log(`🔍 [조건부 렌더링 평가] ${component.id}:`, {
field,
fieldValue,
operator,
expectedValue: value,
action
});
// 조건 평가
let conditionMet = false;
switch (operator) {
case "=":
case "==":
case "===":
conditionMet = fieldValue === value;
break;
case "!=":
case "!==":
conditionMet = fieldValue !== value;
break;
case ">":
conditionMet = Number(fieldValue) > Number(value);
break;
case "<":
conditionMet = Number(fieldValue) < Number(value);
break;
case ">=":
conditionMet = Number(fieldValue) >= Number(value);
break;
case "<=":
conditionMet = Number(fieldValue) <= Number(value);
break;
case "contains":
conditionMet = String(fieldValue || "").includes(String(value));
break;
case "empty":
conditionMet = !fieldValue || fieldValue === "";
break;
case "notEmpty":
conditionMet = !!fieldValue && fieldValue !== "";
break;
default:
conditionMet = fieldValue === value;
}
// 액션에 따라 렌더링 결정
console.log(`🔍 [조건부 렌더링 결과] ${component.id}:`, {
conditionMet,
action,
shouldRender: action === "show" ? conditionMet : !conditionMet
});
if (action === "show" && !conditionMet) {
// "show" 액션: 조건이 충족되지 않으면 렌더링하지 않음
console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (show 조건 불충족)`);
return null;
}
if (action === "hide" && conditionMet) {
// "hide" 액션: 조건이 충족되면 렌더링하지 않음
console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (hide 조건 충족)`);
return null;
}
// "enable"/"disable" 액션은 conditionalDisabled props로 전달
}
// 🆕 모든 v2- 컴포넌트는 ComponentRegistry에서 통합 처리
// (v2-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, // 🆕 화면 정보

View File

@ -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>
);
};

View File

@ -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 사용으로 패널에서 숨김
});
// 타입 내보내기

View File

@ -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 사용으로 패널에서 숨김
});
// 컴포넌트 내보내기

View File

@ -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 파일 업로드 컴포넌트
/**
*

View File

@ -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`,

View File

@ -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>
);
}

View File

@ -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>
))}

View File

@ -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 (
<>

View File

@ -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

View File

@ -29,10 +29,15 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
}
};
// 라벨: style.labelText 우선, 없으면 component.label 사용
// style.labelDisplay가 false면 라벨 숨김
const style = component.style || {};
const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label);
return (
<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,

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,15 +25,29 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
// 값 변경 핸들러
const handleChange = (value: any) => {
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

View File

@ -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}
/>
);
}

View File

@ -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}
/>
);
}

View File

@ -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: {

View File

@ -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("✅ 수정 모달 열기:", {

View File

@ -85,7 +85,7 @@ interface GroupedData {
// 캐시 및 유틸리티
// ========================================
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const tableColumnCache = new Map<string, { columns: any[]; inputTypes?: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const 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>
);
}

View File

@ -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"> (AZ, 19)</option>
<option value="desc"> (ZA, 91)</option>
</select>
</div>
)}
</div>
</div>
{/* 가로 스크롤 및 컬럼 고정 */}
<div className="space-y-3">
<div>

View File

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

View File

@ -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) => {

View File

@ -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>

View File

@ -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">

View File

@ -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],
);
// 새로고침

View File

@ -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"; // 작업배정
/**
* ( )

View File

@ -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",

View File

@ -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,
};
}
}
/**
* 🔥
*/

View File

@ -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,

View File

@ -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",
};

View File

@ -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]);

View File

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

View File

@ -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; // 항상 표시 (조건 무시)
}

View File

@ -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