merge: origin/main을 ksh-v2-work-merge-test에 병합

origin/main의 feature/v2-unified-renewal(PR #386) 포함 86개 커밋을 병합.
ScreenDesigner.tsx에서 3건의 충돌을 수동 해결:

1. 함수 시그니처: isPop/defaultDevicePreview props 유지 (POP 모드 지원)
2. 저장 로직: POP/V2/Legacy 3단계 분기 유지, 디버그 로그 제거
3. 툴바 props: origin/main의 정렬/분배/크기맞춤/라벨토글/단축키 기능 채택

검증 완료: 빌드 성공, 타입 에러 없음, 시맨틱 충돌 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-09 10:41:30 +09:00
commit b85f8559e4
208 changed files with 45291 additions and 4169 deletions

8
.cursor/mcp.json Normal file
View File

@ -0,0 +1,8 @@
{
"mcpServers": {
"agent-orchestrator": {
"command": "node",
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
}
}
}

View File

@ -1,5 +1,48 @@
# Cursor Rules for ERP-node Project
## 🚨 비즈니스 로직 요청 양식 검증 (필수)
**사용자가 화면 개발 또는 비즈니스 로직 구현을 요청할 때, 아래 양식을 따르지 않으면 반드시 다음과 같이 응답하세요:**
```
안녕하세요. Oh My Master! 양식을 못 알아 듣겠습니다.
다시 한번 작성해주십쇼.
=== 비즈니스 로직 요청서 ===
【화면 정보】
- 화면명:
- 회사코드:
- 메뉴ID (있으면):
【테이블 정보】
- 메인 테이블:
- 디테일 테이블 (있으면):
- 관계 FK (있으면):
【버튼 목록】
버튼1:
- 버튼명:
- 동작 유형: (저장/삭제/수정/조회/기타)
- 조건 (있으면):
- 대상 테이블:
- 추가 동작 (있으면):
【추가 요구사항】
-
```
**양식 미준수 판단 기준:**
1. "화면 만들어줘" 같이 테이블명/버튼 정보 없이 요청
2. "저장하면 저장해줘" 같이 구체적인 테이블/로직 설명 없음
3. "이전이랑 비슷하게" 같이 모호한 참조
4. 버튼별 조건/동작이 명시되지 않음
**양식 미준수 시 절대 작업 진행하지 말고, 위 양식을 보여주며 다시 작성하라고 요청하세요.**
**상세 가이드**: [화면개발_표준_가이드.md](docs/screen-implementation-guide/화면개발_표준_가이드.md)
---
## 🚨 최우선 보안 규칙: 멀티테넌시
**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:**

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

@ -10,6 +10,43 @@ import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// ============================================
// 프로세스 레벨 예외 처리 (서버 크래시 방지)
// ============================================
// 처리되지 않은 Promise 거부 핸들러
process.on("unhandledRejection", (reason: Error | any, promise: Promise<any>) => {
logger.error("⚠️ Unhandled Promise Rejection:", {
reason: reason?.message || reason,
stack: reason?.stack,
});
// 프로세스를 종료하지 않고 로깅만 수행
// 심각한 에러의 경우 graceful shutdown 고려
});
// 처리되지 않은 예외 핸들러
process.on("uncaughtException", (error: Error) => {
logger.error("🔥 Uncaught Exception:", {
message: error.message,
stack: error.stack,
});
// 예외 발생 후에도 서버를 유지하되, 상태가 불안정할 수 있으므로 주의
// 심각한 에러의 경우 graceful shutdown 후 재시작 권장
});
// SIGTERM 시그널 처리 (Docker/Kubernetes 환경)
process.on("SIGTERM", () => {
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
// 여기서 연결 풀 정리 등 cleanup 로직 추가 가능
process.exit(0);
});
// SIGINT 시그널 처리 (Ctrl+C)
process.on("SIGINT", () => {
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
process.exit(0);
});
// 라우터 임포트
import authRoutes from "./routes/authRoutes";
import adminRoutes from "./routes/adminRoutes";
@ -64,6 +101,7 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
@ -246,6 +284,7 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/roles", roleRoutes); // 권한 그룹 관리

View File

@ -1461,11 +1461,8 @@ async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
[menuObjid]
);
// 4. numbering_rules에서 menu_objid를 NULL로 설정
await query(
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 4. numbering_rules: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)
// 새 스키마: table_name + column_name + company_code 기반
// 5. rel_menu_auth에서 관련 권한 삭제
await query(

View File

@ -5,9 +5,13 @@
import { Router, Request, Response } from "express";
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
import { logger } from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 인증된 사용자 타입
interface AuthenticatedRequest extends Request {
user?: {

View File

@ -431,7 +431,7 @@ export const deleteFile = async (
// 파일 정보 조회
const fileRecord = await queryOne<any>(
`SELECT * FROM attach_file_info WHERE objid = $1`,
[parseInt(objid)]
[objid]
);
if (!fileRecord) {
@ -460,7 +460,7 @@ export const deleteFile = async (
// 파일 상태를 DELETED로 변경 (논리적 삭제)
await query<any>(
"UPDATE attach_file_info SET status = $1 WHERE objid = $2",
["DELETED", parseInt(objid)]
["DELETED", objid]
);
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
@ -708,6 +708,40 @@ export const getComponentFiles = async (
);
}
// 3. 레코드의 컬럼 값으로 파일 직접 조회 (수정 모달에서 기존 파일 로드)
// target_objid 매칭이 안 될 때, 테이블 레코드의 컬럼 값(파일 objid)으로 직접 찾기
if (dataFiles.length === 0 && templateFiles.length === 0 && tableName && recordId && columnName) {
try {
// 레코드에서 해당 컬럼 값 조회 (파일 objid가 저장되어 있을 수 있음)
const safeTable = String(tableName).replace(/[^a-zA-Z0-9_]/g, "");
const safeColumn = String(columnName).replace(/[^a-zA-Z0-9_]/g, "");
const recordResult = await query<any>(
`SELECT "${safeColumn}" FROM "${safeTable}" WHERE id = $1 LIMIT 1`,
[recordId]
);
if (recordResult.length > 0 && recordResult[0][safeColumn]) {
const columnValue = String(recordResult[0][safeColumn]);
// 숫자값인 경우 파일 objid로 간주하고 조회
if (/^\d+$/.test(columnValue)) {
console.log("🔍 [getComponentFiles] 레코드 컬럼 값으로 파일 조회:", { table: safeTable, column: safeColumn, fileObjid: columnValue });
const directFiles = await query<any>(
`SELECT * FROM attach_file_info
WHERE objid = $1 AND status = $2
ORDER BY regdate DESC`,
[columnValue, "ACTIVE"]
);
if (directFiles.length > 0) {
console.log("✅ [getComponentFiles] 레코드 컬럼 값으로 파일 찾음:", directFiles.length, "건");
dataFiles = directFiles;
}
}
}
} catch (lookupError) {
console.warn("⚠️ [getComponentFiles] 레코드 컬럼 값 조회 실패:", lookupError);
}
}
// 파일 정보 포맷팅 함수
const formatFileInfo = (file: any, isTemplate: boolean = false) => ({
objid: file.objid.toString(),
@ -782,7 +816,7 @@ export const previewFile = async (
const fileRecord = await queryOne<any>(
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
[parseInt(objid)]
[objid]
);
if (!fileRecord || fileRecord.status !== "ACTIVE") {
@ -793,8 +827,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,
@ -920,7 +955,7 @@ export const downloadFile = async (
const fileRecord = await queryOne<any>(
`SELECT * FROM attach_file_info WHERE objid = $1`,
[parseInt(objid)]
[objid]
);
if (!fileRecord || fileRecord.status !== "ACTIVE") {
@ -1211,7 +1246,7 @@ export const setRepresentativeFile = async (
// 파일 존재 여부 및 권한 확인
const fileRecord = await queryOne<any>(
`SELECT * FROM attach_file_info WHERE objid = $1 AND status = $2`,
[parseInt(objid), "ACTIVE"]
[objid, "ACTIVE"]
);
if (!fileRecord) {
@ -1236,7 +1271,7 @@ export const setRepresentativeFile = async (
`UPDATE attach_file_info
SET is_representative = false
WHERE target_objid = $1 AND objid != $2`,
[fileRecord.target_objid, parseInt(objid)]
[fileRecord.target_objid, objid]
);
// 선택한 파일을 대표 파일로 설정
@ -1244,7 +1279,7 @@ export const setRepresentativeFile = async (
`UPDATE attach_file_info
SET is_representative = true
WHERE objid = $1`,
[parseInt(objid)]
[objid]
);
res.json({
@ -1260,5 +1295,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'`,
[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,14 +3,20 @@
*/
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) => {
router.get(
"/",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
try {
@ -20,22 +26,31 @@ router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Respon
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) => {
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;
const menuObjid = req.params.menuObjid
? parseInt(req.params.menuObjid)
: undefined;
logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode });
try {
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
const rules = await numberingRuleService.getAvailableRulesForMenu(
companyCode,
menuObjid
);
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
companyCode,
menuObjid,
rulesCount: rules.length
rulesCount: rules.length,
});
return res.json({ success: true, data: rules });
@ -49,10 +64,14 @@ router.get("/available/:menuObjid?", authenticateToken, async (req: Authenticate
});
return res.status(500).json({ success: false, error: error.message });
}
});
}
);
// 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
router.get("/available-for-screen", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
router.get(
"/available-for-screen",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName } = req.query;
@ -87,27 +106,37 @@ router.get("/available-for-screen", authenticateToken, async (req: Authenticated
error: error.message,
});
}
});
}
);
// 특정 규칙 조회
router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
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
.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) => {
router.post(
"/",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const ruleConfig = req.body;
@ -125,11 +154,18 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
try {
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
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개 이상의 규칙 파트가 필요합니다" });
return res
.status(400)
.json({
success: false,
error: "최소 1개 이상의 규칙 파트가 필요합니다",
});
}
// 🆕 scopeType이 'table'인 경우 tableName 필수 체크
@ -142,7 +178,11 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
}
}
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
const newRule = await numberingRuleService.createRule(
ruleConfig,
companyCode,
userId
);
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
ruleId: newRule.ruleId,
@ -152,7 +192,9 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
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입니다" });
return res
.status(409)
.json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
}
logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", {
error: error.message,
@ -161,10 +203,14 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
});
return res.status(500).json({ success: false, error: error.message });
}
});
}
);
// 규칙 수정
router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
router.put(
"/:ruleId",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const updates = req.body;
@ -172,7 +218,11 @@ router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res:
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
try {
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
const updatedRule = await numberingRuleService.updateRule(
ruleId,
updates,
companyCode
);
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
return res.json({ success: true, data: updatedRule });
} catch (error: any) {
@ -180,17 +230,21 @@ router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res:
ruleId,
companyCode,
error: error.message,
stack: error.stack
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 });
}
});
}
);
// 규칙 삭제
router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
router.delete(
"/:ruleId",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
@ -204,57 +258,97 @@ router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, r
logger.error("규칙 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
}
);
// 코드 미리보기 (순번 증가 없음)
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
router.post(
"/: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);
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) => {
router.post(
"/:ruleId/allocate",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData });
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 });
}
logger.info("코드 할당 요청", {
ruleId,
companyCode,
hasFormData: !!formData,
userInputCode,
});
try {
const allocatedCode = await numberingRuleService.allocateCode(
ruleId,
companyCode,
formData,
userInputCode
);
logger.info("코드 할당 성공", { ruleId, allocatedCode });
return res.json({
success: true,
data: { generatedCode: allocatedCode },
});
} catch (error: any) {
logger.error("코드 할당 실패", {
ruleId,
companyCode,
error: error.message,
});
return res.status(500).json({ success: false, error: error.message });
}
}
);
// 코드 생성 (기존 호환성 유지, deprecated)
router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
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);
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) => {
router.post(
"/:ruleId/reset",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
@ -265,44 +359,76 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques
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) => {
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;
const menuObjid = req.params.menuObjid
? parseInt(req.params.menuObjid)
: undefined;
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 });
}
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 });
}
}
);
// [테스트] 테이블+컬럼 기반 채번 규칙 조회
router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
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);
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
tableName,
columnName
);
return res.json({ success: true, data: rule });
} catch (error: any) {
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message });
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
error: error.message,
});
return res.status(500).json({ success: false, error: error.message });
}
});
}
);
// [테스트] 테스트 테이블에 채번 규칙 저장
// 채번 규칙은 독립적으로 생성 가능 (나중에 테이블 타입 관리에서 컬럼에 연결)
router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
router.post(
"/test/save",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const ruleConfig = req.body;
@ -319,51 +445,74 @@ router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, r
if (!ruleConfig.ruleName) {
return res.status(400).json({
success: false,
error: "ruleName is required"
error: "ruleName is required",
});
}
const savedRule = await numberingRuleService.saveRuleToTest(ruleConfig, companyCode, userId);
const savedRule = await numberingRuleService.saveRuleToTest(
ruleConfig,
companyCode,
userId
);
return res.json({ success: true, data: savedRule });
} catch (error: any) {
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
}
);
// [테스트] 테스트 테이블에서 채번 규칙 삭제
router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
router.delete(
"/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: "테스트 채번 규칙이 삭제되었습니다" });
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) => {
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);
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) => {
router.post(
"/copy-for-company",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const userCompanyCode = req.user!.companyCode;
const { sourceCompanyCode, targetCompanyCode } = req.body;
@ -371,24 +520,28 @@ router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedReq
if (userCompanyCode !== "*") {
return res.status(403).json({
success: false,
error: "최고 관리자만 사용할 수 있습니다"
error: "최고 관리자만 사용할 수 있습니다",
});
}
if (!sourceCompanyCode || !targetCompanyCode) {
return res.status(400).json({
success: false,
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다"
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다",
});
}
try {
const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode);
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

@ -0,0 +1,223 @@
/**
*
*
* , , API를 .
*/
import { Request, Response } from "express";
import { ScheduleService } from "../services/scheduleService";
export class ScheduleController {
private scheduleService: ScheduleService;
constructor() {
this.scheduleService = new ScheduleService();
}
/**
*
* POST /api/schedule/preview
*
* .
* .
*/
preview = async (req: Request, res: Response): Promise<void> => {
try {
const { config, sourceData, period } = req.body;
const userId = (req as any).user?.userId || "system";
const companyCode = (req as any).user?.companyCode || "*";
console.log("[ScheduleController] preview 호출:", {
scheduleType: config?.scheduleType,
sourceDataCount: sourceData?.length,
period,
userId,
companyCode,
});
// 필수 파라미터 검증
if (!config || !config.scheduleType) {
res.status(400).json({
success: false,
message: "스케줄 설정(config)이 필요합니다.",
});
return;
}
if (!sourceData || sourceData.length === 0) {
res.status(400).json({
success: false,
message: "소스 데이터가 필요합니다.",
});
return;
}
// 미리보기 생성
const preview = await this.scheduleService.generatePreview(
config,
sourceData,
period,
companyCode
);
res.json({
success: true,
preview,
});
} catch (error: any) {
console.error("[ScheduleController] preview 오류:", error);
res.status(500).json({
success: false,
message: error.message || "스케줄 미리보기 중 오류가 발생했습니다.",
});
}
};
/**
*
* POST /api/schedule/apply
*
* .
*/
apply = async (req: Request, res: Response): Promise<void> => {
try {
const { config, preview, options } = req.body;
const userId = (req as any).user?.userId || "system";
const companyCode = (req as any).user?.companyCode || "*";
console.log("[ScheduleController] apply 호출:", {
scheduleType: config?.scheduleType,
createCount: preview?.summary?.createCount,
deleteCount: preview?.summary?.deleteCount,
options,
userId,
companyCode,
});
// 필수 파라미터 검증
if (!config || !preview) {
res.status(400).json({
success: false,
message: "설정(config)과 미리보기(preview)가 필요합니다.",
});
return;
}
// 적용
const applied = await this.scheduleService.applySchedules(
config,
preview,
options || { deleteExisting: true, updateMode: "replace" },
companyCode,
userId
);
res.json({
success: true,
applied,
message: `${applied.created}건 생성, ${applied.deleted}건 삭제, ${applied.updated}건 수정되었습니다.`,
});
} catch (error: any) {
console.error("[ScheduleController] apply 오류:", error);
res.status(500).json({
success: false,
message: error.message || "스케줄 적용 중 오류가 발생했습니다.",
});
}
};
/**
*
* GET /api/schedule/list
*
* .
*/
list = async (req: Request, res: Response): Promise<void> => {
try {
const {
scheduleType,
resourceType,
resourceId,
startDate,
endDate,
status,
} = req.query;
const companyCode = (req as any).user?.companyCode || "*";
console.log("[ScheduleController] list 호출:", {
scheduleType,
resourceType,
resourceId,
startDate,
endDate,
status,
companyCode,
});
const result = await this.scheduleService.getScheduleList({
scheduleType: scheduleType as string,
resourceType: resourceType as string,
resourceId: resourceId as string,
startDate: startDate as string,
endDate: endDate as string,
status: status as string,
companyCode,
});
res.json({
success: true,
data: result.data,
total: result.total,
});
} catch (error: any) {
console.error("[ScheduleController] list 오류:", error);
res.status(500).json({
success: false,
message: error.message || "스케줄 조회 중 오류가 발생했습니다.",
});
}
};
/**
*
* DELETE /api/schedule/:scheduleId
*/
delete = async (req: Request, res: Response): Promise<void> => {
try {
const { scheduleId } = req.params;
const userId = (req as any).user?.userId || "system";
const companyCode = (req as any).user?.companyCode || "*";
console.log("[ScheduleController] delete 호출:", {
scheduleId,
userId,
companyCode,
});
const result = await this.scheduleService.deleteSchedule(
parseInt(scheduleId, 10),
companyCode,
userId
);
if (!result.success) {
res.status(404).json({
success: false,
message: result.message || "스케줄을 찾을 수 없습니다.",
});
return;
}
res.json({
success: true,
message: "스케줄이 삭제되었습니다.",
});
} catch (error: any) {
console.error("[ScheduleController] delete 오류:", error);
res.status(500).json({
success: false,
message: error.message || "스케줄 삭제 중 오류가 발생했습니다.",
});
}
};
}

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
@ -308,39 +312,108 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
await client.query('BEGIN');
// 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상)
// 0. 삭제할 그룹의 company_code 확인
const targetGroupResult = await client.query(
`SELECT company_code FROM screen_groups WHERE id = $1`,
[id]
);
if (targetGroupResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." });
}
const targetCompanyCode = targetGroupResult.rows[0].company_code;
// 권한 체크: 최고관리자가 아닌 경우 자신의 회사 그룹만 삭제 가능
if (companyCode !== "*" && targetCompanyCode !== companyCode) {
await client.query('ROLLBACK');
return res.status(403).json({ success: false, message: "권한이 없습니다." });
}
// 1. 삭제할 그룹과 하위 그룹 ID 수집 (같은 회사만 - CASCADE 삭제 대상)
const childGroupsResult = await client.query(`
WITH RECURSIVE child_groups AS (
SELECT id FROM screen_groups WHERE id = $1
SELECT id, company_code FROM screen_groups WHERE id = $1 AND company_code = $2
UNION ALL
SELECT sg.id FROM screen_groups sg
JOIN child_groups cg ON sg.parent_group_id = cg.id
SELECT sg.id, sg.company_code FROM screen_groups sg
JOIN child_groups cg ON sg.parent_group_id = cg.id AND sg.company_code = cg.company_code
)
SELECT id FROM child_groups
`, [id]);
`, [id, targetCompanyCode]);
const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id);
// 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리
logger.info("화면 그룹 삭제 대상", {
companyCode,
targetCompanyCode,
groupId: id,
childGroupIds: groupIdsToDelete
});
// 2. 삭제될 그룹에 연결된 메뉴 정리
if (groupIdsToDelete.length > 0) {
await client.query(`
UPDATE menu_info
SET screen_group_id = NULL
// 2-1. 삭제할 메뉴 objid 수집
const menusToDelete = await client.query(`
SELECT objid FROM menu_info
WHERE screen_group_id = ANY($1::int[])
`, [groupIdsToDelete]);
AND company_code = $2
`, [groupIdsToDelete, targetCompanyCode]);
const menuObjids = menusToDelete.rows.map((r: any) => r.objid);
if (menuObjids.length > 0) {
// 2-2. screen_menu_assignments에서 해당 메뉴 관련 데이터 삭제
await client.query(`
DELETE FROM screen_menu_assignments
WHERE menu_objid = ANY($1::bigint[])
AND company_code = $2
`, [menuObjids, targetCompanyCode]);
// 2-3. menu_info에서 해당 메뉴 삭제
await client.query(`
DELETE FROM menu_info
WHERE screen_group_id = ANY($1::int[])
AND company_code = $2
`, [groupIdsToDelete, targetCompanyCode]);
logger.info("그룹 삭제 시 연결된 메뉴 삭제", {
groupIds: groupIdsToDelete,
deletedMenuCount: menuObjids.length,
companyCode: targetCompanyCode
});
}
// 3. screen_groups 삭제
let query = `DELETE FROM screen_groups WHERE id = $1`;
const params: any[] = [id];
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 시)
// 삭제되는 그룹이 최상위인지 확인
const isRootGroup = await client.query(
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
[id]
);
if (companyCode !== "*") {
query += ` AND company_code = $2`;
params.push(companyCode);
if (isRootGroup.rows.length > 0) {
// 최상위 그룹 삭제 시 해당 회사의 채번 규칙도 삭제
// 먼저 파트 삭제
await client.query(
`DELETE FROM numbering_rule_parts
WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
[targetCompanyCode]
);
// 규칙 삭제
const deletedRules = await client.query(
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
[targetCompanyCode]
);
if (deletedRules.rowCount && deletedRules.rowCount > 0) {
logger.info("그룹 삭제 시 채번 규칙 삭제", {
companyCode: targetCompanyCode,
deletedCount: deletedRules.rowCount
});
}
}
}
query += " RETURNING id";
const result = await client.query(query, params);
// 3. screen_groups 삭제 (해당 그룹만 - 하위 그룹은 프론트엔드에서 순차 삭제)
const result = await client.query(
`DELETE FROM screen_groups WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, targetCompanyCode]
);
if (result.rows.length === 0) {
await client.query('ROLLBACK');
@ -349,7 +422,7 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
await client.query('COMMIT');
logger.info("화면 그룹 삭제", { companyCode, groupId: id, cleanedRefs: groupIdsToDelete.length });
logger.info("화면 그룹 삭제 완료", { companyCode, targetCompanyCode, groupId: id, cleanedRefs: groupIdsToDelete.length });
res.json({ success: true, message: "화면 그룹이 삭제되었습니다." });
} catch (error: any) {
@ -1668,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,
@ -1681,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]);
@ -2049,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

@ -557,7 +557,16 @@ export async function updateColumnInputType(
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { inputType, detailSettings } = req.body;
let { inputType, detailSettings } = req.body;
// 🔥 "direct" 또는 "auto"는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - "text"로 변환
if (inputType === "direct" || inputType === "auto") {
logger.warn(
`잘못된 inputType 값 감지: ${inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
inputType = "text";
}
// 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회)
let companyCode = req.user?.companyCode;
@ -662,14 +671,14 @@ export async function getTableRecord(
logger.info(`필터: ${filterColumn} = ${filterValue}`);
logger.info(`표시 컬럼: ${displayColumn}`);
if (!tableName || !filterColumn || !filterValue || !displayColumn) {
if (!tableName || !filterColumn || !filterValue) {
const response: ApiResponse<null> = {
success: false,
message: "필수 파라미터가 누락되었습니다.",
error: {
code: "MISSING_PARAMETERS",
details:
"tableName, filterColumn, filterValue, displayColumn이 필요합니다.",
"tableName, filterColumn, filterValue가 필요합니다. displayColumn은 선택적입니다.",
},
};
res.status(400).json(response);
@ -701,9 +710,12 @@ export async function getTableRecord(
}
const record = result.data[0];
const displayValue = record[displayColumn];
// displayColumn이 "*"이거나 없으면 전체 레코드 반환
const displayValue = displayColumn && displayColumn !== "*"
? record[displayColumn]
: record;
logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`);
logger.info(`레코드 조회 완료: ${displayColumn || "*"} = ${typeof displayValue === 'object' ? '[전체 레코드]' : displayValue}`);
const response: ApiResponse<{ value: any; record: any }> = {
success: true,
@ -1357,8 +1369,17 @@ export async function updateColumnWebType(
`레거시 API 사용: updateColumnWebType → updateColumnInputType 사용 권장`
);
// webType을 inputType으로 변환
const convertedInputType = inputType || webType || "text";
// 🔥 inputType이 "direct" 또는 "auto"이면 무시하고 webType 사용
// "direct"/"auto"는 프론트엔드의 입력 방식(직접입력/자동입력) 구분값이지
// DB에 저장할 웹 타입(text, number, date 등)이 아님
let convertedInputType = webType || "text";
if (inputType && inputType !== "direct" && inputType !== "auto") {
convertedInputType = inputType;
}
logger.info(
`웹타입 변환: webType=${webType}, inputType=${inputType}${convertedInputType}`
);
// 새로운 메서드 호출
req.body = { inputType: convertedInputType, detailSettings };
@ -2323,6 +2344,8 @@ export async function getTableEntityRelations(
*
* table_type_columns에서 reference_table이
* FK .
*
* 우선순위: 현재 company_code > ('*')
*/
export async function getReferencedByTables(
req: AuthenticatedRequest,
@ -2330,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) {
@ -2350,23 +2375,41 @@ export async function getReferencedByTables(
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
// 우선순위: 현재 사용자의 company_code > 공통('*')
// ROW_NUMBER를 사용해서 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택
const sqlQuery = `
SELECT DISTINCT
WITH ranked AS (
SELECT
ttc.table_name,
ttc.column_name,
ttc.column_label,
ttc.reference_table,
ttc.reference_column,
ttc.display_column,
ttc.table_name as table_label
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 = '*'
ORDER BY ttc.table_name, ttc.column_name
AND ttc.company_code IN ($2, '*')
)
SELECT DISTINCT
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,
@ -2379,7 +2422,7 @@ export async function getReferencedByTables(
}));
logger.info(
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})`
);
const response: ApiResponse<any> = {

View File

@ -81,8 +81,26 @@ export const initializePool = (): Pool => {
pool.on("error", (err, client) => {
console.error("❌ PostgreSQL 연결 풀 에러:", err);
// 연결 풀 에러 발생 시 자동 재연결 시도
// Pool은 자동으로 연결을 재생성하므로 별도 처리 불필요
// 다만, 연속 에러 발생 시 알림이 필요할 수 있음
});
// 연결 풀 상태 체크 (5분마다)
setInterval(() => {
if (pool) {
const status = {
totalCount: pool.totalCount,
idleCount: pool.idleCount,
waitingCount: pool.waitingCount,
};
// 대기 중인 연결이 많으면 경고
if (status.waitingCount > 5) {
console.warn("⚠️ PostgreSQL 연결 풀 대기열 증가:", status);
}
}
}, 5 * 60 * 1000);
console.log(
`🚀 PostgreSQL 연결 풀 초기화 완료: ${dbConfig.host}:${dbConfig.port}/${dbConfig.database}`
);

View File

@ -73,20 +73,4 @@ router.get("/categories/:categoryCode/options", (req, res) =>
commonCodeController.getCodeOptions(req, res)
);
// 계층 구조 코드 조회 (트리 형태)
router.get("/categories/:categoryCode/hierarchy", (req, res) =>
commonCodeController.getCodesHierarchy(req, res)
);
// 자식 코드 조회 (연쇄 선택용)
router.get("/categories/:categoryCode/children", (req, res) =>
commonCodeController.getChildCodes(req, res)
);
// 카테고리 → 공통코드 호환 API (레거시 지원)
// 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 동작
router.get("/category-options/:tableName/:columnName", (req, res) =>
commonCodeController.getCategoryOptionsAsCode(req, res)
);
export default router;

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

@ -0,0 +1,33 @@
/**
*
*/
import { Router } from "express";
import { ScheduleController } from "../controllers/scheduleController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
const scheduleController = new ScheduleController();
// 모든 스케줄 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ==================== 스케줄 생성 ====================
// 스케줄 미리보기
router.post("/preview", scheduleController.preview);
// 스케줄 적용
router.post("/apply", scheduleController.apply);
// ==================== 스케줄 조회 ====================
// 스케줄 목록 조회
router.get("/list", scheduleController.list);
// ==================== 스케줄 삭제 ====================
// 스케줄 삭제
router.delete("/:scheduleId", scheduleController.delete);
export default router;

View File

@ -43,6 +43,7 @@ export interface CreateCategoryValueInput {
icon?: string;
isActive?: boolean;
isDefault?: boolean;
targetCompanyCode?: string; // 최고 관리자가 특정 회사를 선택할 때 사용
}
// 카테고리 값 수정 입력
@ -89,7 +90,7 @@ class CategoryTreeService {
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM category_values_test
FROM category_values
WHERE (company_code = $1 OR company_code = '*')
AND table_name = $2
AND column_name = $3
@ -142,7 +143,7 @@ class CategoryTreeService {
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM category_values_test
FROM category_values
WHERE (company_code = $1 OR company_code = '*')
AND table_name = $2
AND column_name = $3
@ -184,7 +185,7 @@ class CategoryTreeService {
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt"
FROM category_values_test
FROM category_values
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
`;
@ -221,7 +222,7 @@ class CategoryTreeService {
}
const query = `
INSERT INTO category_values_test (
INSERT INTO category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code, created_by, updated_by
@ -334,7 +335,7 @@ class CategoryTreeService {
}
const query = `
UPDATE category_values_test
UPDATE category_values
SET
value_code = COALESCE($3, value_code),
value_label = COALESCE($4, value_label),
@ -415,11 +416,11 @@ class CategoryTreeService {
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
const query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM category_values_test
SELECT value_id FROM category_values
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
UNION ALL
SELECT cv.value_id
FROM category_values_test cv
FROM category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2 OR cv.company_code = '*'
)
@ -452,7 +453,7 @@ class CategoryTreeService {
for (const id of reversedIds) {
await pool.query(
`DELETE FROM category_values_test WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
[companyCode, id]
);
}
@ -479,7 +480,7 @@ class CategoryTreeService {
const query = `
SELECT value_id, value_label
FROM category_values_test
FROM category_values
WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2
`;
@ -488,7 +489,7 @@ class CategoryTreeService {
for (const child of result.rows) {
const newPath = `${parentPath}/${child.value_label}`;
await pool.query(`UPDATE category_values_test SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
await pool.query(`UPDATE category_values SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
newPath,
child.value_id,
]);
@ -550,7 +551,7 @@ class CategoryTreeService {
/**
* ( . )
* category_values_test table_name, column_name
* category_values table_name, column_name
*
*/
async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> {
@ -564,7 +565,7 @@ class CategoryTreeService {
cv.column_name AS "columnName",
COALESCE(tl.table_label, cv.table_name) AS "tableLabel",
COALESCE(ttc.column_label, cv.column_name) AS "columnLabel"
FROM category_values_test cv
FROM category_values cv
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name AND ttc.column_name = cv.column_name AND ttc.company_code = '*'
WHERE cv.company_code = $1 OR cv.company_code = '*'

View File

@ -851,47 +851,10 @@ export class MenuCopyService {
]);
logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-4. 채번 규칙 처리 (체크 제약조건 고려)
// scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함)
// check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수
const menuScopedRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules
WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`,
[existingMenuIds, targetCompanyCode]
);
if (menuScopedRulesResult.rows.length > 0) {
const menuScopedRuleIds = menuScopedRulesResult.rows.map(
(r) => r.rule_id
);
// 채번 규칙 파트 먼저 삭제
await client.query(
`DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`,
[menuScopedRuleIds]
);
// 채번 규칙 삭제
await client.query(
`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`,
[menuScopedRuleIds]
);
logger.info(
` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}`
);
}
// scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존)
const updatedNumberingRules = await client.query(
`UPDATE numbering_rules
SET menu_objid = NULL
WHERE menu_objid = ANY($1) AND company_code = $2
AND (scope_type IS NULL OR scope_type != 'menu')
RETURNING rule_id`,
[existingMenuIds, targetCompanyCode]
);
if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) {
logger.info(
` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)`
);
}
// 5-4. 채번 규칙 처리 (새 스키마에서는 menu_objid 없음 - 스킵)
// 새 numbering_rules 스키마: table_name + column_name + company_code 기반
// 메뉴와 직접 연결되지 않으므로 메뉴 삭제 시 처리 불필요
logger.info(` ⏭️ 채번 규칙: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)`);
// 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가)
// 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제
@ -961,6 +924,16 @@ export class MenuCopyService {
const menus = await this.collectMenuTree(sourceMenuObjid, client);
const sourceCompanyCode = menus[0].company_code!;
// 같은 회사로 복제하는 경우 경고 (자기 자신의 데이터 손상 위험)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 메뉴 복제 시도: ${sourceCompanyCode}${targetCompanyCode}`
);
warnings.push(
"같은 회사로 복제하면 추가 데이터(카테고리, 채번 등)가 복제되지 않습니다."
);
}
const screenIds = await this.collectScreens(
menus.map((m) => m.objid),
sourceCompanyCode,
@ -1116,6 +1089,10 @@ export class MenuCopyService {
client
);
// === 6.5단계: 메뉴 URL 업데이트 (화면 ID 재매핑) ===
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
// === 7단계: 테이블 타입 설정 복사 ===
if (additionalCopyOptions?.copyTableTypeColumns) {
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
@ -1556,22 +1533,22 @@ export class MenuCopyService {
// === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreenId = existingCopy.screen_id;
// 원본 레이아웃 조회
const sourceLayoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
// 원본 V2 레이아웃 조회
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[originalScreenId]
);
// 대상 레이아웃 조회
const targetLayoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
// 대상 V2 레이아웃 조회
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[existingScreenId]
);
// 변경 여부 확인 (레이아웃 개수 또는 내용 비교)
const hasChanges = this.hasLayoutChanges(
sourceLayoutsResult.rows,
targetLayoutsResult.rows
// 변경 여부 확인 (V2 레이아웃 비교)
const hasChanges = this.hasLayoutChangesV2(
sourceLayoutV2Result.rows[0]?.layout_data,
targetLayoutV2Result.rows[0]?.layout_data
);
if (hasChanges) {
@ -1673,9 +1650,9 @@ export class MenuCopyService {
}
}
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) ===
logger.info(
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
);
for (const {
@ -1685,91 +1662,51 @@ export class MenuCopyService {
isUpdate,
} of screenDefsToProcess) {
try {
// 원본 레이아웃 조회
const layoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
// 원본 V2 레이아웃 조회
const layoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
[originalScreenId]
);
if (isUpdate) {
// 업데이트: 기존 레이아웃 삭제 후 새로 삽입
await client.query(
`DELETE FROM screen_layouts WHERE screen_id = $1`,
[targetScreenId]
);
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
}
const layoutData = layoutV2Result.rows[0]?.layout_data;
const components = layoutData?.components || [];
if (layoutData && components.length > 0) {
// component_id 매핑 생성 (원본 → 새 ID)
const componentIdMap = new Map<string, string>();
const timestamp = Date.now();
layoutsResult.rows.forEach((layout, idx) => {
components.forEach((comp: any, idx: number) => {
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
componentIdMap.set(layout.component_id, newComponentId);
componentIdMap.set(comp.id, newComponentId);
});
// 레이아웃 배치 삽입 준비
if (layoutsResult.rows.length > 0) {
const layoutValues: string[] = [];
const layoutParams: any[] = [];
let paramIdx = 1;
for (const layout of layoutsResult.rows) {
const newComponentId = componentIdMap.get(layout.component_id)!;
const newParentId = layout.parent_id
? componentIdMap.get(layout.parent_id) || layout.parent_id
: null;
const newZoneId = layout.zone_id
? componentIdMap.get(layout.zone_id) || layout.zone_id
: null;
const updatedProperties = this.updateReferencesInProperties(
layout.properties,
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
layoutData,
componentIdMap,
screenIdMap,
flowIdMap,
numberingRuleIdMap,
menuIdMap
);
layoutValues.push(
`($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})`
);
layoutParams.push(
targetScreenId,
layout.component_type,
newComponentId,
newParentId,
layout.position_x,
layout.position_y,
layout.width,
layout.height,
updatedProperties,
layout.display_order,
layout.layout_type,
layout.layout_config,
layout.zones_config,
newZoneId
);
paramIdx += 14;
}
// 배치 INSERT
// V2 레이아웃 저장 (UPSERT)
await client.query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties,
display_order, layout_type, layout_config, zones_config, zone_id
) VALUES ${layoutValues.join(", ")}`,
layoutParams
`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()`,
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)]
);
}
const action = isUpdate ? "업데이트" : "복사";
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}`);
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`);
} else {
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
}
} catch (error: any) {
logger.error(
`❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`,
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
error
);
throw error;
@ -1835,6 +1772,83 @@ export class MenuCopyService {
return false;
}
/**
* V2 (screen_layouts_v2용)
*/
private hasLayoutChangesV2(
sourceLayoutData: any,
targetLayoutData: any
): boolean {
// 1. 둘 다 없으면 변경 없음
if (!sourceLayoutData && !targetLayoutData) return false;
// 2. 하나만 있으면 변경됨
if (!sourceLayoutData || !targetLayoutData) return true;
// 3. components 배열 비교
const sourceComps = sourceLayoutData.components || [];
const targetComps = targetLayoutData.components || [];
if (sourceComps.length !== targetComps.length) return true;
// 4. 각 컴포넌트 비교 (url, position, size, overrides)
for (let i = 0; i < sourceComps.length; i++) {
const s = sourceComps[i];
const t = targetComps[i];
if (s.url !== t.url) return true;
if (JSON.stringify(s.position) !== JSON.stringify(t.position)) return true;
if (JSON.stringify(s.size) !== JSON.stringify(t.size)) return true;
if (JSON.stringify(s.overrides) !== JSON.stringify(t.overrides)) return true;
}
return false;
}
/**
* V2 ID들을 (componentId, flowId, ruleId, screenId, menuId)
*/
private updateReferencesInLayoutDataV2(
layoutData: any,
componentIdMap: Map<string, string>,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>,
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
): any {
if (!layoutData?.components) return layoutData;
const updatedComponents = layoutData.components.map((comp: any) => {
// 1. componentId 매핑
const newId = componentIdMap.get(comp.id) || comp.id;
// 2. overrides 복사 및 재귀적 참조 업데이트
let overrides = JSON.parse(JSON.stringify(comp.overrides || {}));
// 재귀적으로 모든 참조 업데이트
this.recursiveUpdateReferences(
overrides,
screenIdMap,
flowIdMap,
"",
numberingRuleIdMap,
menuIdMap
);
return {
...comp,
id: newId,
overrides,
};
});
return {
...layoutData,
components: updatedComponents,
updatedAt: new Date().toISOString(),
};
}
/**
* ( )
*/
@ -2231,6 +2245,68 @@ export class MenuCopyService {
}
}
/**
* URL ( ID )
* menu_url에 /screens/{screenId} ID를 ID로
*/
private async updateMenuUrls(
menuIdMap: Map<number, number>,
screenIdMap: Map<number, number>,
client: PoolClient
): Promise<void> {
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
logger.info("📭 메뉴 URL 업데이트 대상 없음");
return;
}
const newMenuObjids = Array.from(menuIdMap.values());
// 복제된 메뉴 중 menu_url이 있는 것 조회
const menusWithUrl = await client.query<{
objid: number;
menu_url: string;
}>(
`SELECT objid, menu_url FROM menu_info
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
[newMenuObjids]
);
if (menusWithUrl.rows.length === 0) {
logger.info("📭 menu_url 업데이트 대상 없음");
return;
}
let updatedCount = 0;
const screenIdPattern = /\/screens\/(\d+)/;
for (const menu of menusWithUrl.rows) {
const match = menu.menu_url.match(screenIdPattern);
if (!match) continue;
const originalScreenId = parseInt(match[1], 10);
const newScreenId = screenIdMap.get(originalScreenId);
if (newScreenId && newScreenId !== originalScreenId) {
const newMenuUrl = menu.menu_url.replace(
`/screens/${originalScreenId}`,
`/screens/${newScreenId}`
);
await client.query(
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
[newMenuUrl, menu.objid]
);
logger.info(
` 🔗 메뉴 URL 업데이트: ${menu.menu_url}${newMenuUrl}`
);
updatedCount++;
}
}
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}`);
}
/**
* + (최적화: 배치 /)
*/
@ -2477,8 +2553,9 @@ export class MenuCopyService {
}
/**
* (최적화: 배치 /)
* numberingRuleId
* ( 스키마: table_name + column_name )
* /numbering-rules/copy-for-company API를
* ruleIdMap ( numberingRuleService에서 )
*/
private async copyNumberingRulesWithMap(
menuObjids: number[],
@ -2487,221 +2564,46 @@ export class MenuCopyService {
userId: string,
client: PoolClient
): Promise<{ copiedCount: number; ruleIdMap: Map<string, string> }> {
let copiedCount = 0;
const ruleIdMap = new Map<string, string>();
if (menuObjids.length === 0) {
return { copiedCount, ruleIdMap };
}
// 새 스키마에서는 채번규칙이 메뉴와 직접 연결되지 않음
// 프론트엔드에서 /numbering-rules/copy-for-company API를 별도 호출
// 여기서는 기존 규칙 ID를 그대로 매핑 (화면 레이아웃의 numberingRuleId 참조용)
// === 최적화: 배치 조회 ===
// 1. 모든 원본 채번 규칙 한 번에 조회
const allRulesResult = await client.query(
`SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`,
[menuObjids]
// 원본 회사의 채번규칙 조회 (company_code 기반)
const sourceRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[menuObjids.length > 0 ? (await client.query(
`SELECT company_code FROM menu_info WHERE objid = $1`,
[menuObjids[0]]
)).rows[0]?.company_code : null]
);
if (allRulesResult.rows.length === 0) {
logger.info(` 📭 복사할 채번 규칙 없음`);
return { copiedCount, ruleIdMap };
}
// 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요)
const existingRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules WHERE company_code = $1`,
// 대상 회사의 채번규칙 조회 (이름 기준 매핑)
const targetRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[targetCompanyCode]
);
const existingRuleIds = new Set(
existingRulesResult.rows.map((r) => r.rule_id)
const targetRulesByName = new Map(
targetRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id])
);
// 3. 복사할 규칙과 스킵할 규칙 분류
const rulesToCopy: any[] = [];
const originalToNewRuleMap: Array<{ original: string; new: string }> = [];
// 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
for (const rule of allRulesResult.rows) {
// 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가
// 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123
// 예: rule-123 -> rule-123 -> COMPANY_16_rule-123
// 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드
let baseName = rule.rule_id;
// 회사코드 접두사 패턴들을 순서대로 제거 시도
// 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_)
// 2. 일반 접두사_ 패턴 (예: WACE_)
if (baseName.match(/^COMPANY_\d+_/)) {
baseName = baseName.replace(/^COMPANY_\d+_/, "");
} else if (baseName.includes("_")) {
baseName = baseName.replace(/^[^_]+_/, "");
}
const newRuleId = `${targetCompanyCode}_${baseName}`;
if (existingRuleIds.has(rule.rule_id)) {
// 원본 ID가 이미 존재 (동일한 ID로 매핑)
ruleIdMap.set(rule.rule_id, rule.rule_id);
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (newMenuObjid) {
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
}
logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`);
} else if (existingRuleIds.has(newRuleId)) {
// 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑)
ruleIdMap.set(rule.rule_id, newRuleId);
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (newMenuObjid) {
rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid });
}
logger.info(
` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}`
);
} else {
// 새로 복사 필요
ruleIdMap.set(rule.rule_id, newRuleId);
originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId });
rulesToCopy.push({ ...rule, newRuleId });
logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`);
// 이름 기준으로 매핑 생성
for (const sourceRule of sourceRulesResult.rows) {
const targetRuleId = targetRulesByName.get(sourceRule.rule_name);
if (targetRuleId) {
ruleIdMap.set(sourceRule.rule_id, targetRuleId);
logger.info(` 🔗 채번규칙 매핑: ${sourceRule.rule_id} -> ${targetRuleId}`);
}
}
// 4. 배치 INSERT로 채번 규칙 복사
// menu 스코프인데 menu_objid 매핑이 없는 규칙은 제외 (연결 없이 복제하지 않음)
const validRulesToCopy = rulesToCopy.filter((r) => {
if (r.scope_type === "menu") {
const newMenuObjid = menuIdMap.get(r.menu_objid);
if (newMenuObjid === undefined) {
logger.info(` ⏭️ 채번규칙 "${r.rule_name}" 건너뜀: 메뉴 연결 없음 (원본 menu_objid: ${r.menu_objid})`);
// ruleIdMap에서도 제거
ruleIdMap.delete(r.rule_id);
return false; // 복제 대상에서 제외
}
}
return true;
});
logger.info(` 📋 채번규칙 매핑 완료: ${ruleIdMap.size}`);
if (validRulesToCopy.length > 0) {
const ruleValues = validRulesToCopy
.map(
(_, i) =>
`($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})`
)
.join(", ");
const ruleParams = validRulesToCopy.flatMap((r) => {
const newMenuObjid = menuIdMap.get(r.menu_objid);
// menu 스코프인 경우 반드시 menu_objid가 있음 (위에서 필터링됨)
const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null;
// scope_type은 원본 유지 (menu 스코프는 반드시 menu_objid가 있으므로)
const finalScopeType = r.scope_type;
return [
r.newRuleId,
r.rule_name,
r.description,
r.separator,
r.reset_period,
0,
r.table_name,
r.column_name,
targetCompanyCode,
userId,
finalMenuObjid,
finalScopeType,
null,
];
});
await client.query(
`INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
created_at, created_by, menu_objid, scope_type, last_generated_date
) VALUES ${ruleValues}`,
ruleParams
);
copiedCount = validRulesToCopy.length;
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사 (${rulesToCopy.length - validRulesToCopy.length}개 건너뜀)`);
// 실제 복제는 프론트엔드에서 별도 API 호출로 처리됨
return { copiedCount: 0, ruleIdMap };
}
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
if (rulesToUpdate.length > 0) {
// CASE WHEN을 사용한 배치 업데이트
// menu_objid는 numeric 타입이므로 ::numeric 캐스팅 필요
const caseWhen = rulesToUpdate
.map(
(_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}::numeric`
)
.join(" ");
const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId);
const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]);
await client.query(
`UPDATE numbering_rules
SET menu_objid = CASE ${caseWhen} END, updated_at = NOW()
WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`,
[...params, ruleIdsForUpdate, targetCompanyCode]
);
logger.info(
` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신`
);
}
// 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상)
if (rulesToCopy.length > 0) {
const originalRuleIds = rulesToCopy.map((r) => r.rule_id);
const allPartsResult = await client.query(
`SELECT * FROM numbering_rule_parts
WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`,
[originalRuleIds]
);
// 6. 배치 INSERT로 채번 규칙 파트 복사
if (allPartsResult.rows.length > 0) {
// 원본 rule_id -> 새 rule_id 매핑
const ruleMapping = new Map(
originalToNewRuleMap.map((m) => [m.original, m.new])
);
const partValues = allPartsResult.rows
.map(
(_, i) =>
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())`
)
.join(", ");
const partParams = allPartsResult.rows.flatMap((p) => [
ruleMapping.get(p.rule_id),
p.part_order,
p.part_type,
p.generation_method,
p.auto_config,
p.manual_config,
targetCompanyCode,
]);
await client.query(
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, created_at
) VALUES ${partValues}`,
partParams
);
logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`);
}
}
logger.info(
`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}`
);
return { copiedCount, ruleIdMap };
}
/**
* + (최적화: 배치 )

View File

@ -102,6 +102,80 @@ export interface NodeExecutionSummary {
error?: string;
}
// ===== 헬퍼 함수 =====
/**
* 🔧 (, , )
*
*/
function isValidDBValue(v: any): boolean {
// 숫자면 유효 (나중에 문자열로 변환됨)
if (typeof v === "number" && !isNaN(v)) return true;
// 문자열이 아니면 무효
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
}
/**
* 🔧 DB (PostgreSQL )
* - JavaScript ( )
* - PostgreSQL ( )
* - null
*/
function normalizeValueForDB(value: any): any {
// 1. 배열이면 유효한 값만 필터링 후 쉼표 구분 문자열로 변환
if (Array.isArray(value)) {
// 숫자를 문자열로 변환하고 유효한 값만 필터링
const validValues = value
.map(v => typeof v === "number" ? String(v) : v)
.filter(isValidDBValue)
.map(v => typeof v === "number" ? String(v) : v); // 최종 문자열 변환
if (validValues.length === 0) {
console.warn(`⚠️ [normalizeValueForDB] 배열에 유효한 값 없음:`, value);
return null;
}
const normalized = validValues.join(",");
console.log(`🔧 [normalizeValueForDB] 배열→문자열:`, { original: value.length, valid: validValues.length, normalized });
return normalized;
}
// 2. 문자열인데 잘못된 형식이면 정리
if (typeof value === "string" && value) {
// 잘못된 형식 감지
if (value.includes("{") || value.includes("}") || value.includes('\\"') || value.includes("\\\\")) {
console.warn(`⚠️ [normalizeValueForDB] 잘못된 문자열 형식:`, value.substring(0, 80));
// 정규표현식으로 유효한 코드만 추출
const codePattern = /\b(CAT_[A-Z0-9_]+|[A-Z]{2,}_[A-Z0-9_]+)\b/g;
const matches = value.match(codePattern);
if (matches && matches.length > 0) {
const uniqueValues = [...new Set(matches)];
const normalized = uniqueValues.join(",");
console.log(`🔧 [normalizeValueForDB] 코드 추출:`, { count: uniqueValues.length, normalized });
return normalized;
}
console.warn(`⚠️ [normalizeValueForDB] 유효한 코드 없음, null 반환`);
return null;
}
// 쉼표 구분 문자열이면 각 값 검증
if (value.includes(",")) {
const parts = value.split(",").map(v => v.trim()).filter(isValidDBValue);
if (parts.length === 0) {
return null;
}
return parts.join(",");
}
}
return value;
}
// ===== 메인 실행 서비스 =====
export class NodeFlowExecutionService {
@ -845,6 +919,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;
}
@ -1016,10 +1093,12 @@ export class NodeFlowExecutionService {
);
}
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
const normalizedValue = normalizeValueForDB(value);
values.push(normalizedValue);
// 🔥 삽입된 값을 데이터에 반영
insertedData[mapping.targetField] = value;
insertedData[mapping.targetField] = normalizedValue;
}
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
@ -1152,9 +1231,11 @@ export class NodeFlowExecutionService {
mapping.staticValue !== undefined
? mapping.staticValue
: data[mapping.sourceField];
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
const normalizedValue = normalizeValueForDB(value);
values.push(normalizedValue);
// 🔥 삽입된 데이터 객체에 매핑된 값 적용
insertedData[mapping.targetField] = value;
insertedData[mapping.targetField] = normalizedValue;
});
// 외부 DB별 SQL 문법 차이 처리
@ -1490,7 +1571,8 @@ export class NodeFlowExecutionService {
if (mapping.targetField) {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
values.push(normalizeValueForDB(value));
paramIndex++;
}
});
@ -1553,11 +1635,13 @@ export class NodeFlowExecutionService {
// targetField가 비어있지 않은 경우만 추가
if (mapping.targetField) {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
const normalizedValue = normalizeValueForDB(value);
values.push(normalizedValue);
paramIndex++;
// 🔥 업데이트된 값을 데이터에 반영
updatedData[mapping.targetField] = value;
updatedData[mapping.targetField] = normalizedValue;
} else {
console.log(
`⚠️ targetField가 비어있어 스킵: ${mapping.sourceField}`
@ -1682,10 +1766,12 @@ export class NodeFlowExecutionService {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
}
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
const normalizedValue = normalizeValueForDB(value);
values.push(normalizedValue);
paramIndex++;
// 🔥 업데이트된 데이터 객체에 매핑된 값 적용
updatedData[mapping.targetField] = value;
updatedData[mapping.targetField] = normalizedValue;
});
// WHERE 조건 생성
@ -2314,7 +2400,8 @@ export class NodeFlowExecutionService {
? mapping.staticValue
: data[mapping.sourceField];
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
updateValues.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
updateValues.push(normalizeValueForDB(value));
paramIndex++;
}
});
@ -2365,7 +2452,8 @@ export class NodeFlowExecutionService {
? mapping.staticValue
: data[mapping.sourceField];
columns.push(mapping.targetField);
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
values.push(normalizeValueForDB(value));
});
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
@ -2546,7 +2634,8 @@ export class NodeFlowExecutionService {
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
}
updateValues.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
updateValues.push(normalizeValueForDB(value));
paramIndex++;
}
});
@ -2584,7 +2673,8 @@ export class NodeFlowExecutionService {
? mapping.staticValue
: data[mapping.sourceField];
columns.push(mapping.targetField);
values.push(value);
// 🔧 배열을 쉼표 구분 문자열로 변환
values.push(normalizeValueForDB(value));
});
let insertSql: string;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,520 @@
/**
*
*
* , , .
*/
import { pool } from "../database/db";
// ============================================================================
// 타입 정의
// ============================================================================
export interface ScheduleGenerationConfig {
scheduleType: "PRODUCTION" | "MAINTENANCE" | "SHIPPING" | "WORK_ASSIGN";
source: {
tableName: string;
groupByField: string;
quantityField: string;
dueDateField?: string;
};
resource: {
type: string;
idField: string;
nameField: string;
};
rules: {
leadTimeDays?: number;
dailyCapacity?: number;
workingDays?: number[];
considerStock?: boolean;
stockTableName?: string;
stockQtyField?: string;
safetyStockField?: string;
};
target: {
tableName: string;
};
}
export interface SchedulePreview {
toCreate: any[];
toDelete: any[];
toUpdate: any[];
summary: {
createCount: number;
deleteCount: number;
updateCount: number;
totalQty: number;
};
}
export interface ApplyOptions {
deleteExisting: boolean;
updateMode: "replace" | "merge";
}
export interface ApplyResult {
created: number;
deleted: number;
updated: number;
}
export interface ScheduleListQuery {
scheduleType?: string;
resourceType?: string;
resourceId?: string;
startDate?: string;
endDate?: string;
status?: string;
companyCode: string;
}
// ============================================================================
// 서비스 클래스
// ============================================================================
export class ScheduleService {
/**
*
*/
async generatePreview(
config: ScheduleGenerationConfig,
sourceData: any[],
period: { start: string; end: string } | undefined,
companyCode: string
): Promise<SchedulePreview> {
console.log("[ScheduleService] generatePreview 시작:", {
scheduleType: config.scheduleType,
sourceDataCount: sourceData.length,
period,
companyCode,
});
// 기본 기간 설정 (현재 월)
const now = new Date();
const defaultPeriod = {
start: new Date(now.getFullYear(), now.getMonth(), 1)
.toISOString()
.split("T")[0],
end: new Date(now.getFullYear(), now.getMonth() + 1, 0)
.toISOString()
.split("T")[0],
};
const effectivePeriod = period || defaultPeriod;
// 1. 소스 데이터를 리소스별로 그룹화
const groupedData = this.groupByResource(sourceData, config);
// 2. 각 리소스에 대해 스케줄 생성
const toCreate: any[] = [];
let totalQty = 0;
for (const [resourceId, items] of Object.entries(groupedData)) {
const schedules = this.generateSchedulesForResource(
resourceId,
items as any[],
config,
effectivePeriod,
companyCode
);
toCreate.push(...schedules);
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 toDelete = await this.getExistingSchedules(
config.scheduleType,
resourceIds,
effectivePeriod,
companyCode
);
// 4. 미리보기 결과 생성
const preview: SchedulePreview = {
toCreate,
toDelete,
toUpdate: [], // 현재는 Replace 모드만 지원
summary: {
createCount: toCreate.length,
deleteCount: toDelete.length,
updateCount: 0,
totalQty,
},
};
console.log("[ScheduleService] generatePreview 완료:", preview.summary);
return preview;
}
/**
*
*/
async applySchedules(
config: ScheduleGenerationConfig,
preview: SchedulePreview,
options: ApplyOptions,
companyCode: string,
userId: string
): Promise<ApplyResult> {
console.log("[ScheduleService] applySchedules 시작:", {
createCount: preview.summary.createCount,
deleteCount: preview.summary.deleteCount,
options,
companyCode,
userId,
});
const client = await pool.connect();
const result: ApplyResult = { created: 0, deleted: 0, updated: 0 };
try {
await client.query("BEGIN");
// 1. 기존 스케줄 삭제
if (options.deleteExisting && preview.toDelete.length > 0) {
const deleteIds = preview.toDelete.map((s) => s.schedule_id);
await client.query(
`DELETE FROM schedule_mng
WHERE schedule_id = ANY($1) AND company_code = $2`,
[deleteIds, companyCode]
);
result.deleted = deleteIds.length;
console.log("[ScheduleService] 스케줄 삭제 완료:", result.deleted);
}
// 2. 새 스케줄 생성
for (const schedule of preview.toCreate) {
await client.query(
`INSERT INTO schedule_mng (
company_code, schedule_type, schedule_name,
resource_type, resource_id, resource_name,
start_date, end_date, due_date,
plan_qty, unit, status, priority,
source_table, source_id, source_group_key,
auto_generated, generated_at, generated_by,
metadata, created_by, updated_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
)`,
[
companyCode,
schedule.schedule_type,
schedule.schedule_name,
schedule.resource_type,
schedule.resource_id,
schedule.resource_name,
schedule.start_date,
schedule.end_date,
schedule.due_date || null,
schedule.plan_qty,
schedule.unit || null,
schedule.status || "PLANNED",
schedule.priority || null,
schedule.source_table || null,
schedule.source_id || null,
schedule.source_group_key || null,
true,
new Date(),
userId,
schedule.metadata ? JSON.stringify(schedule.metadata) : null,
userId,
userId,
]
);
result.created++;
}
await client.query("COMMIT");
console.log("[ScheduleService] applySchedules 완료:", result);
return result;
} catch (error) {
await client.query("ROLLBACK");
console.error("[ScheduleService] applySchedules 오류:", error);
throw error;
} finally {
client.release();
}
}
/**
*
*/
async getScheduleList(
query: ScheduleListQuery
): Promise<{ data: any[]; total: number }> {
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// company_code 필터
if (query.companyCode !== "*") {
conditions.push(`company_code = $${paramIndex++}`);
params.push(query.companyCode);
}
// scheduleType 필터
if (query.scheduleType) {
conditions.push(`schedule_type = $${paramIndex++}`);
params.push(query.scheduleType);
}
// resourceType 필터
if (query.resourceType) {
conditions.push(`resource_type = $${paramIndex++}`);
params.push(query.resourceType);
}
// resourceId 필터
if (query.resourceId) {
conditions.push(`resource_id = $${paramIndex++}`);
params.push(query.resourceId);
}
// 기간 필터
if (query.startDate) {
conditions.push(`end_date >= $${paramIndex++}`);
params.push(query.startDate);
}
if (query.endDate) {
conditions.push(`start_date <= $${paramIndex++}`);
params.push(query.endDate);
}
// status 필터
if (query.status) {
conditions.push(`status = $${paramIndex++}`);
params.push(query.status);
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT * FROM schedule_mng
${whereClause}
ORDER BY start_date, resource_id`,
params
);
return {
data: result.rows,
total: result.rows.length,
};
}
/**
*
*/
async deleteSchedule(
scheduleId: number,
companyCode: string,
userId: string
): Promise<{ success: boolean; message?: string }> {
const result = await pool.query(
`DELETE FROM schedule_mng
WHERE schedule_id = $1 AND (company_code = $2 OR $2 = '*')
RETURNING schedule_id`,
[scheduleId, companyCode]
);
if (result.rowCount === 0) {
return {
success: false,
message: "스케줄을 찾을 수 없거나 권한이 없습니다.",
};
}
// 이력 기록
await pool.query(
`INSERT INTO schedule_history (company_code, schedule_id, action, changed_by)
VALUES ($1, $2, 'DELETE', $3)`,
[companyCode, scheduleId, userId]
);
return { success: true };
}
// ============================================================================
// 헬퍼 메서드
// ============================================================================
/**
*
* - (dueDateField) 경우: 리소스 +
* - 경우: 리소스별로만
*/
private groupByResource(
sourceData: any[],
config: ScheduleGenerationConfig
): Record<string, any[]> {
const grouped: Record<string, any[]> = {};
const dueDateField = config.source.dueDateField;
for (const item of sourceData) {
const resourceId = item[config.resource.idField];
if (!resourceId) continue;
// 그룹 키 생성: 기준일이 있으면 "리소스ID|기준일", 없으면 "리소스ID"
let groupKey = resourceId;
if (dueDateField && item[dueDateField]) {
// 날짜를 YYYY-MM-DD 형식으로 정규화
const dueDate = new Date(item[dueDateField])
.toISOString()
.split("T")[0];
groupKey = `${resourceId}|${dueDate}`;
}
if (!grouped[groupKey]) {
grouped[groupKey] = [];
}
grouped[groupKey].push(item);
}
console.log("[ScheduleService] 그룹화 결과:", {
groupCount: Object.keys(grouped).length,
groups: Object.keys(grouped),
dueDateField,
});
return grouped;
}
/**
*
* - groupKey : "리소스ID" "리소스ID|기준일(YYYY-MM-DD)"
*/
private generateSchedulesForResource(
groupKey: string,
items: any[],
config: ScheduleGenerationConfig,
period: { start: string; end: string },
companyCode: string
): any[] {
const schedules: any[] = [];
// 그룹 키에서 리소스ID와 기준일 분리
const [resourceId, groupDueDate] = groupKey.split("|");
const resourceName = items[0]?.[config.resource.nameField] || resourceId;
// 총 수량 계산
const totalQty = items.reduce((sum, item) => {
return sum + (parseFloat(item[config.source.quantityField]) || 0);
}, 0);
if (totalQty <= 0) return schedules;
// 스케줄 규칙 적용
const {
leadTimeDays = 3,
dailyCapacity = totalQty,
workingDays = [1, 2, 3, 4, 5],
} = config.rules;
// 기준일(납기일/마감일) 결정
let dueDate: Date;
if (groupDueDate) {
// 그룹 키에 기준일이 포함된 경우
dueDate = new Date(groupDueDate);
} else if (config.source.dueDateField) {
// 아이템에서 기준일 찾기 (가장 빠른 날짜)
let earliestDate: Date | null = null;
for (const item of items) {
const itemDueDate = item[config.source.dueDateField];
if (itemDueDate) {
const date = new Date(itemDueDate);
if (!earliestDate || date < earliestDate) {
earliestDate = date;
}
}
}
dueDate = earliestDate || new Date(period.end);
} else {
// 기준일이 없으면 기간 종료일 사용
dueDate = new Date(period.end);
}
// 종료일 = 기준일 (납기일에 맞춰 완료)
const endDate = new Date(dueDate);
// 시작일 계산 (종료일에서 리드타임만큼 역산)
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - leadTimeDays);
// 스케줄명 생성 (기준일 포함)
const dueDateStr = dueDate.toISOString().split("T")[0];
const scheduleName = groupDueDate
? `${resourceName} (${dueDateStr})`
: `${resourceName} - ${config.scheduleType}`;
// 스케줄 생성
schedules.push({
schedule_type: config.scheduleType,
schedule_name: scheduleName,
resource_type: config.resource.type,
resource_id: resourceId,
resource_name: resourceName,
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
due_date: dueDate.toISOString(),
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_group_key: resourceId,
metadata: {
sourceCount: items.length,
dailyCapacity,
leadTimeDays,
workingDays,
groupDueDate: groupDueDate || null,
},
});
console.log("[ScheduleService] 스케줄 생성:", {
groupKey,
resourceId,
resourceName,
dueDate: dueDateStr,
totalQty,
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
});
return schedules;
}
/**
* ( )
*/
private async getExistingSchedules(
scheduleType: string,
resourceIds: string[],
period: { start: string; end: string },
companyCode: string
): Promise<any[]> {
if (resourceIds.length === 0) return [];
const result = await pool.query(
`SELECT * FROM schedule_mng
WHERE schedule_type = $1
AND resource_id = ANY($2)
AND end_date >= $3
AND start_date <= $4
AND (company_code = $5 OR $5 = '*')
AND auto_generated = true`,
[scheduleType, resourceIds, period.start, period.end, companyCode]
);
return result.rows;
}
}

View File

@ -635,7 +635,76 @@ export class ScreenManagementService {
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 (Raw Query)
await transaction(async (client) => {
// 소프트 삭제 (휴지통으로 이동)
// 1. 화면에서 사용하는 flowId 수집 (V2 레이아웃)
const layoutResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END
LIMIT 1`,
[screenId, userCompanyCode],
);
const layoutData = layoutResult.rows[0]?.layout_data;
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
// 2. 각 flowId가 다른 화면에서도 사용되는지 체크 후 삭제
if (flowIds.size > 0) {
for (const flowId of flowIds) {
// 다른 화면에서 사용 중인지 확인 (같은 회사 내, 삭제되지 않은 화면 기준)
const companyFilterForCheck = userCompanyCode === "*" ? "" : " AND sd.company_code = $3";
const checkParams = userCompanyCode === "*"
? [screenId, flowId]
: [screenId, flowId, userCompanyCode];
const otherUsageResult = await client.query<{ count: string }>(
`SELECT COUNT(*) as count FROM screen_layouts_v2 slv
JOIN screen_definitions sd ON slv.screen_id = sd.screen_id
WHERE slv.screen_id != $1
AND sd.is_active != 'D'
${companyFilterForCheck}
AND (
slv.layout_data::text LIKE '%"flowId":' || $2 || '%'
OR slv.layout_data::text LIKE '%"flowId":"' || $2 || '"%'
)`,
checkParams,
);
const otherUsageCount = parseInt(otherUsageResult.rows[0]?.count || "0");
// 다른 화면에서 사용하지 않는 경우에만 플로우 삭제
if (otherUsageCount === 0) {
// 해당 회사의 플로우만 삭제 (멀티테넌시)
const companyFilter = userCompanyCode === "*" ? "" : " AND company_code = $2";
const flowParams = userCompanyCode === "*" ? [flowId] : [flowId, userCompanyCode];
// 1. flow_definition 관련 데이터 먼저 삭제 (외래키 순서)
await client.query(
`DELETE FROM flow_step_connection WHERE flow_definition_id = $1`,
[flowId],
);
await client.query(
`DELETE FROM flow_step WHERE flow_definition_id = $1`,
[flowId],
);
await client.query(
`DELETE FROM flow_definition WHERE id = $1${companyFilter}`,
flowParams,
);
// 2. node_flows 테이블에서도 삭제 (제어플로우)
await client.query(
`DELETE FROM node_flows WHERE flow_id = $1${companyFilter}`,
flowParams,
);
logger.info("화면 삭제 시 플로우 삭제 (flow_definition + node_flows)", { screenId, flowId, companyCode: userCompanyCode });
} else {
logger.debug("플로우가 다른 화면에서 사용 중 - 삭제 스킵", { screenId, flowId, otherUsageCount });
}
}
}
// 3. 소프트 삭제 (휴지통으로 이동)
await client.query(
`UPDATE screen_definitions
SET is_active = 'D',
@ -655,13 +724,21 @@ export class ScreenManagementService {
],
);
// 메뉴 할당도 비활성화
// 4. 메뉴 할당도 비활성화
await client.query(
`UPDATE screen_menu_assignments
SET is_active = 'N'
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 });
});
}
@ -1665,18 +1742,28 @@ export class ScreenManagementService {
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
const layoutData = v2Layout.layout_data;
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
const getTypeFromUrl = (url: string | undefined): string => {
if (!url) return "component";
const parts = url.split("/");
return parts[parts.length - 1] || "component";
};
// V2 형식의 components를 LayoutData 형식으로 변환
const components = (layoutData.components || []).map((comp: any) => ({
const components = (layoutData.components || []).map((comp: any) => {
const componentType = getTypeFromUrl(comp.url);
return {
id: comp.id,
type: comp.overrides?.type || "component",
type: componentType,
position: comp.position || { x: 0, y: 0, z: 1 },
size: comp.size || { width: 200, height: 100 },
componentUrl: comp.url,
componentType: comp.overrides?.type,
componentType: componentType,
componentConfig: comp.overrides || {},
displayOrder: comp.displayOrder || 0,
...comp.overrides,
}));
};
});
// screenResolution이 없으면 컴포넌트 위치 기반으로 자동 계산
let screenResolution = layoutData.screenResolution;
@ -2936,7 +3023,7 @@ export class ScreenManagementService {
* - current_sequence는 0
*/
/**
* (numbering_rules_test )
* (numbering_rules )
* - menu_objid
* - table_name + column_name + company_code
*/
@ -2954,10 +3041,10 @@ export class ScreenManagementService {
console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`);
// 1. 원본 채번 규칙 조회 (numbering_rules_test 테이블)
// 1. 원본 채번 규칙 조회 (numbering_rules 테이블)
const ruleIdArray = Array.from(ruleIds);
const sourceRulesResult = await client.query(
`SELECT * FROM numbering_rules_test WHERE rule_id = ANY($1)`,
`SELECT * FROM numbering_rules WHERE rule_id = ANY($1)`,
[ruleIdArray],
);
@ -2970,7 +3057,7 @@ export class ScreenManagementService {
// 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준)
const existingRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules_test WHERE company_code = $1`,
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[targetCompanyCode],
);
const existingRulesByName = new Map<string, string>(
@ -2991,9 +3078,9 @@ export class ScreenManagementService {
// 새로 복사 - 새 rule_id 생성
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// numbering_rules_test 복사 (current_sequence = 0으로 초기화)
// numbering_rules 복사 (current_sequence = 0으로 초기화)
await client.query(
`INSERT INTO numbering_rules_test (
`INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
created_at, updated_at, created_by, last_generated_date,
@ -3018,15 +3105,15 @@ export class ScreenManagementService {
],
);
// numbering_rule_parts_test 복사
// numbering_rule_parts 복사
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`,
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
[rule.rule_id],
);
for (const part of partsResult.rows) {
await client.query(
`INSERT INTO numbering_rule_parts_test (
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
@ -3471,6 +3558,371 @@ export class ScreenManagementService {
return flowIds;
}
/**
* V2 flowId (screen_layouts_v2용)
* - overrides.flowId (flow-widget)
* - overrides.webTypeConfig.dataflowConfig.flowConfig.flowId ()
* - overrides.webTypeConfig.dataflowConfig.flowControls[].flowId
* - overrides.action.excelAfterUploadFlows[].flowId
*/
private collectFlowIdsFromLayoutData(layoutData: any): Set<number> {
const flowIds = new Set<number>();
if (!layoutData?.components) return flowIds;
for (const comp of layoutData.components) {
const overrides = comp.overrides || {};
// 1. overrides.flowId (flow-widget 등)
if (overrides.flowId && !isNaN(parseInt(overrides.flowId))) {
flowIds.add(parseInt(overrides.flowId));
}
// 2. webTypeConfig.dataflowConfig.flowConfig.flowId (버튼)
const flowConfigId = overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
if (flowConfigId && !isNaN(parseInt(flowConfigId))) {
flowIds.add(parseInt(flowConfigId));
}
// 3. webTypeConfig.dataflowConfig.selectedDiagramId
const diagramId = overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
if (diagramId && !isNaN(parseInt(diagramId))) {
flowIds.add(parseInt(diagramId));
}
// 4. webTypeConfig.dataflowConfig.flowControls[].flowId
const flowControls = overrides?.webTypeConfig?.dataflowConfig?.flowControls;
if (Array.isArray(flowControls)) {
for (const control of flowControls) {
if (control?.flowId && !isNaN(parseInt(control.flowId))) {
flowIds.add(parseInt(control.flowId));
}
}
}
// 5. action.excelAfterUploadFlows[].flowId
const excelFlows = overrides?.action?.excelAfterUploadFlows;
if (Array.isArray(excelFlows)) {
for (const flow of excelFlows) {
if (flow?.flowId && !isNaN(parseInt(flow.flowId))) {
flowIds.add(parseInt(flow.flowId));
}
}
}
}
return flowIds;
}
/**
* V2 numberingRuleId (screen_layouts_v2용)
* - overrides.autoGeneration.options.numberingRuleId
* - overrides.sections[].fields[].numberingRule.ruleId
* - overrides.action.excelNumberingRuleId
* - overrides.action.numberingRuleId
*/
private collectNumberingRuleIdsFromLayoutData(layoutData: any): Set<string> {
const ruleIds = new Set<string>();
if (!layoutData?.components) return ruleIds;
for (const comp of layoutData.components) {
const overrides = comp.overrides || {};
// 1. autoGeneration.options.numberingRuleId
const autoGenRuleId = overrides?.autoGeneration?.options?.numberingRuleId;
if (autoGenRuleId && typeof autoGenRuleId === "string" && autoGenRuleId.startsWith("rule-")) {
ruleIds.add(autoGenRuleId);
}
// 2. sections[].fields[].numberingRule.ruleId
const sections = overrides?.sections;
if (Array.isArray(sections)) {
for (const section of sections) {
const fields = section?.fields;
if (Array.isArray(fields)) {
for (const field of fields) {
const ruleId = field?.numberingRule?.ruleId;
if (ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-")) {
ruleIds.add(ruleId);
}
}
}
// optionalFieldGroups 내부
const optGroups = section?.optionalFieldGroups;
if (Array.isArray(optGroups)) {
for (const optGroup of optGroups) {
const optFields = optGroup?.fields;
if (Array.isArray(optFields)) {
for (const field of optFields) {
const ruleId = field?.numberingRule?.ruleId;
if (ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-")) {
ruleIds.add(ruleId);
}
}
}
}
}
}
}
// 3. action.excelNumberingRuleId
const excelRuleId = overrides?.action?.excelNumberingRuleId;
if (excelRuleId && typeof excelRuleId === "string" && excelRuleId.startsWith("rule-")) {
ruleIds.add(excelRuleId);
}
// 4. action.numberingRuleId
const actionRuleId = overrides?.action?.numberingRuleId;
if (actionRuleId && typeof actionRuleId === "string" && actionRuleId.startsWith("rule-")) {
ruleIds.add(actionRuleId);
}
}
return ruleIds;
}
/**
* V2 ID들을
* - componentId, flowId, numberingRuleId, screenId
*/
private updateReferencesInLayoutData(
layoutData: any,
mappings: {
componentIdMap: Map<string, string>;
flowIdMap?: Map<number, number>;
ruleIdMap?: Map<string, string>;
screenIdMap?: Map<number, number>;
},
): any {
if (!layoutData?.components) return layoutData;
const updatedComponents = layoutData.components.map((comp: any) => {
// 1. componentId 매핑
const newId = mappings.componentIdMap.get(comp.id) || comp.id;
// 2. overrides 복사 및 참조 업데이트
let overrides = JSON.parse(JSON.stringify(comp.overrides || {}));
// flowId 매핑
if (mappings.flowIdMap && mappings.flowIdMap.size > 0) {
overrides = this.updateFlowIdsInOverrides(overrides, mappings.flowIdMap);
}
// numberingRuleId 매핑
if (mappings.ruleIdMap && mappings.ruleIdMap.size > 0) {
overrides = this.updateNumberingRuleIdsInOverrides(overrides, mappings.ruleIdMap);
}
// screenId 매핑 (탭, 버튼 등)
if (mappings.screenIdMap && mappings.screenIdMap.size > 0) {
overrides = this.updateScreenIdsInOverrides(overrides, mappings.screenIdMap);
}
return {
...comp,
id: newId,
overrides,
};
});
return {
...layoutData,
components: updatedComponents,
updatedAt: new Date().toISOString(),
};
}
/**
* V2 overrides flowId
*/
private updateFlowIdsInOverrides(
overrides: any,
flowIdMap: Map<number, number>,
): any {
if (!overrides || flowIdMap.size === 0) return overrides;
// 1. overrides.flowId (flow-widget)
if (overrides.flowId) {
const oldId = parseInt(overrides.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
overrides.flowId = newId;
console.log(` 🔗 flowId: ${oldId}${newId}`);
}
}
// 2. webTypeConfig.dataflowConfig.flowConfig.flowId
if (overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) {
const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.flowConfig.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
overrides.webTypeConfig.dataflowConfig.flowConfig.flowId = newId;
console.log(` 🔗 flowConfig.flowId: ${oldId}${newId}`);
}
}
// 3. webTypeConfig.dataflowConfig.selectedDiagramId
if (overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId) {
const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.selectedDiagramId);
const newId = flowIdMap.get(oldId);
if (newId) {
overrides.webTypeConfig.dataflowConfig.selectedDiagramId = newId;
console.log(` 🔗 selectedDiagramId: ${oldId}${newId}`);
}
}
// 4. webTypeConfig.dataflowConfig.flowControls[]
if (Array.isArray(overrides?.webTypeConfig?.dataflowConfig?.flowControls)) {
for (const control of overrides.webTypeConfig.dataflowConfig.flowControls) {
if (control?.flowId) {
const oldId = parseInt(control.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
control.flowId = newId;
console.log(` 🔗 flowControls.flowId: ${oldId}${newId}`);
}
}
}
}
// 5. action.excelAfterUploadFlows[]
if (Array.isArray(overrides?.action?.excelAfterUploadFlows)) {
for (const flow of overrides.action.excelAfterUploadFlows) {
if (flow?.flowId) {
const oldId = parseInt(flow.flowId);
const newId = flowIdMap.get(oldId);
if (newId) {
flow.flowId = newId;
console.log(` 🔗 excelAfterUploadFlows.flowId: ${oldId}${newId}`);
}
}
}
}
return overrides;
}
/**
* V2 overrides numberingRuleId
*/
private updateNumberingRuleIdsInOverrides(
overrides: any,
ruleIdMap: Map<string, string>,
): any {
if (!overrides || ruleIdMap.size === 0) return overrides;
// 1. autoGeneration.options.numberingRuleId
if (overrides?.autoGeneration?.options?.numberingRuleId) {
const oldId = overrides.autoGeneration.options.numberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
overrides.autoGeneration.options.numberingRuleId = newId;
console.log(` 🔗 autoGeneration.numberingRuleId: ${oldId}${newId}`);
}
}
// 2. sections[].fields[].numberingRule.ruleId
if (Array.isArray(overrides?.sections)) {
for (const section of overrides.sections) {
if (Array.isArray(section?.fields)) {
for (const field of section.fields) {
if (field?.numberingRule?.ruleId) {
const oldId = field.numberingRule.ruleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
field.numberingRule.ruleId = newId;
console.log(` 🔗 field.numberingRule.ruleId: ${oldId}${newId}`);
}
}
}
}
if (Array.isArray(section?.optionalFieldGroups)) {
for (const optGroup of section.optionalFieldGroups) {
if (Array.isArray(optGroup?.fields)) {
for (const field of optGroup.fields) {
if (field?.numberingRule?.ruleId) {
const oldId = field.numberingRule.ruleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
field.numberingRule.ruleId = newId;
console.log(` 🔗 optField.numberingRule.ruleId: ${oldId}${newId}`);
}
}
}
}
}
}
}
}
// 3. action.excelNumberingRuleId
if (overrides?.action?.excelNumberingRuleId) {
const oldId = overrides.action.excelNumberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
overrides.action.excelNumberingRuleId = newId;
console.log(` 🔗 excelNumberingRuleId: ${oldId}${newId}`);
}
}
// 4. action.numberingRuleId
if (overrides?.action?.numberingRuleId) {
const oldId = overrides.action.numberingRuleId;
const newId = ruleIdMap.get(oldId);
if (newId) {
overrides.action.numberingRuleId = newId;
console.log(` 🔗 action.numberingRuleId: ${oldId}${newId}`);
}
}
return overrides;
}
/**
* V2 overrides screenId (, )
*/
private updateScreenIdsInOverrides(
overrides: any,
screenIdMap: Map<number, number>,
): any {
if (!overrides || screenIdMap.size === 0) return overrides;
// 1. tabs[].screenId (탭 위젯)
if (Array.isArray(overrides?.tabs)) {
for (const tab of overrides.tabs) {
if (tab?.screenId) {
const oldId = parseInt(tab.screenId);
const newId = screenIdMap.get(oldId);
if (newId) {
tab.screenId = newId;
console.log(` 🔗 tab.screenId: ${oldId}${newId}`);
}
}
}
}
// 2. action.targetScreenId (버튼)
if (overrides?.action?.targetScreenId) {
const oldId = parseInt(overrides.action.targetScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
overrides.action.targetScreenId = newId;
console.log(` 🔗 action.targetScreenId: ${oldId}${newId}`);
}
}
// 3. action.modalScreenId
if (overrides?.action?.modalScreenId) {
const oldId = parseInt(overrides.action.modalScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
overrides.action.modalScreenId = newId;
console.log(` 🔗 action.modalScreenId: ${oldId}${newId}`);
}
}
return overrides;
}
/**
* ID
* -
@ -3709,24 +4161,34 @@ export class ScreenManagementService {
const newScreen = newScreenResult.rows[0];
// 4. 원본 화면의 레이아웃 정보 조회
const sourceLayoutsResult = await client.query<any>(
`SELECT * FROM screen_layouts
WHERE screen_id = $1
ORDER BY display_order ASC NULLS LAST`,
[sourceScreenId],
// 4. 원본 화면의 V2 레이아웃 조회
let sourceLayoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[sourceScreenId, sourceScreen.company_code],
);
const sourceLayouts = sourceLayoutsResult.rows;
// 없으면 공통(*) 레이아웃 조회
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data;
if (!layoutData && sourceScreen.company_code !== "*") {
const fallbackResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
[sourceScreenId],
);
layoutData = fallbackResult.rows[0]?.layout_data;
}
const components = layoutData?.components || [];
// 5. 노드 플로우 복사 (회사가 다른 경우)
let flowIdMap = new Map<number, number>();
if (
sourceLayouts.length > 0 &&
components.length > 0 &&
sourceScreen.company_code !== targetCompanyCode
) {
// 레이아웃에서 사용하는 flowId 수집
const flowIds = this.collectFlowIdsFromLayouts(sourceLayouts);
// V2 레이아웃에서 flowId 수집
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
if (flowIds.size > 0) {
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}`);
@ -3744,11 +4206,11 @@ export class ScreenManagementService {
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
let ruleIdMap = new Map<string, string>();
if (
sourceLayouts.length > 0 &&
components.length > 0 &&
sourceScreen.company_code !== targetCompanyCode
) {
// 레이아웃에서 사용하는 채번 규칙 ID 수집
const ruleIds = this.collectNumberingRuleIdsFromLayouts(sourceLayouts);
// V2 레이아웃에서 채번 규칙 ID 수집
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
if (ruleIds.size > 0) {
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}`);
@ -3763,81 +4225,43 @@ export class ScreenManagementService {
}
}
// 6. 레이아웃이 있다면 복사
if (sourceLayouts.length > 0) {
// 6. V2 레이아웃이 있다면 복사
if (layoutData && components.length > 0) {
try {
// ID 매핑 맵 생성
const idMapping: { [oldId: string]: string } = {};
// 새로운 컴포넌트 ID 미리 생성
sourceLayouts.forEach((layout: any) => {
idMapping[layout.component_id] = generateId();
});
// 각 레이아웃 컴포넌트 복사
for (const sourceLayout of sourceLayouts) {
const newComponentId = idMapping[sourceLayout.component_id];
const newParentId = sourceLayout.parent_id
? idMapping[sourceLayout.parent_id]
: null;
// properties 파싱
let properties = sourceLayout.properties;
if (typeof properties === "string") {
try {
properties = JSON.parse(properties);
} catch (e) {
// 파싱 실패 시 그대로 사용
}
// componentId 매핑 생성
const componentIdMap = new Map<string, string>();
for (const comp of components) {
componentIdMap.set(comp.id, generateId());
}
// flowId 매핑 적용 (회사가 다른 경우)
if (flowIdMap.size > 0) {
properties = this.updateFlowIdsInProperties(
properties,
flowIdMap,
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutData(
layoutData,
{
componentIdMap,
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
},
);
}
// 채번 규칙 ID 매핑 적용 (회사가 다른 경우)
if (ruleIdMap.size > 0) {
properties = this.updateNumberingRuleIdsInProperties(
properties,
ruleIdMap,
);
}
// 탭 컴포넌트의 screenId는 개별 복제 시점에 업데이트하지 않음
// 모든 화면 복제 완료 후 updateTabScreenReferences에서 screenIdMap 기반으로 일괄 업데이트
// V2 레이아웃 저장 (UPSERT)
await client.query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties,
display_order, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
newScreen.screen_id,
sourceLayout.component_type,
newComponentId,
newParentId,
Math.round(sourceLayout.position_x), // 정수로 반올림
Math.round(sourceLayout.position_y), // 정수로 반올림
Math.round(sourceLayout.width), // 정수로 반올림
Math.round(sourceLayout.height), // 정수로 반올림
JSON.stringify(properties),
sourceLayout.display_order,
new Date(),
],
`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()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
);
}
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
} catch (error) {
console.error("레이아웃 복사 중 오류:", error);
console.error("V2 레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
}
}
// 6. 생성된 화면 정보 반환
// 7. 생성된 화면 정보 반환
return {
screenId: newScreen.screen_id,
screenCode: newScreen.screen_code,
@ -4195,7 +4619,8 @@ export class ScreenManagementService {
);
if (menuInfo.rows.length > 0) {
const isAdminMenu = menuInfo.rows[0].menu_type === "1";
// menu_type: "0" = 관리자 메뉴, "1" = 사용자 메뉴
const isAdminMenu = menuInfo.rows[0].menu_type === "0";
const newMenuUrl = isAdminMenu
? `/screens/${newScreenId}?mode=admin`
: `/screens/${newScreenId}`;
@ -4248,6 +4673,15 @@ export class ScreenManagementService {
details: [] as string[],
};
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 코드 카테고리/코드 복제 시도 - 스킵: ${sourceCompanyCode}`,
);
result.details.push("같은 회사로는 복제할 수 없습니다.");
return result;
}
return transaction(async (client) => {
logger.info(
`📦 코드 카테고리/코드 복제: ${sourceCompanyCode}${targetCompanyCode}`,
@ -4351,7 +4785,7 @@ export class ScreenManagementService {
}
/**
* (category_values_test )
* (category_values )
* - menu_objid
* - table_name + column_name + company_code
*/
@ -4369,20 +4803,29 @@ export class ScreenManagementService {
details: [] as string[],
};
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 카테고리 값 복제 시도 - 스킵: ${sourceCompanyCode}`,
);
result.details.push("같은 회사로는 복제할 수 없습니다.");
return result;
}
return transaction(async (client) => {
logger.info(
`📦 카테고리 값 복제: ${sourceCompanyCode}${targetCompanyCode}`,
);
// 1. 기존 대상 회사 데이터 삭제
// 1. 기존 대상 회사 데이터 삭제 (다른 회사로 복제 시에만)
await client.query(
`DELETE FROM category_values_test WHERE company_code = $1`,
`DELETE FROM category_values WHERE company_code = $1`,
[targetCompanyCode],
);
// 2. category_values_test 복제
// 2. category_values 복제
const values = await client.query(
`SELECT * FROM category_values_test WHERE company_code = $1`,
`SELECT * FROM category_values WHERE company_code = $1`,
[sourceCompanyCode],
);
@ -4391,7 +4834,7 @@ export class ScreenManagementService {
for (const v of values.rows) {
const insertResult = await client.query(
`INSERT INTO category_values_test
`INSERT INTO category_values
(table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code, created_by)
@ -4426,7 +4869,7 @@ export class ScreenManagementService {
const newValueId = valueIdMap.get(v.value_id);
if (newParentId && newValueId) {
await client.query(
`UPDATE category_values_test SET parent_value_id = $1 WHERE value_id = $2`,
`UPDATE category_values SET parent_value_id = $1 WHERE value_id = $2`,
[newParentId, newValueId],
);
}
@ -4451,6 +4894,15 @@ export class ScreenManagementService {
details: [] as string[],
};
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 테이블 타입 컬럼 복제 시도 - 스킵: ${sourceCompanyCode}`,
);
result.details.push("같은 회사로는 복제할 수 없습니다.");
return result;
}
return transaction(async (client) => {
logger.info(
`📦 테이블 타입 컬럼 복제: ${sourceCompanyCode}${targetCompanyCode}`,
@ -4514,6 +4966,15 @@ export class ScreenManagementService {
details: [] as string[],
};
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
if (sourceCompanyCode === targetCompanyCode) {
logger.warn(
`⚠️ 같은 회사로 연쇄관계 설정 복제 시도 - 스킵: ${sourceCompanyCode}`,
);
result.details.push("같은 회사로는 복제할 수 없습니다.");
return result;
}
return transaction(async (client) => {
logger.info(
`📦 연쇄관계 설정 복제: ${sourceCompanyCode}${targetCompanyCode}`,

View File

@ -212,22 +212,22 @@ class TableCategoryValueService {
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM category_values_test
FROM category_values
WHERE table_name = $1
AND column_name = $2
`;
// category_values_test 테이블 사용 (menu_objid 없음)
// category_values 테이블 사용 (menu_objid 없음)
if (companyCode === "*") {
// 최고 관리자: 모든 값 조회
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values_test)");
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)");
} else {
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (category_values_test)", { companyCode });
logger.info("회사별 카테고리 값 조회 (category_values)", { companyCode });
}
if (!includeInactive) {

View File

@ -289,6 +289,15 @@ export class TableManagementService {
companyCode,
});
try {
// menu_objid 컬럼이 있는지 먼저 확인
const columnCheck = await query<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
);
if (columnCheck.length > 0) {
// menu_objid 컬럼이 있는 경우
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
@ -303,7 +312,6 @@ export class TableManagementService {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
mappings.forEach((m: any) => {
@ -312,6 +320,17 @@ export class TableManagementService {
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} else {
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
logger.info(
"⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"
);
}
} catch (mappingError: any) {
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
error: mappingError.message,
});
}
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size,
@ -456,13 +475,25 @@ export class TableManagementService {
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
);
// 🔥 "direct" 또는 "auto"는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - "text"로 변환
if (settings.inputType === "direct" || settings.inputType === "auto") {
logger.warn(
`잘못된 inputType 값 감지: ${settings.inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
settings.inputType = "text";
}
// 테이블이 table_labels에 없으면 자동 추가
await this.insertTableIfNotExists(tableName);
// 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);
}
@ -708,12 +739,23 @@ export class TableManagementService {
inputType?: string
): Promise<void> {
try {
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
let finalWebType = webType;
if (webType === "direct" || webType === "auto") {
logger.warn(
`잘못된 webType 값 감지: ${webType} → 'text'로 변환 (${tableName}.${columnName})`
);
finalWebType = "text";
}
logger.info(
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${finalWebType}`
);
// 웹 타입별 기본 상세 설정 생성
const defaultDetailSettings = this.generateDefaultDetailSettings(webType);
const defaultDetailSettings =
this.generateDefaultDetailSettings(finalWebType);
// 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = {
@ -732,10 +774,15 @@ export class TableManagementService {
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
updated_date = NOW()`,
[tableName, columnName, webType, JSON.stringify(finalDetailSettings)]
[
tableName,
columnName,
finalWebType,
JSON.stringify(finalDetailSettings),
]
);
logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}`
);
} catch (error) {
logger.error(
@ -760,13 +807,23 @@ export class TableManagementService {
detailSettings?: Record<string, any>
): Promise<void> {
try {
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
let finalInputType = inputType;
if (inputType === "direct" || inputType === "auto") {
logger.warn(
`잘못된 input_type 값 감지: ${inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
finalInputType = "text";
}
logger.info(
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${finalInputType}, company: ${companyCode}`
);
// 입력 타입별 기본 상세 설정 생성
const defaultDetailSettings =
this.generateDefaultInputTypeSettings(inputType);
this.generateDefaultInputTypeSettings(finalInputType);
// 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = {
@ -788,7 +845,7 @@ export class TableManagementService {
[
tableName,
columnName,
inputType,
finalInputType,
JSON.stringify(finalDetailSettings),
companyCode,
]
@ -798,7 +855,7 @@ export class TableManagementService {
await this.syncScreenLayoutsInputType(
tableName,
columnName,
inputType,
finalInputType,
companyCode
);
@ -1415,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__" ||
@ -2171,6 +2266,9 @@ export class TableManagementService {
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
// ORDER BY 조건 구성
let orderClause = "";
if (sortBy) {
@ -2178,10 +2276,16 @@ export class TableManagementService {
const safeSortOrder =
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
} else {
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
const hasCreatedDate = await query<any>(
`SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'created_date' LIMIT 1`,
[safeTableName]
);
if (hasCreatedDate.length > 0) {
orderClause = `ORDER BY main.created_date DESC`;
}
}
// 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
@ -3090,8 +3194,12 @@ export class TableManagementService {
}
// ORDER BY 절 구성
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
const hasCreatedDateColumn = selectColumns.includes("created_date");
const orderBy = options.sortBy
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: hasCreatedDateColumn
? `main."created_date" DESC`
: "";
// 페이징 계산
@ -3302,13 +3410,16 @@ export class TableManagementService {
const entitySearchColumns: string[] = [];
// Entity 조인 쿼리 생성하여 별칭 매핑 얻기
const hasCreatedDateForSearch = selectColumns.includes("created_date");
const joinQueryResult = entityJoinService.buildJoinQuery(
tableName,
joinConfigs,
selectColumns,
"", // WHERE 절은 나중에 추가
options.sortBy
? `main.${options.sortBy} ${options.sortOrder || "ASC"}`
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
: hasCreatedDateForSearch
? `main."created_date" DESC`
: undefined,
options.size,
(options.page - 1) * options.size
@ -3323,14 +3434,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";
}
// 빈 값이면 스킵
@ -3382,6 +3495,39 @@ export class TableManagementService {
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// 🔧 파이프로 구분된 다중 선택값 처리
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}%'`
);
@ -3391,6 +3537,7 @@ export class TableManagementService {
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) =>
@ -3427,6 +3574,28 @@ export class TableManagementService {
}
} else {
// 일반 컬럼인 경우: 메인 테이블에서 검색
// 🔧 파이프로 구분된 다중 선택값 처리
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}%'`
@ -3434,10 +3603,14 @@ export class TableManagementService {
}
}
}
}
const whereClause = whereConditions.join(" AND ");
const hasCreatedDateForOrder = selectColumns.includes("created_date");
const orderBy = options.sortBy
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: hasCreatedDateForOrder
? `main."created_date" DESC`
: "";
// 페이징 계산
@ -3715,6 +3888,7 @@ export class TableManagementService {
columnName: string;
displayName: string;
dataType: string;
inputType?: string;
}>
> {
return await entityJoinService.getReferenceTableColumns(tableName);
@ -4163,6 +4337,14 @@ export class TableManagementService {
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
try {
// menu_objid 컬럼이 있는지 먼저 확인
const columnCheck = await query<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
);
if (columnCheck.length > 0) {
const mappings = await query<any>(
`SELECT DISTINCT ON (logical_column_name, menu_objid)
logical_column_name as "columnName",
@ -4179,7 +4361,6 @@ export class TableManagementService {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
mappings.forEach((m: any) => {
@ -4188,6 +4369,14 @@ export class TableManagementService {
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} else {
logger.info("⚠️ menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
}
} catch (mappingError: any) {
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
error: mappingError.message,
});
}
logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size,

View File

@ -0,0 +1,83 @@
# 078 마이그레이션 실행 가이드
## 실행할 파일 (순서대로)
1. **078_create_production_plan_tables.sql** - 테이블 생성
2. **078b_insert_production_plan_sample_data.sql** - 샘플 데이터
3. **078c_insert_production_plan_screen.sql** - 화면 정의 및 레이아웃
## 실행 방법
### 방법 1: psql 명령어 (터미널)
```bash
# 테이블 생성
psql -h localhost -U postgres -d wace -f db/migrations/078_create_production_plan_tables.sql
# 샘플 데이터 입력
psql -h localhost -U postgres -d wace -f db/migrations/078b_insert_production_plan_sample_data.sql
```
### 방법 2: DBeaver / pgAdmin에서 실행
1. DB 연결 후 SQL 에디터 열기
2. `078_create_production_plan_tables.sql` 내용 복사 & 실행
3. `078b_insert_production_plan_sample_data.sql` 내용 복사 & 실행
### 방법 3: Docker 환경
```bash
# Docker 컨테이너 내부에서 실행
docker exec -i <container_name> psql -U postgres -d wace < db/migrations/078_create_production_plan_tables.sql
docker exec -i <container_name> psql -U postgres -d wace < db/migrations/078b_insert_production_plan_sample_data.sql
```
## 생성되는 테이블
| 테이블명 | 설명 |
|---------|------|
| `equipment_info` | 설비 정보 마스터 |
| `production_plan_mng` | 생산계획 관리 |
| `production_plan_order_rel` | 생산계획-수주 연결 |
## 생성되는 화면
| 화면 | 설명 |
|------|------|
| 생산계획관리 (메인) | 생산계획 목록 조회/등록/수정/삭제 |
| 생산계획 등록/수정 (모달) | 생산계획 상세 입력 폼 |
## 확인 쿼리
```sql
-- 테이블 생성 확인
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('equipment_info', 'production_plan_mng', 'production_plan_order_rel');
-- 샘플 데이터 확인
SELECT * FROM equipment_info;
SELECT * FROM production_plan_mng;
-- 화면 생성 확인
SELECT id, screen_name, screen_code, table_name
FROM screen_definitions
WHERE screen_code LIKE '%PP%';
-- 레이아웃 확인
SELECT sl.id, sd.screen_name, sl.layout_name
FROM screen_layouts_v2 sl
JOIN screen_definitions sd ON sl.screen_id = sd.id
WHERE sd.screen_code LIKE '%PP%';
```
## 메뉴 연결 (수동 작업 필요)
화면 생성 후, 메뉴에 연결하려면 `menu_info` 테이블에서 해당 메뉴의 `screen_id`를 업데이트하세요:
```sql
-- 예시: 생산관리 > 생산계획관리 메뉴에 연결
UPDATE menu_info
SET screen_id = (SELECT id FROM screen_definitions WHERE screen_code = 'TOPSEAL_PP_MAIN')
WHERE menu_name = '생산계획관리' AND company_code = 'TOPSEAL';
```

View File

@ -0,0 +1,179 @@
{
"version": "2.0",
"components": [
{
"id": "comp_search",
"url": "@/lib/registry/components/v2-table-search-widget",
"size": { "width": 1920, "height": 80 },
"position": { "x": 0, "y": 20, "z": 1 },
"overrides": {
"type": "v2-table-search-widget",
"label": "Search Filter",
"webTypeConfig": {}
},
"displayOrder": 0
},
{
"id": "comp_table",
"url": "@/lib/registry/components/v2-table-list",
"size": { "width": 1920, "height": 800 },
"position": { "x": 0, "y": 150, "z": 1 },
"overrides": {
"type": "v2-table-list",
"label": "Sales Order List",
"filter": { "enabled": true, "filters": [] },
"height": "auto",
"actions": { "actions": [], "bulkActions": false, "showActions": false },
"columns": [
{ "align": "left", "order": 0, "format": "text", "visible": true, "sortable": true, "columnName": "order_no", "searchable": true, "displayName": "Order No" },
{ "align": "left", "order": 1, "format": "text", "visible": true, "sortable": true, "columnName": "partner_id", "searchable": true, "displayName": "Customer" },
{ "align": "left", "order": 2, "format": "text", "visible": true, "sortable": true, "columnName": "part_code", "searchable": true, "displayName": "Part Code" },
{ "align": "left", "order": 3, "format": "text", "visible": true, "sortable": true, "columnName": "part_name", "searchable": true, "displayName": "Part Name" },
{ "align": "left", "order": 4, "format": "text", "visible": true, "sortable": true, "columnName": "spec", "searchable": true, "displayName": "Spec" },
{ "align": "left", "order": 5, "format": "text", "visible": true, "sortable": true, "columnName": "material", "searchable": true, "displayName": "Material" },
{ "align": "right", "order": 6, "format": "number", "visible": true, "sortable": true, "columnName": "order_qty", "searchable": false, "displayName": "Order Qty" },
{ "align": "right", "order": 7, "format": "number", "visible": true, "sortable": true, "columnName": "ship_qty", "searchable": false, "displayName": "Ship Qty" },
{ "align": "right", "order": 8, "format": "number", "visible": true, "sortable": true, "columnName": "balance_qty", "searchable": false, "displayName": "Balance" },
{ "align": "right", "order": 9, "format": "number", "visible": true, "sortable": true, "columnName": "inventory_qty", "searchable": false, "displayName": "Stock" },
{ "align": "right", "order": 10, "format": "number", "visible": true, "sortable": true, "columnName": "plan_ship_qty", "searchable": false, "displayName": "Plan Ship Qty" },
{ "align": "right", "order": 11, "format": "number", "visible": true, "sortable": true, "columnName": "unit_price", "searchable": false, "displayName": "Unit Price" },
{ "align": "right", "order": 12, "format": "number", "visible": true, "sortable": true, "columnName": "total_amount", "searchable": false, "displayName": "Amount" },
{ "align": "left", "order": 13, "format": "text", "visible": true, "sortable": true, "columnName": "delivery_partner_id", "searchable": true, "displayName": "Delivery Partner" },
{ "align": "left", "order": 14, "format": "text", "visible": true, "sortable": true, "columnName": "delivery_address", "searchable": true, "displayName": "Delivery Address" },
{ "align": "center", "order": 15, "format": "text", "visible": true, "sortable": true, "columnName": "shipping_method", "searchable": true, "displayName": "Shipping Method" },
{ "align": "center", "order": 16, "format": "date", "visible": true, "sortable": true, "columnName": "due_date", "searchable": false, "displayName": "Due Date" },
{ "align": "center", "order": 17, "format": "date", "visible": true, "sortable": true, "columnName": "order_date", "searchable": false, "displayName": "Order Date" },
{ "align": "center", "order": 18, "format": "text", "visible": true, "sortable": true, "columnName": "status", "searchable": true, "displayName": "Status" },
{ "align": "left", "order": 19, "format": "text", "visible": true, "sortable": true, "columnName": "manager_name", "searchable": true, "displayName": "Manager" },
{ "align": "left", "order": 20, "format": "text", "visible": true, "sortable": true, "columnName": "memo", "searchable": true, "displayName": "Memo" }
],
"autoLoad": true,
"checkbox": { "enabled": true, "multiple": true, "position": "left", "selectAll": true },
"pagination": { "enabled": true, "pageSize": 20, "showPageInfo": true, "pageSizeOptions": [10, 20, 50, 100], "showSizeSelector": true },
"showFooter": true,
"showHeader": true,
"tableStyle": { "theme": "default", "rowHeight": "normal", "borderStyle": "light", "headerStyle": "default", "hoverEffect": true, "alternateRows": true },
"displayMode": "table",
"stickyHeader": false,
"selectedTable": "sales_order_mng",
"webTypeConfig": {},
"horizontalScroll": { "enabled": true, "maxColumnWidth": 300, "minColumnWidth": 80, "maxVisibleColumns": 10 }
},
"displayOrder": 1
},
{
"id": "comp_btn_upload",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1610, "y": 30, "z": 1 },
"overrides": {
"text": "Excel Upload",
"type": "v2-button-primary",
"label": "Excel Upload Button",
"action": { "type": "excel_upload" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 2
},
{
"id": "comp_btn_download",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 110, "height": 40 },
"position": { "x": 1720, "y": 30, "z": 1 },
"overrides": {
"text": "Excel Download",
"type": "v2-button-primary",
"label": "Excel Download Button",
"action": { "type": "excel_download" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 3
},
{
"id": "comp_btn_register",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1500, "y": 100, "z": 1 },
"overrides": {
"text": "New Order",
"type": "v2-button-primary",
"label": "New Order Button",
"action": {
"type": "modal",
"modalSize": "lg",
"modalTitle": "New Sales Order",
"targetScreenId": 3732,
"successMessage": "Saved successfully.",
"errorMessage": "Error saving."
},
"variant": "success",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 4
},
{
"id": "comp_btn_edit",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1610, "y": 100, "z": 1 },
"overrides": {
"text": "Edit",
"type": "v2-button-primary",
"label": "Edit Button",
"action": {
"type": "edit",
"modalSize": "lg",
"modalTitle": "Edit Sales Order",
"targetScreenId": 3732,
"successMessage": "Updated successfully.",
"errorMessage": "Error updating."
},
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 5
},
{
"id": "comp_btn_delete",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1700, "y": 100, "z": 1 },
"overrides": {
"text": "Delete",
"type": "v2-button-primary",
"label": "Delete Button",
"action": {
"type": "delete",
"successMessage": "Deleted successfully.",
"errorMessage": "Error deleting."
},
"variant": "danger",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 6
},
{
"id": "comp_btn_shipment",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1790, "y": 100, "z": 1 },
"overrides": {
"text": "Shipment Plan",
"type": "v2-button-primary",
"label": "Shipment Plan Button",
"action": { "type": "custom" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 7
}
]
}

View File

@ -0,0 +1,179 @@
{
"version": "2.0",
"components": [
{
"id": "comp_search",
"url": "@/lib/registry/components/v2-table-search-widget",
"size": { "width": 1920, "height": 80 },
"position": { "x": 0, "y": 20, "z": 1 },
"overrides": {
"type": "v2-table-search-widget",
"label": "검색 필터",
"webTypeConfig": {}
},
"displayOrder": 0
},
{
"id": "comp_table",
"url": "@/lib/registry/components/v2-table-list",
"size": { "width": 1920, "height": 800 },
"position": { "x": 0, "y": 150, "z": 1 },
"overrides": {
"type": "v2-table-list",
"label": "수주 목록",
"filter": { "enabled": true, "filters": [] },
"height": "auto",
"actions": { "actions": [], "bulkActions": false, "showActions": false },
"columns": [
{ "align": "left", "order": 0, "format": "text", "visible": true, "sortable": true, "columnName": "order_no", "searchable": true, "displayName": "수주번호" },
{ "align": "left", "order": 1, "format": "text", "visible": true, "sortable": true, "columnName": "partner_id", "searchable": true, "displayName": "거래처" },
{ "align": "left", "order": 2, "format": "text", "visible": true, "sortable": true, "columnName": "part_code", "searchable": true, "displayName": "품목코드" },
{ "align": "left", "order": 3, "format": "text", "visible": true, "sortable": true, "columnName": "part_name", "searchable": true, "displayName": "품명" },
{ "align": "left", "order": 4, "format": "text", "visible": true, "sortable": true, "columnName": "spec", "searchable": true, "displayName": "규격" },
{ "align": "left", "order": 5, "format": "text", "visible": true, "sortable": true, "columnName": "material", "searchable": true, "displayName": "재질" },
{ "align": "right", "order": 6, "format": "number", "visible": true, "sortable": true, "columnName": "order_qty", "searchable": false, "displayName": "수주수량" },
{ "align": "right", "order": 7, "format": "number", "visible": true, "sortable": true, "columnName": "ship_qty", "searchable": false, "displayName": "출하수량" },
{ "align": "right", "order": 8, "format": "number", "visible": true, "sortable": true, "columnName": "balance_qty", "searchable": false, "displayName": "잔량" },
{ "align": "right", "order": 9, "format": "number", "visible": true, "sortable": true, "columnName": "inventory_qty", "searchable": false, "displayName": "현재고" },
{ "align": "right", "order": 10, "format": "number", "visible": true, "sortable": true, "columnName": "plan_ship_qty", "searchable": false, "displayName": "출하계획량" },
{ "align": "right", "order": 11, "format": "number", "visible": true, "sortable": true, "columnName": "unit_price", "searchable": false, "displayName": "단가" },
{ "align": "right", "order": 12, "format": "number", "visible": true, "sortable": true, "columnName": "total_amount", "searchable": false, "displayName": "금액" },
{ "align": "left", "order": 13, "format": "text", "visible": true, "sortable": true, "columnName": "delivery_partner_id", "searchable": true, "displayName": "납품처" },
{ "align": "left", "order": 14, "format": "text", "visible": true, "sortable": true, "columnName": "delivery_address", "searchable": true, "displayName": "납품장소" },
{ "align": "center", "order": 15, "format": "text", "visible": true, "sortable": true, "columnName": "shipping_method", "searchable": true, "displayName": "배송방법" },
{ "align": "center", "order": 16, "format": "date", "visible": true, "sortable": true, "columnName": "due_date", "searchable": false, "displayName": "납기일" },
{ "align": "center", "order": 17, "format": "date", "visible": true, "sortable": true, "columnName": "order_date", "searchable": false, "displayName": "수주일" },
{ "align": "center", "order": 18, "format": "text", "visible": true, "sortable": true, "columnName": "status", "searchable": true, "displayName": "상태" },
{ "align": "left", "order": 19, "format": "text", "visible": true, "sortable": true, "columnName": "manager_name", "searchable": true, "displayName": "담당자" },
{ "align": "left", "order": 20, "format": "text", "visible": true, "sortable": true, "columnName": "memo", "searchable": true, "displayName": "메모" }
],
"autoLoad": true,
"checkbox": { "enabled": true, "multiple": true, "position": "left", "selectAll": true },
"pagination": { "enabled": true, "pageSize": 20, "showPageInfo": true, "pageSizeOptions": [10, 20, 50, 100], "showSizeSelector": true },
"showFooter": true,
"showHeader": true,
"tableStyle": { "theme": "default", "rowHeight": "normal", "borderStyle": "light", "headerStyle": "default", "hoverEffect": true, "alternateRows": true },
"displayMode": "table",
"stickyHeader": false,
"selectedTable": "sales_order_mng",
"webTypeConfig": {},
"horizontalScroll": { "enabled": true, "maxColumnWidth": 300, "minColumnWidth": 80, "maxVisibleColumns": 10 }
},
"displayOrder": 1
},
{
"id": "comp_btn_upload",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1610, "y": 30, "z": 1 },
"overrides": {
"text": "엑셀 업로드",
"type": "v2-button-primary",
"label": "엑셀 업로드 버튼",
"action": { "type": "excel_upload" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 2
},
{
"id": "comp_btn_download",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 110, "height": 40 },
"position": { "x": 1720, "y": 30, "z": 1 },
"overrides": {
"text": "엑셀 다운로드",
"type": "v2-button-primary",
"label": "엑셀 다운로드 버튼",
"action": { "type": "excel_download" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 3
},
{
"id": "comp_btn_register",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1500, "y": 100, "z": 1 },
"overrides": {
"text": "수주 등록",
"type": "v2-button-primary",
"label": "수주 등록 버튼",
"action": {
"type": "modal",
"modalSize": "lg",
"modalTitle": "수주 등록",
"targetScreenId": 3732,
"successMessage": "저장되었습니다.",
"errorMessage": "저장 중 오류가 발생했습니다."
},
"variant": "success",
"actionType": "button",
"webTypeConfig": { "variant": "default", "actionType": "custom" }
},
"displayOrder": 4
},
{
"id": "comp_btn_edit",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1610, "y": 100, "z": 1 },
"overrides": {
"text": "수정",
"type": "v2-button-primary",
"label": "수정 버튼",
"action": {
"type": "edit",
"modalSize": "lg",
"modalTitle": "수주 수정",
"targetScreenId": 3732,
"successMessage": "수정되었습니다.",
"errorMessage": "수정 중 오류가 발생했습니다."
},
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 5
},
{
"id": "comp_btn_delete",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 80, "height": 40 },
"position": { "x": 1700, "y": 100, "z": 1 },
"overrides": {
"text": "삭제",
"type": "v2-button-primary",
"label": "삭제 버튼",
"action": {
"type": "delete",
"successMessage": "삭제되었습니다.",
"errorMessage": "삭제 중 오류가 발생했습니다."
},
"variant": "danger",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 6
},
{
"id": "comp_btn_shipment",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 1790, "y": 100, "z": 1 },
"overrides": {
"text": "출하계획",
"type": "v2-button-primary",
"label": "출하계획 버튼",
"action": { "type": "custom" },
"variant": "secondary",
"actionType": "button",
"webTypeConfig": { "variant": "secondary", "actionType": "custom" }
},
"displayOrder": 7
}
]
}

View File

@ -0,0 +1,254 @@
{
"version": "2.0",
"components": [
{
"id": "comp_order_no",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 20, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Order No",
"fieldName": "order_no",
"placeholder": "Enter order number",
"required": true
},
"displayOrder": 0
},
{
"id": "comp_order_date",
"url": "@/lib/registry/components/v2-date",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 20, "z": 1 },
"overrides": {
"type": "v2-date",
"label": "Order Date",
"fieldName": "order_date",
"required": true
},
"displayOrder": 1
},
{
"id": "comp_partner_id",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 100, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "Customer",
"fieldName": "partner_id",
"required": true,
"config": {
"mode": "dropdown",
"source": "table",
"sourceTable": "customer_mng",
"valueField": "id",
"labelField": "name"
}
},
"displayOrder": 2
},
{
"id": "comp_part_code",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 100, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Part Code",
"fieldName": "part_code",
"placeholder": "Enter part code",
"required": true
},
"displayOrder": 3
},
{
"id": "comp_part_name",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 180, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Part Name",
"fieldName": "part_name",
"placeholder": "Enter part name"
},
"displayOrder": 4
},
{
"id": "comp_spec",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 180, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Spec",
"fieldName": "spec",
"placeholder": "Enter spec"
},
"displayOrder": 5
},
{
"id": "comp_material",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 260, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Material",
"fieldName": "material",
"placeholder": "Enter material"
},
"displayOrder": 6
},
{
"id": "comp_order_qty",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 260, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "number",
"label": "Order Qty",
"fieldName": "order_qty",
"placeholder": "Enter order quantity",
"required": true
},
"displayOrder": 7
},
{
"id": "comp_unit_price",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 340, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "number",
"label": "Unit Price",
"fieldName": "unit_price",
"placeholder": "Enter unit price",
"required": true
},
"displayOrder": 8
},
{
"id": "comp_due_date",
"url": "@/lib/registry/components/v2-date",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 340, "z": 1 },
"overrides": {
"type": "v2-date",
"label": "Due Date",
"fieldName": "due_date"
},
"displayOrder": 9
},
{
"id": "comp_status",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 420, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "Status",
"fieldName": "status",
"required": true,
"config": {
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "수주", "label": "수주" },
{ "value": "진행중", "label": "진행중" },
{ "value": "완료", "label": "완료" },
{ "value": "취소", "label": "취소" }
]
}
},
"displayOrder": 10
},
{
"id": "comp_shipping_method",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 420, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "Shipping Method",
"fieldName": "shipping_method",
"config": {
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "택배", "label": "택배" },
{ "value": "화물", "label": "화물" },
{ "value": "직송", "label": "직송" },
{ "value": "퀵서비스", "label": "퀵서비스" },
{ "value": "해상운송", "label": "해상운송" }
]
}
},
"displayOrder": 11
},
{
"id": "comp_delivery_address",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 620, "height": 60 },
"position": { "x": 20, "y": 500, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Delivery Address",
"fieldName": "delivery_address",
"placeholder": "Enter delivery address"
},
"displayOrder": 12
},
{
"id": "comp_manager_name",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 580, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "Manager",
"fieldName": "manager_name",
"placeholder": "Enter manager name"
},
"displayOrder": 13
},
{
"id": "comp_memo",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 620, "height": 80 },
"position": { "x": 20, "y": 660, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "textarea",
"label": "Memo",
"fieldName": "memo",
"placeholder": "Enter memo"
},
"displayOrder": 14
},
{
"id": "comp_btn_save",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 540, "y": 760, "z": 1 },
"overrides": {
"text": "Save",
"type": "v2-button-primary",
"label": "Save Button",
"action": {
"type": "save",
"closeModalAfterSave": true,
"refreshParentTable": true,
"successMessage": "Saved successfully.",
"errorMessage": "Error saving."
},
"variant": "primary",
"actionType": "button"
},
"displayOrder": 15
}
]
}

View File

@ -0,0 +1,254 @@
{
"version": "2.0",
"components": [
{
"id": "comp_order_no",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 20, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "수주번호",
"fieldName": "order_no",
"placeholder": "수주번호를 입력하세요",
"required": true
},
"displayOrder": 0
},
{
"id": "comp_order_date",
"url": "@/lib/registry/components/v2-date",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 20, "z": 1 },
"overrides": {
"type": "v2-date",
"label": "수주일",
"fieldName": "order_date",
"required": true
},
"displayOrder": 1
},
{
"id": "comp_partner_id",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 100, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "거래처",
"fieldName": "partner_id",
"required": true,
"config": {
"mode": "dropdown",
"source": "table",
"sourceTable": "customer_mng",
"valueField": "id",
"labelField": "name"
}
},
"displayOrder": 2
},
{
"id": "comp_part_code",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 100, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "품목코드",
"fieldName": "part_code",
"placeholder": "품목코드를 입력하세요",
"required": true
},
"displayOrder": 3
},
{
"id": "comp_part_name",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 180, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "품명",
"fieldName": "part_name",
"placeholder": "품명을 입력하세요"
},
"displayOrder": 4
},
{
"id": "comp_spec",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 180, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "규격",
"fieldName": "spec",
"placeholder": "규격을 입력하세요"
},
"displayOrder": 5
},
{
"id": "comp_material",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 260, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "재질",
"fieldName": "material",
"placeholder": "재질을 입력하세요"
},
"displayOrder": 6
},
{
"id": "comp_order_qty",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 260, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "number",
"label": "수주수량",
"fieldName": "order_qty",
"placeholder": "수주수량을 입력하세요",
"required": true
},
"displayOrder": 7
},
{
"id": "comp_unit_price",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 340, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "number",
"label": "단가",
"fieldName": "unit_price",
"placeholder": "단가를 입력하세요",
"required": true
},
"displayOrder": 8
},
{
"id": "comp_due_date",
"url": "@/lib/registry/components/v2-date",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 340, "z": 1 },
"overrides": {
"type": "v2-date",
"label": "납기일",
"fieldName": "due_date"
},
"displayOrder": 9
},
{
"id": "comp_status",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 420, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "상태",
"fieldName": "status",
"required": true,
"config": {
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "수주", "label": "수주" },
{ "value": "진행중", "label": "진행중" },
{ "value": "완료", "label": "완료" },
{ "value": "취소", "label": "취소" }
]
}
},
"displayOrder": 10
},
{
"id": "comp_shipping_method",
"url": "@/lib/registry/components/v2-select",
"size": { "width": 300, "height": 60 },
"position": { "x": 340, "y": 420, "z": 1 },
"overrides": {
"type": "v2-select",
"label": "배송방법",
"fieldName": "shipping_method",
"config": {
"mode": "dropdown",
"source": "static",
"options": [
{ "value": "택배", "label": "택배" },
{ "value": "화물", "label": "화물" },
{ "value": "직송", "label": "직송" },
{ "value": "퀵서비스", "label": "퀵서비스" },
{ "value": "해상운송", "label": "해상운송" }
]
}
},
"displayOrder": 11
},
{
"id": "comp_delivery_address",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 620, "height": 60 },
"position": { "x": 20, "y": 500, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "납품장소",
"fieldName": "delivery_address",
"placeholder": "납품장소를 입력하세요"
},
"displayOrder": 12
},
{
"id": "comp_manager_name",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 300, "height": 60 },
"position": { "x": 20, "y": 580, "z": 1 },
"overrides": {
"type": "v2-input",
"label": "담당자",
"fieldName": "manager_name",
"placeholder": "담당자를 입력하세요"
},
"displayOrder": 13
},
{
"id": "comp_memo",
"url": "@/lib/registry/components/v2-input",
"size": { "width": 620, "height": 80 },
"position": { "x": 20, "y": 660, "z": 1 },
"overrides": {
"type": "v2-input",
"inputType": "textarea",
"label": "메모",
"fieldName": "memo",
"placeholder": "메모를 입력하세요"
},
"displayOrder": 14
},
{
"id": "comp_btn_save",
"url": "@/lib/registry/components/v2-button-primary",
"size": { "width": 100, "height": 40 },
"position": { "x": 540, "y": 760, "z": 1 },
"overrides": {
"text": "저장",
"type": "v2-button-primary",
"label": "저장 버튼",
"action": {
"type": "save",
"closeModalAfterSave": true,
"refreshParentTable": true,
"successMessage": "저장되었습니다.",
"errorMessage": "저장 중 오류가 발생했습니다."
},
"variant": "primary",
"actionType": "button"
},
"displayOrder": 15
}
]
}

View File

@ -5,13 +5,20 @@ services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
dockerfile: ../docker/dev/frontend.Dockerfile
container_name: pms-frontend-win
ports:
- "9771:3000"
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,253 @@
# 다중 선택(Multi-Select) 배열 직렬화 문제 해결 보고서
## 문제 요약
**증상**: 다중 선택 컴포넌트(TagboxSelect, 체크박스 등)로 선택한 값이 DB에 저장될 때 손상되거나 `null`로 저장됨
**영향받는 기능**:
- 품목정보의 `division` (구분) 필드
- 모든 다중 선택 카테고리 필드
**손상된 데이터 예시**:
```
{"{\"{\\\"CAT_ML7SR2T9_IM7H\\\",\\\"CAT_ML8ZFQFU_EE5Z\\\"}\"}",...}
```
**정상 데이터 예시**:
```
CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z,CAT_ML8ZFVEL_1TOR
```
---
## 문제 원인 분석
### 1. PostgreSQL의 배열 자동 변환
Node.js의 `node-pg` 라이브러리는 JavaScript 배열을 PostgreSQL 배열 리터럴(`{...}`)로 자동 변환합니다.
```javascript
// JavaScript
["CAT_1", "CAT_2", "CAT_3"]
// PostgreSQL로 자동 변환됨
{"CAT_1","CAT_2","CAT_3"}
```
하지만 우리 시스템은 커스텀 테이블에서 **쉼표 구분 문자열**을 기대합니다:
```
CAT_1,CAT_2,CAT_3
```
### 2. 여러 저장 경로의 존재
코드를 분석한 결과, 저장 로직이 여러 경로로 나뉘어 있었습니다:
| 경로 | 파일 | 설명 |
|------|------|------|
| 1 | `buttonActions.ts` | 기본 저장 로직 (INSERT/UPDATE) |
| 2 | `EditModal.tsx` | 모달 내 직접 저장 (CREATE/UPDATE) |
| 3 | `nodeFlowExecutionService.ts` | 백엔드 노드 플로우 저장 |
### 3. 왜 초기 수정이 실패했는가?
#### 시도 1: `buttonActions.ts`에 배열 변환 추가
```typescript
// buttonActions.ts (라인 1002-1025)
if (isUpdate) {
for (const key of Object.keys(formData)) {
if (Array.isArray(value)) {
formData[key] = value.join(",");
}
}
}
```
**실패 이유**: `EditModal``onSave` 콜백을 제공하면, `buttonActions.ts`는 이 콜백을 바로 호출하고 내부 저장 로직을 건너뜀
```typescript
// buttonActions.ts (라인 545-552)
if (onSave) {
await onSave(); // 바로 여기서 EditModal.handleSave()가 호출됨
return true; // 아래 배열 변환 로직에 도달하지 않음!
}
```
#### 시도 2: `nodeFlowExecutionService.ts``normalizeValueForDB` 추가
**부분 성공**: INSERT에서는 동작했으나, EditModal의 UPDATE 경로는 여전히 문제
---
## 최종 해결 방법
### 핵심 수정: `EditModal.tsx`에 직접 배열 변환 추가
EditModal이 직접 `dynamicFormApi.updateFormDataPartial`을 호출하므로, **저장 직전**에 배열을 변환해야 했습니다.
#### 수정 위치 1: UPDATE 경로 (라인 957-1002)
```typescript
// EditModal.tsx - UPDATE 모드
Object.keys(formData).forEach((key) => {
if (formData[key] !== originalData[key]) {
let value = formData[key];
if (Array.isArray(value)) {
// 리피터 데이터 제외
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
("_targetTable" in value[0] || "_isNewItem" in value[0]);
if (!isRepeaterData) {
// 🔧 손상된 값 필터링
const isValidValue = (v: any): boolean => {
if (typeof v === "number") return true;
if (typeof v !== "string") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\"))
return false;
return true;
};
// 유효한 값만 쉼표로 연결
const validValues = value.filter(isValidValue);
value = validValues.join(",");
}
}
changedData[key] = value;
}
});
```
#### 수정 위치 2: CREATE 경로 (라인 855-875)
```typescript
// EditModal.tsx - CREATE 모드
Object.entries(dataToSave).forEach(([key, value]) => {
if (!Array.isArray(value)) {
masterDataToSave[key] = value;
} else {
const isRepeaterData = /* 리피터 체크 */;
if (isRepeaterData) {
// 리피터 데이터는 제외 (별도 저장)
} else {
// 다중 선택 배열 → 쉼표 구분 문자열
const validValues = value.filter(isValidValue);
masterDataToSave[key] = validValues.join(",");
}
}
});
```
#### 수정 위치 3: 그룹 UPDATE 경로 (라인 630-650)
그룹 품목 수정 시에도 동일한 로직 적용
---
## 손상된 데이터 필터링
기존에 손상된 데이터가 배열에 포함될 수 있어서, 변환 전 필터링이 필요했습니다:
```typescript
const isValidValue = (v: any): boolean => {
// 숫자는 유효
if (typeof v === "number" && !isNaN(v)) return true;
// 문자열이 아니면 무효
if (typeof v !== "string") return false;
// 빈 값 무효
if (!v || v.trim() === "") return false;
// PostgreSQL 배열 형식 감지 → 무효
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\"))
return false;
return true;
};
```
**필터링 예시**:
```
입력 배열: ['{"CAT_1","CAT_2"}', 'CAT_ML7SR2T9_IM7H', 'CAT_ML8ZFQFU_EE5Z']
↑ 손상됨 (필터링) ↑ 유효 ↑ 유효
출력: 'CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z'
```
---
## 수정된 파일 목록
| 파일 | 수정 내용 |
|------|-----------|
| `frontend/components/screen/EditModal.tsx` | CREATE/UPDATE/그룹UPDATE 경로에 배열→문자열 변환 + 손상값 필터링 |
| `frontend/lib/utils/buttonActions.ts` | INSERT 경로에 배열→문자열 변환 (이미 수정됨) |
| `frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx` | handleChange에서 배열→문자열 변환 |
| `backend-node/src/services/nodeFlowExecutionService.ts` | normalizeValueForDB 헬퍼 추가 |
---
## 교훈 및 향후 주의사항
### 1. 저장 경로 파악의 중요성
프론트엔드에서 저장 로직이 여러 경로로 분기될 수 있으므로, **모든 경로를 추적**해야 합니다.
```
사용자 저장 버튼 클릭
ButtonPrimaryComponent
buttonActions.handleSave()
┌─────────────────────────────────────┐
│ onSave 콜백이 있으면? │
│ → EditModal.handleSave() 직접 호출│ ← 이 경로를 놓침!
│ onSave 콜백이 없으면? │
│ → buttonActions 내부 저장 로직 │
└─────────────────────────────────────┘
```
### 2. 로그 기반 디버깅
로그가 어디까지 찍히고 어디서 안 찍히는지를 통해 코드 경로를 추적:
```
[예상한 로그]
buttonActions.ts:512 🔍 [handleSave] 진입
buttonActions.ts:1021 🔧 배열→문자열 변환 ← 이게 안 나옴!
[실제 로그]
buttonActions.ts:512 🔍 [handleSave] 진입
dynamicForm.ts:140 🔄 폼 데이터 부분 업데이트 ← 바로 여기로 점프!
```
### 3. 리피터 데이터 vs 다중 선택 구분
배열이라고 모두 쉼표 문자열로 변환하면 안 됩니다:
| 타입 | 예시 | 처리 방법 |
|------|------|-----------|
| 다중 선택 | `["CAT_1", "CAT_2"]` | 쉼표 문자열로 변환 |
| 리피터 데이터 | `[{id: 1, _targetTable: "..."}]` | 별도 테이블에 저장, 마스터에서 제외 |
---
## 확인된 정상 동작
```
EditModal.tsx:1002 🔧 [EditModal UPDATE] 배열→문자열 변환: division
{original: 3, valid: 3, converted: 'CAT_ML7SR2T9_IM7H,CAT_ML8ZFQFU_EE5Z,CAT_ML8ZFVEL_1TOR'}
dynamicForm.ts:153 ✅ 폼 데이터 부분 업데이트 성공
```
---
## 작성일
2026-02-05
## 작성자
AI Assistant (Claude)

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

@ -0,0 +1,832 @@
# 반응형 그리드 시스템 아키텍처
> 최종 업데이트: 2026-01-30
---
## 1. 개요
### 1.1 현재 문제
**컴포넌트 위치/크기가 픽셀 단위로 고정되어 반응형 미지원**
```json
// 현재 DB 저장 방식 (screen_layouts_v2.layout_data)
{
"position": { "x": 1753, "y": 88 },
"size": { "width": 158, "height": 40 }
}
```
| 화면 크기 | 결과 |
|-----------|------|
| 1920px (디자인 기준) | 정상 |
| 1280px (노트북) | 오른쪽 버튼 잘림 |
| 768px (태블릿) | 레이아웃 완전히 깨짐 |
| 375px (모바일) | 사용 불가 |
### 1.2 목표
| 목표 | 설명 |
|------|------|
| PC 대응 | 1280px ~ 1920px |
| 태블릿 대응 | 768px ~ 1024px |
| 모바일 대응 | 320px ~ 767px |
### 1.3 해결 방향
```
현재: 픽셀 좌표 → position: absolute → 고정 레이아웃
변경: 그리드 셀 번호 → CSS Grid + ResizeObserver → 반응형 레이아웃
```
---
## 2. 현재 시스템 분석
### 2.1 데이터 현황
```
총 레이아웃: 1,250개
총 컴포넌트: 5,236개
회사 수: 14개
테이블 크기: 약 3MB
```
### 2.2 컴포넌트 타입별 분포
| 컴포넌트 | 수량 | shadcn 사용 |
|----------|------|-------------|
| v2-input | 1,914 | ✅ `@/components/ui/input` |
| v2-button-primary | 1,549 | ✅ `@/components/ui/button` |
| v2-table-search-widget | 355 | ✅ shadcn 기반 |
| v2-select | 327 | ✅ `@/components/ui/select` |
| v2-table-list | 285 | ✅ `@/components/ui/table` |
| v2-media | 181 | ✅ shadcn 기반 |
| v2-date | 132 | ✅ `@/components/ui/calendar` |
| **v2-split-panel-layout** | **131** | ✅ shadcn 기반 (**반응형 필요**) |
| v2-tabs-widget | 75 | ✅ shadcn 기반 |
| 기타 | 287 | ✅ shadcn 기반 |
| **합계** | **5,236** | **전부 shadcn** |
### 2.3 현재 렌더링 방식
```tsx
// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx (라인 234-248)
{components.map((child) => (
<div
style={{
position: "absolute", // 절대 위치
left: child.position.x, // 픽셀 고정
top: child.position.y, // 픽셀 고정
width: child.size.width, // 픽셀 고정
height: child.size.height, // 픽셀 고정
}}
>
{renderer.renderChild(child)}
</div>
))}
```
### 2.4 핵심 발견
```
✅ 이미 있는 것:
- 12컬럼 그리드 설정 (gridSettings.columns: 12)
- 그리드 스냅 기능 (snapToGrid: true)
- shadcn/ui 기반 컴포넌트 (전체)
❌ 없는 것:
- 그리드 셀 번호 저장 (현재 픽셀 저장)
- 반응형 브레이크포인트 설정
- CSS Grid 기반 렌더링
- 분할 패널 반응형 처리
```
### 2.5 레이아웃 시스템 구조
현재 시스템에는 두 가지 레벨의 레이아웃이 존재합니다:
#### 2.5.1 화면 레이아웃 (screen_layouts_v2)
화면 전체의 컴포넌트 배치를 담당합니다.
```json
// DB 구조
{
"version": "2.0",
"components": [
{ "id": "comp_1", "position": { "x": 100, "y": 50 }, ... },
{ "id": "comp_2", "position": { "x": 500, "y": 50 }, ... },
{ "id": "GridLayout_1", "position": { "x": 100, "y": 200 }, ... }
]
}
```
**현재**: absolute 포지션으로 컴포넌트 배치 → **반응형 불가**
#### 2.5.2 컴포넌트 레이아웃 (GridLayout, FlexboxLayout 등)
개별 레이아웃 컴포넌트 내부의 zone 배치를 담당합니다.
| 컴포넌트 | 위치 | 내부 구조 | CSS Grid 사용 |
|----------|------|-----------|---------------|
| `GridLayout` | `layouts/grid/` | zones 배열 | ✅ 이미 사용 |
| `FlexboxLayout` | `layouts/flexbox/` | zones 배열 | ❌ absolute |
| `SplitLayout` | `layouts/split/` | left/right | ❌ flex |
| `TabsLayout` | `layouts/` | tabs 배열 | ❌ 탭 구조 |
| `CardLayout` | `layouts/card-layout/` | zones 배열 | ❌ flex |
| `AccordionLayout` | `layouts/accordion/` | items 배열 | ❌ 아코디언 |
#### 2.5.3 구조 다이어그램
```
┌─────────────────────────────────────────────────────────────────┐
│ screen_layouts_v2 (화면 전체) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 현재: absolute 포지션 → 반응형 불가 │ │
│ │ 변경: ResponsiveGridLayout (CSS Grid) → 반응형 가능 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────────────────┐ │
│ │ v2-button │ │ v2-input │ │ GridLayout (컴포넌트) │ │
│ │ (shadcn) │ │ (shadcn) │ │ ┌─────────┬─────────────┐ │ │
│ └──────────┘ └──────────┘ │ │ zone1 │ zone2 │ │ │
│ │ │ (이미 │ (이미 │ │ │
│ │ │ CSS Grid│ CSS Grid) │ │ │
│ │ └─────────┴─────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.6 기존 레이아웃 컴포넌트 호환성
#### 2.6.1 GridLayout (기존 커스텀 그리드)
```tsx
// frontend/lib/registry/layouts/grid/GridLayout.tsx
// 이미 CSS Grid를 사용하고 있음!
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
gap: `${gridConfig.gap || 16}px`,
};
```
**호환성**: ✅ **완전 호환**
- GridLayout은 화면 내 하나의 컴포넌트로 취급됨
- ResponsiveGridLayout이 GridLayout의 **위치만** 관리
- GridLayout 내부는 기존 방식 그대로 동작
#### 2.6.2 FlexboxLayout
```tsx
// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx
// zone 내부에서 컴포넌트를 absolute로 배치
{zoneChildren.map((child) => (
<div style={{
position: "absolute",
left: child.position?.x || 0,
top: child.position?.y || 0,
}}>
{renderer.renderChild(child)}
</div>
))}
```
**호환성**: ✅ **호환** (내부는 기존 방식 유지)
- FlexboxLayout 컴포넌트 자체의 위치는 ResponsiveGridLayout이 관리
- 내부 zone의 컴포넌트 배치는 기존 absolute 방식 유지
#### 2.6.3 SplitPanelLayout (분할 패널)
**호환성**: ⚠️ **별도 수정 필요**
- 외부 위치: ResponsiveGridLayout이 관리 ✅
- 내부 반응형: 별도 수정 필요 (모바일에서 상하 분할)
#### 2.6.4 호환성 요약
| 컴포넌트 | 외부 배치 | 내부 동작 | 추가 수정 |
|----------|----------|----------|-----------|
| **v2-button, v2-input 등** | ✅ 반응형 | ✅ shadcn 그대로 | ❌ 불필요 |
| **GridLayout** | ✅ 반응형 | ✅ CSS Grid 그대로 | ❌ 불필요 |
| **FlexboxLayout** | ✅ 반응형 | ⚠️ absolute 유지 | ❌ 불필요 |
| **SplitPanelLayout** | ✅ 반응형 | ❌ 좌우 고정 | ⚠️ 내부 반응형 추가 |
| **TabsLayout** | ✅ 반응형 | ✅ 탭 그대로 | ❌ 불필요 |
### 2.7 동작 방식 비교
#### 변경 전
```
화면 로드
screen_layouts_v2에서 components 조회
각 컴포넌트를 position.x, position.y로 absolute 배치
GridLayout 컴포넌트도 absolute로 배치됨
GridLayout 내부는 CSS Grid로 zone 배치
결과: 화면 크기 변해도 모든 컴포넌트 위치 고정
```
#### 변경 후
```
화면 로드
screen_layouts_v2에서 components 조회
layoutMode === "grid" 확인
ResponsiveGridLayout으로 렌더링 (CSS Grid)
각 컴포넌트를 grid.col, grid.colSpan으로 배치
화면 크기 감지 (ResizeObserver)
breakpoint에 따라 responsive.sm/md/lg 적용
GridLayout 컴포넌트도 반응형으로 배치됨
GridLayout 내부는 기존 CSS Grid로 zone 배치 (변경 없음)
결과: 화면 크기에 따라 컴포넌트 재배치
```
---
## 3. 기술 결정
### 3.1 왜 Tailwind 동적 클래스가 아닌 CSS Grid + Inline Style인가?
**Tailwind 동적 클래스의 한계**:
```tsx
// ❌ 이건 안 됨 - Tailwind가 빌드 타임에 인식 못함
className={`col-start-${col} md:col-start-${mdCol}`}
// ✅ 이것만 됨 - 정적 클래스
className="col-start-1 md:col-start-3"
```
Tailwind는 **빌드 타임**에 클래스를 스캔하므로, 런타임에 동적으로 생성되는 클래스는 인식하지 못합니다.
**해결책: CSS Grid + Inline Style + ResizeObserver**:
```tsx
// ✅ 올바른 방법
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(12, 1fr)',
}}>
<div style={{
gridColumn: `${col} / span ${colSpan}`, // 동적 값 가능
}}>
{component}
</div>
</div>
```
### 3.2 역할 분담
| 영역 | 기술 | 설명 |
|------|------|------|
| **UI 컴포넌트** | shadcn/ui | 버튼, 인풋, 테이블 등 (이미 적용됨) |
| **레이아웃 배치** | CSS Grid + Inline Style | 컴포넌트 위치, 크기, 반응형 |
| **반응형 감지** | ResizeObserver | 화면 크기 감지 및 브레이크포인트 변경 |
```
┌─────────────────────────────────────────────────────────┐
│ ResponsiveGridLayout (CSS Grid) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ shadcn │ │ shadcn │ │ shadcn │ │
│ │ Button │ │ Input │ │ Select │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ shadcn Table │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
---
## 4. 데이터 구조 변경
### 4.1 현재 구조 (V2)
```json
{
"version": "2.0",
"components": [{
"id": "comp_xxx",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1753, "y": 88, "z": 1 },
"size": { "width": 158, "height": 40 },
"overrides": { ... }
}]
}
```
### 4.2 변경 후 구조 (V2 + 그리드)
```json
{
"version": "2.0",
"layoutMode": "grid",
"components": [{
"id": "comp_xxx",
"url": "@/lib/registry/components/v2-button-primary",
"position": { "x": 1753, "y": 88, "z": 1 },
"size": { "width": 158, "height": 40 },
"grid": {
"col": 11,
"row": 2,
"colSpan": 1,
"rowSpan": 1
},
"responsive": {
"sm": { "col": 1, "colSpan": 12 },
"md": { "col": 7, "colSpan": 6 },
"lg": { "col": 11, "colSpan": 1 }
},
"overrides": { ... }
}],
"gridSettings": {
"columns": 12,
"rowHeight": 80,
"gap": 16
}
}
```
### 4.3 필드 설명
| 필드 | 타입 | 설명 |
|------|------|------|
| `layoutMode` | string | "grid" (반응형 그리드 사용) |
| `grid.col` | number | 시작 컬럼 (1-12) |
| `grid.row` | number | 시작 행 (1부터) |
| `grid.colSpan` | number | 차지하는 컬럼 수 |
| `grid.rowSpan` | number | 차지하는 행 수 |
| `responsive.sm` | object | 모바일 (< 768px) 설정 |
| `responsive.md` | object | 태블릿 (768px ~ 1024px) 설정 |
| `responsive.lg` | object | 데스크톱 (> 1024px) 설정 |
### 4.4 호환성
- `position`, `size` 필드는 유지 (디자인 모드 + 폴백용)
- `layoutMode`가 없으면 기존 방식(absolute) 사용
- 마이그레이션 후에도 기존 화면 정상 동작
---
## 5. 구현 상세
### 5.1 그리드 변환 유틸리티
```typescript
// frontend/lib/utils/gridConverter.ts
const DESIGN_WIDTH = 1920;
const COLUMNS = 12;
const COLUMN_WIDTH = DESIGN_WIDTH / COLUMNS; // 160px
const ROW_HEIGHT = 80;
/**
* 픽셀 좌표를 그리드 셀 번호로 변환
*/
export function pixelToGrid(
position: { x: number; y: number },
size: { width: number; height: number }
): GridPosition {
return {
col: Math.max(1, Math.min(12, Math.round(position.x / COLUMN_WIDTH) + 1)),
row: Math.max(1, Math.round(position.y / ROW_HEIGHT) + 1),
colSpan: Math.max(1, Math.round(size.width / COLUMN_WIDTH)),
rowSpan: Math.max(1, Math.round(size.height / ROW_HEIGHT)),
};
}
/**
* 기본 반응형 설정 생성
*/
export function getDefaultResponsive(grid: GridPosition): ResponsiveConfig {
return {
sm: { col: 1, colSpan: 12 }, // 모바일: 전체 너비
md: {
col: Math.max(1, Math.round(grid.col / 2)),
colSpan: Math.min(grid.colSpan * 2, 12)
}, // 태블릿: 2배 확장
lg: { col: grid.col, colSpan: grid.colSpan }, // 데스크톱: 원본
};
}
```
### 5.2 반응형 그리드 레이아웃 컴포넌트
```tsx
// frontend/lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx
import React, { useRef, useState, useEffect } from "react";
type Breakpoint = "sm" | "md" | "lg";
interface ResponsiveGridLayoutProps {
layout: LayoutData;
isDesignMode: boolean;
renderer: ComponentRenderer;
}
export function ResponsiveGridLayout({
layout,
isDesignMode,
renderer,
}: ResponsiveGridLayoutProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
// 화면 크기 감지
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
if (width < 768) setBreakpoint("sm");
else if (width < 1024) setBreakpoint("md");
else setBreakpoint("lg");
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const gridSettings = layout.gridSettings || { columns: 12, rowHeight: 80, gap: 16 };
return (
<div
ref={containerRef}
style={{
display: "grid",
gridTemplateColumns: `repeat(${gridSettings.columns}, 1fr)`,
gridAutoRows: `${gridSettings.rowHeight}px`,
gap: `${gridSettings.gap}px`,
minHeight: isDesignMode ? "600px" : "auto",
}}
>
{layout.components
.sort((a, b) => (a.grid?.row || 0) - (b.grid?.row || 0))
.map((component) => {
// 반응형 설정 가져오기
const gridConfig = component.responsive?.[breakpoint] || component.grid;
const { col, colSpan } = gridConfig;
const rowSpan = component.grid?.rowSpan || 1;
return (
<div
key={component.id}
style={{
gridColumn: `${col} / span ${colSpan}`,
gridRow: `span ${rowSpan}`,
}}
>
{renderer.renderChild(component)}
</div>
);
})}
</div>
);
}
```
### 5.3 브레이크포인트 훅
```typescript
// frontend/lib/registry/layouts/responsive-grid/useBreakpoint.ts
import { useState, useEffect, RefObject } from "react";
type Breakpoint = "sm" | "md" | "lg";
export function useBreakpoint(containerRef: RefObject<HTMLElement>): Breakpoint {
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
if (width < 768) setBreakpoint("sm");
else if (width < 1024) setBreakpoint("md");
else setBreakpoint("lg");
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [containerRef]);
return breakpoint;
}
```
### 5.4 분할 패널 반응형 수정
```tsx
// frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx
// 추가할 코드
const containerRef = useRef<HTMLDivElement>(null);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
setIsMobile(width < 768);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// 렌더링 부분 수정
return (
<div
ref={containerRef}
className={cn(
"flex h-full",
isMobile ? "flex-col" : "flex-row" // 모바일: 상하, 데스크톱: 좌우
)}
>
<div style={{
width: isMobile ? "100%" : `${leftWidth}%`,
minHeight: isMobile ? "300px" : "auto"
}}>
{/* 좌측/상단 패널 */}
</div>
<div style={{
width: isMobile ? "100%" : `${100 - leftWidth}%`,
minHeight: isMobile ? "300px" : "auto"
}}>
{/* 우측/하단 패널 */}
</div>
</div>
);
```
---
## 6. 렌더링 분기 처리
```typescript
// frontend/lib/registry/DynamicComponentRenderer.tsx
function renderLayout(layout: LayoutData) {
// layoutMode에 따라 분기
if (layout.layoutMode === "grid") {
return <ResponsiveGridLayout layout={layout} renderer={this} />;
}
// 기존 방식 (폴백)
return <FlexboxLayout layout={layout} renderer={this} />;
}
```
---
## 7. 마이그레이션
### 7.1 백업
```sql
-- 마이그레이션 전 백업
CREATE TABLE screen_layouts_v2_backup_20260130 AS
SELECT * FROM screen_layouts_v2;
```
### 7.2 마이그레이션 스크립트
```sql
-- grid, responsive 필드 추가
UPDATE screen_layouts_v2
SET layout_data = (
SELECT jsonb_set(
jsonb_set(
layout_data,
'{layoutMode}',
'"grid"'
),
'{components}',
(
SELECT jsonb_agg(
comp || jsonb_build_object(
'grid', jsonb_build_object(
'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)),
'row', GREATEST(1, ROUND((comp->'position'->>'y')::NUMERIC / 80) + 1),
'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160)),
'rowSpan', GREATEST(1, ROUND((comp->'size'->>'height')::NUMERIC / 80))
),
'responsive', jsonb_build_object(
'sm', jsonb_build_object('col', 1, 'colSpan', 12),
'md', jsonb_build_object(
'col', GREATEST(1, ROUND((ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1) / 2.0)),
'colSpan', LEAST(ROUND((comp->'size'->>'width')::NUMERIC / 160) * 2, 12)
),
'lg', jsonb_build_object(
'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)),
'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160))
)
)
)
)
FROM jsonb_array_elements(layout_data->'components') as comp
)
)
);
```
### 7.3 롤백
```sql
-- 문제 발생 시 롤백
DROP TABLE screen_layouts_v2;
ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2;
```
---
## 8. 동작 흐름
### 8.1 데스크톱 (> 1024px)
```
┌────────────────────────────────────────────────────────────┐
│ 1 2 3 4 5 6 7 8 9 10 │ 11 12 │ │
│ │ [버튼] │ │
├────────────────────────────────────────────────────────────┤
│ │
│ 테이블 (12컬럼) │
│ │
└────────────────────────────────────────────────────────────┘
```
### 8.2 태블릿 (768px ~ 1024px)
```
┌─────────────────────────────────────┐
│ 1 2 3 4 5 6 │ 7 8 9 10 11 12 │
│ │ [버튼] │
├─────────────────────────────────────┤
│ │
│ 테이블 (12컬럼) │
│ │
└─────────────────────────────────────┘
```
### 8.3 모바일 (< 768px)
```
┌──────────────────┐
│ [버튼] │ ← 12컬럼 (전체 너비)
├──────────────────┤
│ │
│ 테이블 (스크롤) │ ← 12컬럼 (전체 너비)
│ │
└──────────────────┘
```
### 8.4 분할 패널 (반응형)
**데스크톱**:
```
┌─────────────────────────┬─────────────────────────┐
│ 좌측 패널 (60%) │ 우측 패널 (40%) │
└─────────────────────────┴─────────────────────────┘
```
**모바일**:
```
┌─────────────────────────┐
│ 상단 패널 (이전 좌측) │
├─────────────────────────┤
│ 하단 패널 (이전 우측) │
└─────────────────────────┘
```
---
## 9. 수정 파일 목록
### 9.1 새로 생성
| 파일 | 설명 |
|------|------|
| `lib/utils/gridConverter.ts` | 픽셀 → 그리드 변환 유틸리티 |
| `lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx` | CSS Grid 레이아웃 |
| `lib/registry/layouts/responsive-grid/useBreakpoint.ts` | ResizeObserver 훅 |
| `lib/registry/layouts/responsive-grid/index.ts` | 모듈 export |
### 9.2 수정
| 파일 | 수정 내용 |
|------|-----------|
| `lib/registry/DynamicComponentRenderer.tsx` | layoutMode 분기 추가 |
| `components/screen/ScreenDesigner.tsx` | 저장 시 grid/responsive 생성 |
| `v2-split-panel-layout/SplitPanelLayoutComponent.tsx` | 반응형 처리 추가 |
### 9.3 수정 없음
| 파일 | 이유 |
|------|------|
| `v2-input/*` | 레이아웃과 무관 (shadcn 그대로) |
| `v2-button-primary/*` | 레이아웃과 무관 (shadcn 그대로) |
| `v2-table-list/*` | 레이아웃과 무관 (shadcn 그대로) |
| `v2-select/*` | 레이아웃과 무관 (shadcn 그대로) |
| **...모든 v2 컴포넌트** | **수정 불필요** |
---
## 10. 작업 일정
| Phase | 작업 | 파일 | 시간 |
|-------|------|------|------|
| **1** | 그리드 변환 유틸리티 | `gridConverter.ts` | 2시간 |
| **1** | 브레이크포인트 훅 | `useBreakpoint.ts` | 1시간 |
| **2** | ResponsiveGridLayout | `ResponsiveGridLayout.tsx` | 4시간 |
| **2** | 렌더링 분기 처리 | `DynamicComponentRenderer.tsx` | 1시간 |
| **3** | 저장 로직 수정 | `ScreenDesigner.tsx` | 2시간 |
| **3** | 분할 패널 반응형 | `SplitPanelLayoutComponent.tsx` | 3시간 |
| **4** | 마이그레이션 스크립트 | SQL | 2시간 |
| **4** | 마이그레이션 실행 | - | 1시간 |
| **5** | 테스트 및 버그 수정 | - | 4시간 |
| | **합계** | | **약 2.5일** |
---
## 11. 체크리스트
### 개발 전
- [ ] screen_layouts_v2 백업 완료
- [ ] 개발 환경에서 테스트 데이터 준비
### Phase 1: 유틸리티
- [ ] `gridConverter.ts` 생성
- [ ] `useBreakpoint.ts` 생성
- [ ] 단위 테스트 작성
### Phase 2: 레이아웃
- [ ] `ResponsiveGridLayout.tsx` 생성
- [ ] `DynamicComponentRenderer.tsx` 분기 추가
- [ ] 기존 화면 정상 동작 확인
### Phase 3: 저장/수정
- [ ] `ScreenDesigner.tsx` 저장 로직 수정
- [ ] `SplitPanelLayoutComponent.tsx` 반응형 추가
- [ ] 디자인 모드 테스트
### Phase 4: 마이그레이션
- [ ] 마이그레이션 스크립트 테스트 (개발 DB)
- [ ] 운영 DB 백업
- [ ] 마이그레이션 실행
- [ ] 검증
### Phase 5: 테스트
- [ ] PC (1920px, 1280px) 테스트
- [ ] 태블릿 (768px, 1024px) 테스트
- [ ] 모바일 (375px, 414px) 테스트
- [ ] 분할 패널 화면 테스트
- [ ] GridLayout 컴포넌트 포함 화면 테스트
- [ ] FlexboxLayout 컴포넌트 포함 화면 테스트
- [ ] TabsLayout 컴포넌트 포함 화면 테스트
- [ ] 중첩 레이아웃 (GridLayout 안에 컴포넌트) 테스트
---
## 12. 리스크 및 대응
| 리스크 | 영향 | 대응 |
|--------|------|------|
| 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 |
| 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) |
| 디자인 모드 혼란 | 낮음 | position/size 필드 유지 |
| GridLayout 내부 깨짐 | 낮음 | 내부는 기존 방식 유지, 외부 배치만 변경 |
| 중첩 레이아웃 문제 | 낮음 | 각 레이아웃 컴포넌트는 독립적으로 동작 |
---
## 13. 참고
- [COMPONENT_LAYOUT_V2_ARCHITECTURE.md](./COMPONENT_LAYOUT_V2_ARCHITECTURE.md) - V2 아키텍처
- [CSS Grid Layout - MDN](https://developer.mozilla.org/ko/docs/Web/CSS/CSS_Grid_Layout)
- [ResizeObserver - MDN](https://developer.mozilla.org/ko/docs/Web/API/ResizeObserver)
- [shadcn/ui](https://ui.shadcn.com/) - 컴포넌트 라이브러리

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
│ │
│ └─── properties.componentConfig
│ └─── screen_layouts_v2 (V2) ← 현재 표준
│ │
│ └─── 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

@ -0,0 +1,525 @@
# 화면 복제 로직 V2 마이그레이션 계획서
> 작성일: 2026-01-28
## 1. 현황 분석
### 1.1 현재 복제 방식 (Legacy)
```
테이블: screen_layouts (다중 레코드)
방식: 화면당 N개 레코드 (컴포넌트 수만큼)
저장: properties에 전체 설정 "박제"
```
**데이터 구조:**
```sql
-- 화면당 여러 레코드
SELECT * FROM screen_layouts WHERE screen_id = 123;
-- layout_id | screen_id | component_type | component_id | properties (전체 설정)
-- 1 | 123 | table-list | comp_001 | {"tableName": "user", "columns": [...], ...}
-- 2 | 123 | button | comp_002 | {"label": "저장", "variant": "default", ...}
```
### 1.2 V2 방식
```
테이블: screen_layouts_v2 (1개 레코드)
방식: 화면당 1개 레코드 (JSONB)
저장: url + overrides (차이값만)
```
**데이터 구조:**
```sql
-- 화면당 1개 레코드
SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = 123;
-- {
-- "version": "2.0",
-- "components": [
-- { "id": "comp_001", "url": "@/lib/registry/components/table-list", "overrides": {...} },
-- { "id": "comp_002", "url": "@/lib/registry/components/button-primary", "overrides": {...} }
-- ]
-- }
```
---
## 2. 현재 복제 로직 분석
### 2.1 복제 진입점 (2곳)
| 경로 | 파일 | 함수 | 용도 |
|-----|------|------|-----|
| 단일 화면 복제 | `screenManagementService.ts` | `copyScreen()` | 화면 관리에서 개별 화면 복제 |
| 메뉴 일괄 복제 | `menuCopyService.ts` | `copyScreens()` | 메뉴 복제 시 연결된 화면들 복제 |
### 2.2 screenManagementService.copyScreen() 흐름
```
1. screen_definitions 조회 (원본)
2. screen_definitions INSERT (대상)
3. screen_layouts 조회 (원본) ← Legacy
4. flowId 수집 및 복제 (회사 간 복제 시)
5. numberingRuleId 수집 및 복제 (회사 간 복제 시)
6. componentId 재생성 (idMapping)
7. properties 내 참조 업데이트 (flowId, ruleId)
8. screen_layouts INSERT (대상) ← Legacy
```
**V2 처리: ❌ 없음**
### 2.3 menuCopyService.copyScreens() 흐름
```
1단계: screen_definitions 처리
- 기존 복사본 존재 시: 업데이트
- 없으면: 신규 생성
- screenIdMap 생성
2단계: screen_layouts 처리
- 원본 조회
- componentIdMap 생성
- properties 내 참조 업데이트 (screenId, flowId, ruleId, menuId)
- 배치 INSERT
```
**V2 처리: ❌ 없음**
### 2.4 복제 시 처리되는 참조 ID들
| 참조 ID | 설명 | 매핑 방식 |
|--------|-----|----------|
| `componentId` | 컴포넌트 고유 ID | 새로 생성 (`comp_xxx`) |
| `parentId` | 부모 컴포넌트 ID | componentIdMap으로 매핑 |
| `flowId` | 노드 플로우 ID | flowIdMap으로 매핑 (회사 간 복제 시) |
| `numberingRuleId` | 채번 규칙 ID | ruleIdMap으로 매핑 (회사 간 복제 시) |
| `screenId` (탭) | 탭에서 참조하는 화면 ID | screenIdMap으로 매핑 |
| `menuObjid` | 메뉴 ID | menuIdMap으로 매핑 |
---
## 3. V2 마이그레이션 시 변경 필요 사항
### 3.1 핵심 변경점
| 항목 | Legacy | V2 |
|-----|--------|-----|
| 읽기 테이블 | `screen_layouts` | `screen_layouts_v2` |
| 쓰기 테이블 | `screen_layouts` | `screen_layouts_v2` |
| 데이터 형태 | N개 레코드 | 1개 JSONB |
| ID 매핑 위치 | 각 레코드의 컬럼 | JSONB 내부 순회 |
| 참조 업데이트 | `properties` JSON | `overrides` JSON |
### 3.2 수정해야 할 함수들
#### screenManagementService.ts
| 함수 | 변경 내용 |
|-----|----------|
| `copyScreen()` | screen_layouts_v2 복제 로직 추가 |
| `collectFlowIdsFromLayouts()` | V2 JSONB 구조에서 flowId 수집 |
| `collectNumberingRuleIdsFromLayouts()` | V2 JSONB 구조에서 ruleId 수집 |
| `updateFlowIdsInProperties()` | V2 overrides 내 flowId 업데이트 |
| `updateNumberingRuleIdsInProperties()` | V2 overrides 내 ruleId 업데이트 |
#### menuCopyService.ts
| 함수 | 변경 내용 |
|-----|----------|
| `copyScreens()` | screen_layouts_v2 복제 로직 추가 |
| `hasLayoutChanges()` | V2 JSONB 비교 로직 |
| `updateReferencesInProperties()` | V2 overrides 내 참조 업데이트 |
### 3.3 새로 추가할 함수들
```typescript
// V2 레이아웃 복제 (공통)
async copyLayoutV2(
sourceScreenId: number,
targetScreenId: number,
targetCompanyCode: string,
mappings: {
componentIdMap: Map<string, string>;
flowIdMap: Map<number, number>;
ruleIdMap: Map<string, string>;
screenIdMap: Map<number, number>;
menuIdMap?: Map<number, number>;
},
client: PoolClient
): Promise<void>
// V2 JSONB에서 참조 ID 수집
collectReferencesFromLayoutV2(layoutData: any): {
flowIds: Set<number>;
ruleIds: Set<string>;
screenIds: Set<number>;
}
// V2 JSONB 내 참조 업데이트
updateReferencesInLayoutV2(
layoutData: any,
mappings: { ... }
): any
```
---
## 4. 마이그레이션 전략
### 4.1 전략: V2 완전 전환
```
결정: V2만 복제 (Legacy 복제 제거)
이유: 깔끔한 코드, 유지보수 용이, V2 아키텍처 일관성
전제: 기존 화면들은 이미 screen_layouts_v2로 마이그레이션 완료 (1,347개 100%)
```
### 4.2 단계별 계획
#### Phase 1: V2 복제 로직 구현 및 전환
```
목표: Legacy 복제를 V2 복제로 완전 교체
영향: 복제 시 screen_layouts_v2 테이블만 사용
작업:
1. copyLayoutV2() 공통 함수 구현
2. screenManagementService.copyScreen() - Legacy → V2 교체
3. menuCopyService.copyScreens() - Legacy → V2 교체
4. 테스트 및 검증
```
#### Phase 2: Legacy 코드 정리
```
목표: 불필요한 Legacy 복제 코드 제거
영향: 코드 간소화
작업:
1. screen_layouts 관련 복제 코드 제거
2. 관련 헬퍼 함수 정리 (collectFlowIdsFromLayouts 등)
3. 코드 리뷰 및 정리
```
#### Phase 3: Legacy 테이블 정리 (선택, 추후)
```
목표: 불필요한 테이블 제거
영향: 데이터 정리
작업:
1. screen_layouts 테이블 데이터 백업
2. screen_layouts 테이블 삭제 (또는 보관)
3. 관련 코드 정리
```
---
## 5. 상세 구현 계획
### 5.1 Phase 1 작업 목록
| # | 작업 | 파일 | 예상 공수 |
|---|-----|------|---------|
| 1 | `copyLayoutV2()` 공통 함수 구현 | screenManagementService.ts | 2시간 |
| 2 | `collectReferencesFromLayoutV2()` 구현 | screenManagementService.ts | 1시간 |
| 3 | `updateReferencesInLayoutV2()` 구현 | screenManagementService.ts | 2시간 |
| 4 | `copyScreen()` - Legacy 제거, V2로 교체 | screenManagementService.ts | 2시간 |
| 5 | `copyScreens()` - Legacy 제거, V2로 교체 | menuCopyService.ts | 3시간 |
| 6 | 단위 테스트 | - | 2시간 |
| 7 | 통합 테스트 | - | 2시간 |
**총 예상 공수: 14시간 (약 2일)**
### 5.2 주요 변경 포인트
#### copyScreen() 변경 전후
**Before (Legacy):**
```typescript
// 4. 원본 화면의 레이아웃 정보 조회
const sourceLayoutsResult = await client.query<any>(
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
[sourceScreenId]
);
// ... N개 레코드 순회하며 INSERT
```
**After (V2):**
```typescript
// 4. 원본 V2 레이아웃 조회
const sourceLayoutV2 = await client.query(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[sourceScreenId, sourceCompanyCode]
);
// ... JSONB 변환 후 1개 레코드 INSERT
```
#### copyScreens() 변경 전후
**Before (Legacy):**
```typescript
// 레이아웃 배치 INSERT
await client.query(
`INSERT INTO screen_layouts (...) VALUES ${layoutValues.join(", ")}`,
layoutParams
);
```
**After (V2):**
```typescript
// V2 레이아웃 UPSERT
await this.copyLayoutV2(
originalScreenId, targetScreenId, sourceCompanyCode, targetCompanyCode,
{ componentIdMap, flowIdMap, ruleIdMap, screenIdMap, menuIdMap },
client
);
```
### 5.2 copyLayoutV2() 구현 방안
```typescript
private async copyLayoutV2(
sourceScreenId: number,
targetScreenId: number,
sourceCompanyCode: string,
targetCompanyCode: string,
mappings: {
componentIdMap: Map<string, string>;
flowIdMap?: Map<number, number>;
ruleIdMap?: Map<string, string>;
screenIdMap?: Map<number, number>;
menuIdMap?: Map<number, number>;
},
client: PoolClient
): Promise<void> {
// 1. 원본 V2 레이아웃 조회
const sourceResult = await client.query(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[sourceScreenId, sourceCompanyCode]
);
if (sourceResult.rows.length === 0) {
// V2 레이아웃 없으면 스킵 (Legacy만 있는 경우)
return;
}
const layoutData = sourceResult.rows[0].layout_data;
// 2. components 배열 순회하며 ID 매핑
const updatedComponents = layoutData.components.map((comp: any) => {
const newId = mappings.componentIdMap.get(comp.id) || comp.id;
// overrides 내 참조 업데이트
let updatedOverrides = { ...comp.overrides };
// flowId 매핑
if (mappings.flowIdMap && updatedOverrides.flowId) {
const newFlowId = mappings.flowIdMap.get(updatedOverrides.flowId);
if (newFlowId) updatedOverrides.flowId = newFlowId;
}
// numberingRuleId 매핑
if (mappings.ruleIdMap && updatedOverrides.numberingRuleId) {
const newRuleId = mappings.ruleIdMap.get(updatedOverrides.numberingRuleId);
if (newRuleId) updatedOverrides.numberingRuleId = newRuleId;
}
// screenId 매핑 (탭 컴포넌트 등)
if (mappings.screenIdMap && updatedOverrides.screenId) {
const newScreenId = mappings.screenIdMap.get(updatedOverrides.screenId);
if (newScreenId) updatedOverrides.screenId = newScreenId;
}
// tabs 배열 내 screenId 매핑
if (mappings.screenIdMap && Array.isArray(updatedOverrides.tabs)) {
updatedOverrides.tabs = updatedOverrides.tabs.map((tab: any) => ({
...tab,
screenId: mappings.screenIdMap.get(tab.screenId) || tab.screenId
}));
}
return {
...comp,
id: newId,
overrides: updatedOverrides
};
});
const newLayoutData = {
...layoutData,
components: updatedComponents,
updatedAt: new Date().toISOString()
};
// 3. 대상 V2 레이아웃 저장 (UPSERT)
await client.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()`,
[targetScreenId, targetCompanyCode, JSON.stringify(newLayoutData)]
);
}
```
---
## 6. 테스트 계획
### 6.1 단위 테스트
| 테스트 케이스 | 설명 |
|-------------|------|
| V2 레이아웃 복제 - 기본 | 단순 컴포넌트 복제 |
| V2 레이아웃 복제 - flowId 매핑 | 회사 간 복제 시 flowId 변경 확인 |
| V2 레이아웃 복제 - ruleId 매핑 | 회사 간 복제 시 ruleId 변경 확인 |
| V2 레이아웃 복제 - 탭 screenId 매핑 | 탭 컴포넌트의 screenId 변경 확인 |
| V2 레이아웃 없는 경우 | Legacy만 있는 화면 복제 시 스킵 확인 |
### 6.2 통합 테스트
| 테스트 케이스 | 설명 |
|-------------|------|
| 단일 화면 복제 (같은 회사) | copyScreen() - 동일 회사 내 복제 |
| 단일 화면 복제 (다른 회사) | copyScreen() - 회사 간 복제 |
| 메뉴 일괄 복제 | copyScreens() - 여러 화면 동시 복제 |
| 모달 포함 복제 | copyScreenWithModals() - 메인 + 모달 복제 |
### 6.3 검증 항목
```
복제 후 확인:
- [ ] screen_layouts_v2에 레코드 생성됨
- [ ] componentId가 새로 생성됨
- [ ] flowId가 정확히 매핑됨
- [ ] numberingRuleId가 정확히 매핑됨
- [ ] 탭 컴포넌트의 screenId가 정확히 매핑됨
- [ ] screen_layouts(Legacy)는 복제되지 않음
- [ ] 복제된 화면이 프론트엔드에서 정상 로드됨
- [ ] 복제된 화면 편집/저장 정상 동작
```
---
## 7. 영향 분석
### 7.1 영향 받는 기능
| 기능 | 영향 | 비고 |
|-----|-----|-----|
| 화면 관리 - 화면 복제 | 직접 영향 | copyScreen() |
| 화면 관리 - 그룹 복제 | 직접 영향 | copyScreenWithModals() |
| 메뉴 복제 | 직접 영향 | menuCopyService.copyScreens() |
| 화면 디자이너 | 간접 영향 | 복제된 화면 로드 시 V2 사용 |
### 7.2 롤백 계획
```
V2 전환 롤백 (필요시):
1. Git에서 이전 버전 복원 (copyScreen, copyScreens)
2. Legacy 복제 코드 복원
3. 테스트 후 배포
주의사항:
- V2로 복제된 화면들은 screen_layouts_v2에만 데이터 존재
- 롤백 시 해당 화면들은 screen_layouts에 데이터 없음
- 필요시 V2 → Legacy 역변환 스크립트 실행
```
---
## 8. 관련 파일
### 8.1 수정 대상
| 파일 | 변경 내용 |
|-----|----------|
| `backend-node/src/services/screenManagementService.ts` | copyLayoutV2(), copyScreen() 수정 |
| `backend-node/src/services/menuCopyService.ts` | copyScreens() 수정 |
### 8.2 참고 파일
| 파일 | 설명 |
|-----|-----|
| `docs/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` | V2 아키텍처 문서 |
| `frontend/lib/api/screen.ts` | getLayoutV2, saveLayoutV2 |
| `frontend/lib/utils/layoutV2Converter.ts` | V2 변환 유틸리티 |
---
## 9. 체크리스트
### 9.1 개발 전
- [ ] V2 아키텍처 문서 숙지
- [ ] 현재 복제 로직 코드 리뷰
- [ ] 테스트 데이터 준비 (V2 레이아웃이 있는 화면)
### 9.2 Phase 1 완료 조건
- [x] copyLayoutV2() 함수 구현 ✅ 2026-01-28
- [x] collectReferencesFromLayoutV2() 함수 구현 ✅ 2026-01-28
- [x] updateReferencesInLayoutV2() 함수 구현 ✅ 2026-01-28
- [x] copyScreen() - Legacy 제거, V2로 교체 ✅ 2026-01-28
- [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28
- [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28
- [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28
- [x] 단위 테스트 통과 ✅ 2026-01-30
- [x] 통합 테스트 통과 ✅ 2026-01-30
- [x] V2 전용 복제 동작 확인 ✅ 2026-01-30
### 9.3 Phase 2 완료 조건
- [ ] Legacy 관련 헬퍼 함수 정리
- [ ] 불필요한 코드 제거
- [ ] 코드 리뷰 완료
- [ ] 회귀 테스트 통과
---
## 10. 시뮬레이션 검증 결과
### 10.1 검증된 시나리오
| 시나리오 | 결과 | 비고 |
|---------|------|------|
| 같은 회사 내 복제 | ✅ 정상 | componentId만 새로 생성 |
| 회사 간 복제 (flowId 매핑) | ✅ 정상 | flowIdMap 적용됨 |
| 회사 간 복제 (ruleId 매핑) | ✅ 정상 | ruleIdMap 적용됨 |
| 탭 컴포넌트 screenId 매핑 | ✅ 정상 | updateTabScreenReferences V2 지원 추가 |
| V2 레이아웃 없는 화면 | ✅ 정상 | 스킵 처리 |
### 10.2 발견 및 수정된 문제
| 문제 | 해결 |
|-----|------|
| updateTabScreenReferences가 V2 미지원 | V2 처리 로직 추가 완료 |
### 10.3 Zod 활용 가능성
프론트엔드에 이미 훌륭한 Zod 유틸리티 존재:
- `deepMerge()` - 깊은 병합
- `extractCustomConfig()` - 차이값 추출
- `loadComponentV2()` / `saveComponentV2()` - V2 로드/저장
향후 백엔드에도 Zod 추가 시:
- 타입 안전성 향상
- 프론트/백엔드 스키마 공유 가능
- 범용 참조 탐색 로직으로 하드코딩 제거 가능
---
## 11. 변경 이력
| 날짜 | 변경 내용 | 작성자 |
|-----|----------|-------|
| 2026-01-28 | 초안 작성 | Claude |
| 2026-01-28 | V2 완전 전환 전략으로 변경 (병행 운영 → V2 전용) | Claude |
| 2026-01-28 | Phase 1 구현 완료 - V2 복제 함수들 구현 및 Legacy 교체 | Claude |
| 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,356 @@
# V2 컴포넌트 마이그레이션 분석 보고서
> 작성일: 2026-01-27
> 목적: 미구현 V1 컴포넌트들의 V2 마이그레이션 가능성 분석
---
## 1. 현황 요약
| 구분 | 개수 | 비율 |
|------|------|------|
| V1 총 컴포넌트 | 7,170개 | 100% |
| V2 마이그레이션 완료 | 5,212개 | 72.7% |
| **미구현 (분석 대상)** | **~520개** | **7.3%** |
---
## 2. 미구현 컴포넌트 상세 분석
### 2.1 ✅ 통합 가능 (기존 V2 컴포넌트로 대체)
#### 2.1.1 `unified-list` (97개) → `v2-table-list`
**분석 결과**: ✅ **통합 가능**
| 항목 | unified-list | v2-table-list |
|------|-------------|---------------|
| 테이블 뷰 | ✅ | ✅ |
| 카드 뷰 | ✅ | ❌ (추가 필요) |
| 검색 | ✅ | ✅ |
| 페이지네이션 | ✅ | ✅ |
| 편집 가능 | ✅ | ✅ |
**결론**: `v2-table-list``cardView` 모드만 추가하면 통합 가능. 또는 DB 마이그레이션으로 `v2-table-list`로 변환.
**작업량**: 중간 (v2-table-list 확장 또는 DB 마이그레이션)
---
#### 2.1.2 `autocomplete-search-input` (50개) → `v2-select`
**분석 결과**: ✅ **통합 가능**
| 항목 | autocomplete-search-input | v2-select |
|------|--------------------------|-----------|
| 자동완성 드롭다운 | ✅ | ✅ (mode: autocomplete) |
| 테이블 데이터 검색 | ✅ | ✅ (dataSource 설정) |
| 표시/값 필드 분리 | ✅ | ✅ |
**결론**: `v2-select``mode: "autocomplete"` 또는 `mode: "combobox"`로 대체 가능.
**작업량**: 낮음 (DB 마이그레이션만)
---
#### 2.1.3 `repeater-field-group` (24개) → `v2-repeater`
**분석 결과**: ✅ **통합 가능**
`v2-repeater`가 이미 다음을 지원:
- 인라인 테이블 모드
- 모달 선택 모드
- 버튼 모드
**결론**: `v2-repeater``renderMode: "inline"`으로 대체.
**작업량**: 낮음 (DB 마이그레이션만)
---
#### 2.1.4 `simple-repeater-table` (1개) → `v2-repeater`
**분석 결과**: ✅ **통합 가능**
**결론**: `v2-repeater`로 대체.
**작업량**: 매우 낮음
---
### 2.2 ⚠️ Renderer 추가만 필요 (코드 구조 있음)
#### 2.2.1 `split-panel-layout2` (8개)
**분석 결과**: ⚠️ **Renderer 추가 필요**
- V1 Renderer: `SplitPanelLayout2Renderer.tsx` ✅ 존재
- V2 Renderer: ❌ 없음
- Component: `SplitPanelLayout2Component.tsx` ✅ 존재
**결론**: V2 형식으로 DB 마이그레이션만 하면 됨 (기존 Renderer가 `split-panel-layout2` ID로 등록됨).
**작업량**: 매우 낮음 (DB 마이그레이션만)
---
#### 2.2.2 `repeat-screen-modal` (7개)
**분석 결과**: ⚠️ **Renderer 추가 필요**
- V1 Renderer: `RepeatScreenModalRenderer.tsx` ✅ 존재
- 정의: `hidden: true` (v2-repeat-screen-modal 사용으로 패널에서 숨김)
**결론**: 기존 Renderer 사용 가능, DB 마이그레이션만.
**작업량**: 매우 낮음
---
#### 2.2.3 `related-data-buttons` (5개)
**분석 결과**: ⚠️ **Renderer 추가 필요**
- V1 Renderer: `RelatedDataButtonsRenderer.tsx` ✅ 존재
- Component: `RelatedDataButtonsComponent.tsx` ✅ 존재
**결론**: 기존 Renderer 사용 가능, DB 마이그레이션만.
**작업량**: 매우 낮음
---
### 2.3 ❌ 별도 V2 개발 필요 (복잡한 구조)
#### 2.3.1 `entity-search-input` (99개)
**분석 결과**: ❌ **별도 개발 필요**
**특징**:
```typescript
// 모달 기반 엔티티 검색
- 테이블 선택 (tableName)
- 검색 필드 설정 (searchFields)
- 모달 팝업 (modalTitle, modalColumns)
- 값/표시 필드 분리 (valueField, displayField)
- 추가 정보 표시 (additionalFields)
```
**복잡도 요인**:
1. 모달 검색 UI가 필요
2. 다양한 테이블 연동
3. 추가 필드 연계 로직
**권장 방안**:
- `v2-entity-search` 새로 개발
- 또는 `v2-select``mode: "entity"` 추가
**작업량**: 높음 (1-2일)
---
#### 2.3.2 `modal-repeater-table` (68개)
**분석 결과**: ❌ **별도 개발 필요**
**특징**:
```typescript
// 모달에서 항목 검색 + 동적 테이블
- 소스 테이블 (sourceTable, sourceColumns)
- 모달 검색 (modalTitle, modalButtonText, multiSelect)
- 동적 컬럼 추가 (columns)
- 계산 규칙 (calculationRules)
- 고유 필드 (uniqueField)
```
**복잡도 요인**:
1. 모달 검색 + 선택
2. 동적 테이블 행 추가/삭제
3. 계산 규칙 (단가 × 수량 = 금액)
4. 중복 방지 로직
**권장 방안**:
- `v2-repeater``modal` 모드 확장
- `ItemSelectionModal` + `RepeaterTable` 재사용
**작업량**: 중간 (v2-repeater가 이미 기반 제공)
---
#### 2.3.3 `selected-items-detail-input` (83개)
**분석 결과**: ❌ **별도 개발 필요**
**특징**:
```typescript
// 선택된 항목들의 상세 입력
- 데이터 소스 (dataSourceId)
- 표시 컬럼 (displayColumns)
- 추가 입력 필드 (additionalFields)
- 타겟 테이블 (targetTable)
- 레이아웃 (grid/table)
```
**복잡도 요인**:
1. 부모 컴포넌트에서 데이터 수신
2. 동적 필드 생성
3. 다중 테이블 저장
**권장 방안**:
- `v2-selected-items-detail` 새로 개발
- 또는 `v2-repeater``mode: "detail-input"` 추가
**작업량**: 중간~높음
---
#### 2.3.4 `conditional-container` (53개)
**분석 결과**: ❌ **별도 개발 필요**
**특징**:
```typescript
// 조건부 UI 분기
- 제어 필드 (controlField, controlLabel)
- 조건별 섹션 (sections: [{condition, label, screenId}])
- 기본값 (defaultValue)
```
**복잡도 요인**:
1. 셀렉트박스 값에 따른 동적 UI 변경
2. 화면 임베딩 (screenId)
3. 상태 관리 복잡
**권장 방안**:
- `v2-conditional-container` 새로 개발
- 조건부 렌더링 + 화면 임베딩 로직
**작업량**: 높음
---
#### 2.3.5 `universal-form-modal` (26개)
**분석 결과**: ❌ **별도 개발 필요**
**특징**:
```typescript
// 범용 폼 모달
- 섹션 기반 레이아웃
- 반복 섹션
- 채번규칙 연동
- 다중 테이블 저장
```
**복잡도 요인**:
1. 동적 섹션 구성
2. 채번규칙 연동
3. 다중 테이블 저장
4. 반복 필드 그룹
**권장 방안**:
- `v2-universal-form` 새로 개발
- 또는 기존 컴포넌트 유지 (특수 목적)
**작업량**: 매우 높음 (3일 이상)
---
### 2.4 🟢 V1 유지 권장 (특수 목적)
| 컴포넌트 | 개수 | 이유 |
|----------|------|------|
| `tax-invoice-list` | 1 | 세금계산서 전용, 재사용 낮음 |
| `mail-recipient-selector` | 1 | 메일 전용, 재사용 낮음 |
| `unified-select` | 5 | → v2-select로 이미 마이그레이션 |
| `unified-date` | 2 | → v2-date로 이미 마이그레이션 |
| `unified-repeater` | 2 | → v2-repeater로 이미 마이그레이션 |
---
## 3. 마이그레이션 우선순위 권장
### 3.1 즉시 처리 (1일 이내)
| 순위 | 컴포넌트 | 개수 | 작업 |
|------|----------|------|------|
| 1 | `split-panel-layout2` | 8 | DB 마이그레이션만 |
| 2 | `repeat-screen-modal` | 7 | DB 마이그레이션만 |
| 3 | `related-data-buttons` | 5 | DB 마이그레이션만 |
| 4 | `autocomplete-search-input` | 50 | → v2-select 변환 |
| 5 | `repeater-field-group` | 24 | → v2-repeater 변환 |
**총: 94개 컴포넌트**
---
### 3.2 단기 처리 (1주 이내)
| 순위 | 컴포넌트 | 개수 | 작업 |
|------|----------|------|------|
| 1 | `unified-list` | 97 | → v2-table-list 확장 또는 변환 |
| 2 | `modal-repeater-table` | 68 | v2-repeater modal 모드 확장 |
**총: 165개 컴포넌트**
---
### 3.3 중기 처리 (2주 이상)
| 순위 | 컴포넌트 | 개수 | 작업 |
|------|----------|------|------|
| 1 | `entity-search-input` | 99 | v2-entity-search 신규 개발 |
| 2 | `selected-items-detail-input` | 83 | v2-selected-items-detail 개발 |
| 3 | `conditional-container` | 53 | v2-conditional-container 개발 |
| 4 | `universal-form-modal` | 26 | v2-universal-form 개발 |
**총: 261개 컴포넌트**
---
## 4. 권장 아키텍처
### 4.1 V2 컴포넌트 통합 계획
```
v2-input ← text-input, number-input, textarea, unified-input ✅ 완료
v2-select ← select-basic, checkbox, radio, autocomplete ⚠️ 진행중
v2-date ← date-input, unified-date ✅ 완료
v2-media ← file-upload, image-widget ✅ 완료
v2-table-list ← table-list, unified-list ⚠️ 확장 필요
v2-repeater ← repeater-field-group, modal-repeater-table,
simple-repeater-table, related-data-buttons ⚠️ 진행중
v2-entity-search ← entity-search-input (신규 개발 필요)
v2-conditional ← conditional-container (신규 개발 필요)
```
---
## 5. 결론
### 즉시 처리 가능 (Renderer/DB만)
- `split-panel-layout2`, `repeat-screen-modal`, `related-data-buttons`: **20개**
- `autocomplete-search-input``v2-select`: **50개**
- `repeater-field-group``v2-repeater`: **24개**
### 통합 검토 필요
- `unified-list``v2-table-list` 확장: **97개**
- `modal-repeater-table``v2-repeater` 확장: **68개**
### 신규 개발 필요
- `entity-search-input`: **99개** (복잡도 높음)
- `selected-items-detail-input`: **83개**
- `conditional-container`: **53개**
- `universal-form-modal`: **26개**
### 유지
- 특수 목적 컴포넌트: **3개** (tax-invoice-list, mail-recipient-selector)
---
## 6. 다음 단계
1. **즉시**: `split-panel-layout2`, `repeat-screen-modal`, `related-data-buttons` DB 마이그레이션
2. **이번 주**: `autocomplete-search-input``v2-select`, `repeater-field-group``v2-repeater` 변환
3. **다음 주**: `unified-list`, `modal-repeater-table` 통합 설계
4. **이후**: `entity-search-input`, `conditional-container` 신규 개발 계획 수립

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,214 @@
# 이미지/파일 저장 방식 가이드
## 개요
WACE 솔루션에서 이미지 및 파일은 **attach_file_info 테이블**에 메타데이터를 저장하고, 실제 파일은 **서버 디스크**에 저장하는 이중 구조를 사용합니다.
---
## 1. 데이터 흐름
```
[사용자 업로드] → [백엔드 API] → [디스크 저장] + [DB 메타데이터 저장]
↓ ↓
/uploads/COMPANY_7/ attach_file_info 테이블
2026/02/06/ (objid, file_path, ...)
1770346704685_5.png
```
### 저장 과정
1. 사용자가 파일 업로드 → `POST /api/files/upload`
2. 백엔드가 파일을 디스크에 저장: `/uploads/{company_code}/{YYYY}/{MM}/{DD}/{timestamp}_{filename}`
3. `attach_file_info` 테이블에 메타데이터 INSERT (objid, file_path, target_objid 등)
4. 비즈니스 테이블의 이미지 컬럼에 **파일 objid** 저장 (예: `item_info.image = '433765011963536400'`)
### 조회 과정
1. 비즈니스 테이블에서 이미지 컬럼 값(objid) 로드
2. `GET /api/files/preview/{objid}` 로 이미지 프리뷰 요청
3. 백엔드가 `attach_file_info`에서 objid로 파일 정보 조회
4. 디스크에서 실제 파일을 읽어 응답
---
## 2. 테이블 구조
### attach_file_info (파일 메타데이터)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| objid | numeric | 파일 고유 ID (PK, 큰 숫자) |
| real_file_name | varchar | 원본 파일명 |
| saved_file_name | varchar | 저장된 파일명 (timestamp_원본명) |
| file_path | varchar | 저장 경로 (/uploads/COMPANY_7/2026/02/06/...) |
| file_ext | varchar | 파일 확장자 |
| file_size | numeric | 파일 크기 (bytes) |
| target_objid | varchar | 연결 대상 (아래 패턴 참조) |
| company_code | varchar | 회사 코드 (멀티테넌시) |
| status | varchar | 상태 (ACTIVE, DELETED) |
| writer | varchar | 업로더 ID |
| regdate | timestamp | 등록일시 |
| is_representative | boolean | 대표 이미지 여부 |
### 비즈니스 테이블 (예: item_info, company_mng)
이미지 컬럼에 `attach_file_info.objid` 값을 문자열로 저장합니다.
```sql
-- item_info.image = '433765011963536400'
-- company_mng.company_image = '413276787660035200'
```
---
## 3. target_objid 패턴
`attach_file_info.target_objid`는 파일이 어디에 연결되어 있는지를 나타냅니다.
| 패턴 | 예시 | 설명 |
|------|------|------|
| 템플릿 모드 | `screen_files:140:comp_z4yffowb:image` | 화면 설계 시 업로드 (screenId:componentId:columnName) |
| 레코드 모드 | `item_info:uuid-xxx:image` | 특정 레코드에 연결 (tableName:recordId:columnName) |
---
## 4. 파일 조회 API
### GET /api/files/preview/{objid}
이미지 프리뷰 (공개 접근 허용).
```
GET /api/files/preview/433765011963536400
→ 200 OK (이미지 바이너리)
```
**주의: objid를 parseInt()로 변환하면 안 됩니다.** JavaScript의 `Number.MAX_SAFE_INTEGER`(9007199254740991)를 초과하는 큰 숫자이므로 **정밀도 손실**이 발생합니다. 반드시 **문자열**로 전달해야 합니다.
```typescript
// 잘못된 방법
const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [parseInt(objid)]);
// → parseInt("433765011963536400") = 433765011963536416 (16 차이!)
// → DB에서 찾을 수 없음 → 404
// 올바른 방법
const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [objid]);
// → PostgreSQL이 문자열 → numeric 자동 캐스팅
```
### GET /api/files/component-files
컴포넌트별 파일 목록 조회 (인증 필요).
```
GET /api/files/component-files?screenId=149&componentId=comp_z4yffowb&tableName=item_info&recordId=uuid-xxx&columnName=image
```
**조회 우선순위:**
1. **데이터 파일**: `target_objid = '{tableName}:{recordId}:{columnName}'` 패턴으로 조회
2. **템플릿 파일**: `target_objid = 'screen_files:{screenId}:{componentId}:{columnName}'` 패턴으로 조회
3. **레코드 컬럼 값 조회 (fallback)**: 위 두 방법으로 파일을 찾지 못하면, 비즈니스 테이블의 레코드에서 해당 컬럼 값(파일 objid)을 읽어 직접 조회
```sql
-- fallback: 레코드의 image 컬럼에 저장된 objid로 직접 조회
SELECT "image" FROM "item_info" WHERE id = $1;
-- → '433765011963536400'
SELECT * FROM attach_file_info WHERE objid = '433765011963536400' AND status = 'ACTIVE';
```
---
## 5. 프론트엔드 컴포넌트
### v2-file-upload (FileUploadComponent.tsx)
현재 사용되는 V2 파일 업로드 컴포넌트입니다.
**파일 경로**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx`
#### 이미지 로드 방식
1. **formData의 컬럼 값으로 로드**: `formData[columnName]`에 파일 objid가 있으면 `/api/files/preview/{objid}`로 이미지 표시
2. **getComponentFiles API로 로드**: target_objid 패턴으로 서버에서 파일 목록 조회
#### 상태 관리
- `uploadedFiles` state: 현재 표시 중인 파일 목록
- `localStorage` 백업: `fileUpload_{componentId}_{columnName}` 키로 저장
- `window.globalFileState`: 전역 파일 상태 (컴포넌트 간 동기화)
#### 등록/수정 모드 구분
- **수정 모드** (isRecordMode=true, recordId 있음): localStorage/서버에서 기존 파일 복원
- **등록 모드** (isRecordMode=false, recordId 없음): localStorage 복원 스킵, 빈 상태로 시작
- **단일 폼 화면** (회사정보 등): `formData[columnName]`의 objid 값으로 이미지 자동 로드
### file-upload (레거시)
**파일 경로**: `frontend/lib/registry/components/file-upload/FileUploadComponent.tsx`
V2MediaRenderer에서 사용하는 레거시 컴포넌트. v2-file-upload와 유사하지만 별도 파일입니다.
### ImageWidget
**파일 경로**: `frontend/components/screen/widgets/types/ImageWidget.tsx`
단순 이미지 표시용 위젯. 파일 업로드 기능은 있으나, `getFullImageUrl()`로 URL을 변환하여 `<img>` 태그로 직접 표시합니다. 파일 관리(목록, 삭제 등) 기능은 없습니다.
---
## 6. 디스크 저장 구조
```
backend-node/uploads/
├── COMPANY_7/ # 회사별 격리
│ ├── 2026/
│ │ ├── 01/
│ │ │ └── 08/
│ │ │ └── 1767863580718_img.jpg
│ │ └── 02/
│ │ └── 06/
│ │ ├── 1770346704685_5.png
│ │ └── 1770352493105_5.png
├── COMPANY_9/
│ └── ...
└── company_*/ # 최고 관리자 전용
└── ...
```
---
## 7. 수정 이력 (2026-02-06)
### parseInt 정밀도 손실 수정
**파일**: `backend-node/src/controllers/fileController.ts`
`attach_file_info.objid``numeric` 타입으로 `433765011963536400` 같은 매우 큰 숫자입니다. JavaScript의 `parseInt()``Number.MAX_SAFE_INTEGER`(약 9 * 10^15)를 초과하면 정밀도 손실이 발생합니다.
| objid (원본) | parseInt 결과 | 차이 |
|:---|:---|:---:|
| 396361999644927100 | 396361999644927104 | -4 |
| 433765011963536400 | 433765011963536384 | +16 |
| 1128460590844245000 | 1128460590844244992 | +8 |
**수정**: `parseInt(objid)``objid` (문자열 직접 전달, 8곳)
### getComponentFiles fallback 추가
**파일**: `backend-node/src/controllers/fileController.ts`
수정 모달에서 이미지가 안 보이는 문제. `target_objid` 패턴이 일치하지 않을 때, 비즈니스 테이블의 레코드 컬럼 값으로 파일을 직접 조회하는 fallback 로직 추가.
### v2-file-upload 등록 모드 파일 잔존 방지
**파일**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx`
연속 등록 시 이전 등록의 이미지가 남아있는 문제. `loadComponentFiles`와 fallback 로직에서 등록 모드(recordId 없음)일 때 파일 복원을 스킵하도록 수정.
### ORDER BY 기본 정렬 추가
**파일**: `backend-node/src/services/tableManagementService.ts`
`sortBy` 파라미터가 없을 때 `ORDER BY created_date DESC`를 기본값으로 적용. 4곳 수정.

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,989 @@
# Multi-Agent 협업 시스템 설계서
> Cursor 에이전트 간 협업을 통한 효율적인 개발 시스템
## 목차
1. [개요](#개요)
2. [아키텍처](#아키텍처)
3. [에이전트 역할 정의](#에이전트-역할-정의)
4. [통신 프로토콜](#통신-프로토콜)
5. [워크플로우](#워크플로우)
6. [프롬프트 템플릿](#프롬프트-템플릿)
7. [MCP 서버 구현](#mcp-서버-구현)
8. [비용 분석](#비용-분석)
9. [한계점 및 해결방안](#한계점-및-해결방안)
---
## 개요
### 문제점: 단일 에이전트의 한계
```
단일 에이전트 문제:
┌─────────────────────────────────────────┐
│ • 컨텍스트 폭발 (50k+ 토큰 → 까먹음) │
│ • 전문성 분산 (모든 영역 얕게 앎) │
│ • 재작업 빈번 (실수, 누락) │
│ • 검증 부재 (크로스체크 없음) │
└─────────────────────────────────────────┘
```
### 해결책: Multi-Agent 협업
```
멀티 에이전트 장점:
┌─────────────────────────────────────────┐
│ • 컨텍스트 분리 (각자 작은 컨텍스트) │
│ • 전문성 집중 (영역별 깊은 이해) │
│ • 크로스체크 (서로 검증) │
│ • 병렬 처리 (동시 작업) │
└─────────────────────────────────────────┘
```
### 모델 티어링 전략
| 에이전트 | 모델 | 역할 | 비용 |
|----------|------|------|------|
| Agent A (PM) | Claude Opus 4.5 | 분석, 계획, 조율 | 높음 |
| Agent B (Backend) | Claude Sonnet | 백엔드 구현 | 낮음 |
| Agent C (DB) | Claude Sonnet | DB/쿼리 담당 | 낮음 |
| Agent D (Frontend) | Claude Sonnet | 프론트 구현 | 낮음 |
**예상 비용 절감: 50-60%**
---
## 아키텍처
### 전체 구조
```
┌─────────────┐
│ USER │
└──────┬──────┘
┌───────────────────────┐
│ Agent A (PM) │
│ Claude Opus 4.5 │
│ │
│ • 사용자 의도 파악 │
│ • 작업 분배 │
│ • 결과 통합 │
│ • 품질 검증 │
└───────────┬───────────┘
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Agent B │ │ Agent C │ │ Agent D │
│ (Backend) │ │ (Database) │ │ (Frontend) │
│ Sonnet │ │ Sonnet │ │ Sonnet │
│ │ │ │ │ │
│ • API 설계/구현 │ │ • 스키마 설계 │ │ • 컴포넌트 구현 │
│ • 서비스 로직 │ │ • 쿼리 작성 │ │ • 페이지 구현 │
│ • 라우팅 │ │ • 마이그레이션 │ │ • 스타일링 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└─────────────────┴─────────────────┘
┌───────────────────────┐
│ MCP Orchestrator │
│ │
│ • 메시지 라우팅 │
│ • 병렬 실행 │
│ • 결과 수집 │
└───────────────────────┘
```
### 폴더별 담당 영역
| 에이전트 | 담당 폴더 | 파일 유형 |
|----------|-----------|-----------|
| Agent B (Backend) | `backend-node/src/` | `.ts`, `.js` |
| Agent C (DB) | `src/com/pms/mapper/`, `db/` | `.xml`, `.sql` |
| Agent D (Frontend) | `frontend/` | `.tsx`, `.ts`, `.css` |
| Agent A (PM) | 전체 조율 | 모든 파일 (읽기 위주) |
---
## 에이전트 역할 정의
### Agent A (PM) - 프로젝트 매니저
```yaml
역할: 전체 조율 및 사용자 인터페이스
모델: Claude Opus 4.5
핵심 책임:
의도 파악:
- 사용자 요청 분석
- 모호한 요청 명확화
- 숨겨진 요구사항 발굴
작업 분배:
- 작업을 세부 태스크로 분해
- 적절한 에이전트에게 할당
- 우선순위 및 의존성 결정
품질 관리:
- 결과물 검증
- 일관성 체크
- 충돌 해결
통합:
- 개별 결과물 취합
- 최종 결과 생성
- 사용자에게 보고
하지 않는 것:
- 직접 코드 구현 (전문가에게 위임)
- 특정 영역 깊이 분석 (전문가에게 요청)
```
### Agent B (Backend) - 백엔드 전문가
```yaml
역할: API 및 서버 로직 담당
모델: Claude Sonnet
담당 영역:
폴더:
- backend-node/src/controllers/
- backend-node/src/services/
- backend-node/src/routes/
- backend-node/src/middleware/
- backend-node/src/utils/
작업:
- REST API 엔드포인트 설계/구현
- 비즈니스 로직 구현
- 미들웨어 작성
- 에러 핸들링
- 인증/인가 로직
담당 아닌 것:
- frontend/ 폴더 (Agent D 담당)
- SQL 쿼리 직접 작성 (Agent C에게 요청)
- DB 스키마 변경 (Agent C 담당)
협업 필요 시:
- DB 쿼리 필요 → Agent C에게 요청
- 프론트 연동 문제 → Agent D와 협의
```
### Agent C (Database) - DB 전문가
```yaml
역할: 데이터베이스 및 쿼리 담당
모델: Claude Sonnet
담당 영역:
폴더:
- src/com/pms/mapper/
- db/
- backend-node/src/database/
작업:
- 테이블 스키마 설계
- MyBatis 매퍼 XML 작성
- SQL 쿼리 최적화
- 인덱스 설계
- 마이그레이션 스크립트
담당 아닌 것:
- API 로직 (Agent B 담당)
- 프론트엔드 (Agent D 담당)
- 비즈니스 로직 판단 (Agent A에게 확인)
협업 필요 시:
- API에서 필요한 데이터 구조 → Agent B와 협의
- 쿼리 결과 사용법 → Agent B에게 전달
```
### Agent D (Frontend) - 프론트엔드 전문가
```yaml
역할: UI/UX 및 클라이언트 로직 담당
모델: Claude Sonnet
담당 영역:
폴더:
- frontend/components/
- frontend/pages/
- frontend/lib/
- frontend/hooks/
- frontend/styles/
작업:
- React 컴포넌트 구현
- 페이지 레이아웃
- 상태 관리
- API 연동 (호출)
- 스타일링
담당 아닌 것:
- API 구현 (Agent B 담당)
- DB 쿼리 (Agent C 담당)
- API 스펙 결정 (Agent A/B와 협의)
협업 필요 시:
- API 엔드포인트 필요 → Agent B에게 요청
- 데이터 구조 확인 → Agent C에게 문의
```
---
## 통신 프로토콜
### 메시지 포맷
```typescript
// 요청 메시지
interface TaskRequest {
id: string; // 고유 ID (예: "task-001")
from: 'A' | 'B' | 'C' | 'D'; // 발신자
to: 'A' | 'B' | 'C' | 'D'; // 수신자
type: 'info_request' | 'work_request' | 'question';
priority: 'high' | 'medium' | 'low';
content: {
task: string; // 작업 내용
context?: string; // 배경 정보
expected_output?: string; // 기대 결과
depends_on?: string[]; // 선행 작업 ID
};
timestamp: string;
}
// 응답 메시지
interface TaskResponse {
id: string; // 요청 ID와 매칭
from: 'A' | 'B' | 'C' | 'D';
to: 'A' | 'B' | 'C' | 'D';
status: 'success' | 'partial' | 'failed' | 'need_clarification';
confidence: 'high' | 'medium' | 'low';
result?: {
summary: string; // 한 줄 요약
details: string; // 상세 내용
files_affected?: string[]; // 영향받는 파일
code_changes?: CodeChange[]; // 코드 변경사항
};
// 메타 정보
scope_violations?: string[]; // 스코프 벗어난 요청
dependencies?: string[]; // 필요한 선행 작업
side_effects?: string[]; // 부작용
alternatives?: string[]; // 대안
// 추가 요청
questions?: string[]; // 명확화 필요
needs_from_others?: {
agent: 'A' | 'B' | 'C' | 'D';
request: string;
}[];
timestamp: string;
}
// 코드 변경
interface CodeChange {
file: string;
action: 'create' | 'modify' | 'delete';
content?: string; // 전체 코드 또는 diff
line_start?: number;
line_end?: number;
}
```
### 상태 코드 정의
| 상태 | 의미 | 후속 조치 |
|------|------|-----------|
| `success` | 완전히 완료 | 결과 사용 가능 |
| `partial` | 부분 완료 | 추가 작업 필요 |
| `failed` | 실패 | 에러 확인 후 재시도 |
| `need_clarification` | 명확화 필요 | 질문에 답변 후 재요청 |
### 확신도 정의
| 확신도 | 의미 | 권장 조치 |
|--------|------|-----------|
| `high` | 확실함 | 바로 적용 가능 |
| `medium` | 대체로 맞음 | 검토 후 적용 |
| `low` | 추측임 | 반드시 검증 필요 |
---
## 워크플로우
### Phase 1: 정보 수집
```
┌─────────────────────────────────────────────────────────────┐
│ Phase 1: 정보 수집 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. User → Agent A: "주문 관리 기능 만들어줘" │
│ │
│ 2. Agent A 분석: │
│ - 기능 범위 파악 │
│ - 필요한 정보 식별 │
│ - 정보 수집 요청 생성 │
│ │
│ 3. Agent A → B, C, D (병렬): │
│ - B에게: "현재 order 관련 API 구조 분석해줘" │
│ - C에게: "orders 테이블 스키마 알려줘" │
│ - D에게: "주문 관련 컴포넌트 현황 알려줘" │
│ │
│ 4. B, C, D → Agent A (응답): │
│ - B: API 현황 보고 │
│ - C: 스키마 정보 보고 │
│ - D: 컴포넌트 현황 보고 │
│ │
│ 5. Agent A: 정보 취합 및 계획 수립 │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Phase 2: 작업 분배
```
┌─────────────────────────────────────────────────────────────┐
│ Phase 2: 작업 분배 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Agent A: 종합 계획 수립 │
│ ┌─────────────────────────────────────────┐ │
│ │ 분석 결과: │ │
│ │ - API에 pagination 추가 필요 │ │
│ │ - DB는 현재 구조 유지 │ │
│ │ - 프론트 무한스크롤 → 페이지네이션 │ │
│ │ │ │
│ │ 작업 순서: │ │
│ │ 1. C: 페이징 쿼리 준비 │ │
│ │ 2. B: API 수정 (C 결과 의존) │ │
│ │ 3. D: 프론트 수정 (B 결과 의존) │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 2. Agent A → B, C, D: 작업 할당 │
│ - C에게: "cursor 기반 페이징 쿼리 작성" │
│ - B에게: "GET /api/orders에 pagination 추가" (C 대기) │
│ - D에게: "Pagination 컴포넌트 적용" (B 대기) │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Phase 3: 실행 및 통합
```
┌─────────────────────────────────────────────────────────────┐
│ Phase 3: 실행 및 통합 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 순차/병렬 실행: │
│ - C: 쿼리 작성 → 완료 보고 │
│ - B: API 수정 (C 완료 후) → 완료 보고 │
│ - D: 프론트 수정 (B 완료 후) → 완료 보고 │
│ │
│ 2. Agent A: 결과 검증 │
│ - 일관성 체크 │
│ - 누락 확인 │
│ - 충돌 해결 │
│ │
│ 3. Agent A → User: 최종 보고 │
│ ┌─────────────────────────────────────────┐ │
│ │ 완료된 작업: │ │
│ │ ✅ orders.xml - 페이징 쿼리 추가 │ │
│ │ ✅ OrderController.ts - pagination 적용 │ │
│ │ ✅ OrderListPage.tsx - UI 수정 │ │
│ │ │ │
│ │ 테스트 필요: │ │
│ │ - GET /api/orders?page=1&limit=10 │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## 프롬프트 템플릿
### Agent A (PM) 시스템 프롬프트
```markdown
# 역할
너는 PM(Project Manager) 에이전트야.
사용자 요청을 분석하고, 전문가 에이전트들(Backend, DB, Frontend)에게
작업을 분배하고, 결과를 통합해서 최종 결과물을 만들어.
# 사용 가능한 도구
- ask_backend_agent: 백엔드 전문가에게 질문/작업 요청
- ask_db_agent: DB 전문가에게 질문/작업 요청
- ask_frontend_agent: 프론트 전문가에게 질문/작업 요청
- parallel_ask: 여러 전문가에게 동시에 요청
# 작업 프로세스
## Phase 1: 분석
1. 사용자 요청 분석
2. 필요한 정보 식별
3. 정보 수집 요청 (parallel_ask 활용)
## Phase 2: 계획
1. 수집된 정보 분석
2. 작업 분해 및 의존성 파악
3. 우선순위 결정
4. 작업 분배 계획 수립
## Phase 3: 실행
1. 의존성 순서대로 작업 요청
2. 결과 검증
3. 필요시 재요청
## Phase 4: 통합
1. 모든 결과 취합
2. 일관성 검증
3. 사용자에게 보고
# 작업 분배 기준
- Backend Agent: API, 서비스 로직, 라우팅 (backend-node/)
- DB Agent: 스키마, 쿼리, 마이그레이션 (mapper/, db/)
- Frontend Agent: 컴포넌트, 페이지, 스타일 (frontend/)
# 판단 기준
- 불확실하면 사용자에게 물어봐
- 에이전트 결과가 이상하면 재요청
- 영향 범위 크면 사용자 확인
- 충돌 시 더 안전한 방향 선택
# 응답 형식
작업 분배 시:
```json
{
"phase": "info_gathering | work_distribution | integration",
"reasoning": "왜 이렇게 분배하는지",
"tasks": [
{
"agent": "backend | db | frontend",
"priority": 1,
"task": "구체적인 작업 내용",
"depends_on": [],
"expected_output": "기대 결과"
}
]
}
```
최종 보고 시:
```json
{
"summary": "한 줄 요약",
"completed_tasks": ["완료된 작업들"],
"files_changed": ["변경된 파일들"],
"next_steps": ["다음 단계 (있다면)"],
"test_instructions": ["테스트 방법"]
}
```
```
### Agent B (Backend) 시스템 프롬프트
```markdown
# 역할
너는 Backend 전문가 에이전트야.
backend-node/ 폴더의 API, 서비스, 라우팅을 담당해.
# 담당 영역 (이것만!)
- backend-node/src/controllers/
- backend-node/src/services/
- backend-node/src/routes/
- backend-node/src/middleware/
- backend-node/src/utils/
# 담당 아닌 것 (절대 건들지 마)
- frontend/ → Frontend Agent 담당
- src/com/pms/mapper/ → DB Agent 담당
- SQL 쿼리 직접 작성 → DB Agent에게 요청
# 코드 작성 규칙
1. TypeScript 사용
2. 에러 핸들링 필수
3. 주석은 한글로
4. 기존 코드 스타일 따르기
5. ... 생략 없이 완전한 코드
# 응답 형식
```json
{
"status": "success | partial | failed | need_clarification",
"confidence": "high | medium | low",
"result": {
"summary": "한 줄 요약",
"details": "상세 설명",
"files_affected": ["파일 경로들"],
"code_changes": [
{
"file": "경로",
"action": "create | modify | delete",
"content": "전체 코드"
}
]
},
"needs_from_others": [
{"agent": "db", "request": "필요한 것"}
],
"side_effects": ["영향받는 것들"],
"questions": ["명확하지 않은 것들"]
}
```
# 협업 규칙
1. 내 영역 아니면 즉시 보고 (scope_violation)
2. 확실하지 않으면 confidence: "low"
3. 다른 에이전트 필요하면 needs_from_others에 명시
4. 부작용 있으면 반드시 보고
```
### Agent C (Database) 시스템 프롬프트
```markdown
# 역할
너는 Database 전문가 에이전트야.
DB 스키마, 쿼리, 마이그레이션을 담당해.
# 담당 영역 (이것만!)
- src/com/pms/mapper/ (MyBatis XML)
- db/ (스키마, 마이그레이션)
- backend-node/src/database/
# 담당 아닌 것 (절대 건들지 마)
- API 로직 → Backend Agent 담당
- 프론트엔드 → Frontend Agent 담당
- 비즈니스 로직 판단 → PM에게 확인
# 코드 작성 규칙
1. PostgreSQL 문법 사용
2. 파라미터 바인딩 (#{}) 필수 - SQL 인젝션 방지
3. 인덱스 고려
4. 성능 최적화 (EXPLAIN 결과 고려)
# MyBatis 매퍼 규칙
```xml
<!-- 파라미터 바인딩 (안전) -->
WHERE id = #{id}
<!-- 동적 쿼리 -->
<if test="name != null and name != ''">
AND name LIKE '%' || #{name} || '%'
</if>
<!-- 페이징 -->
LIMIT #{limit} OFFSET #{offset}
```
# 응답 형식
```json
{
"status": "success | partial | failed | need_clarification",
"confidence": "high | medium | low",
"result": {
"summary": "한 줄 요약",
"details": "상세 설명",
"schema_info": {
"tables": ["관련 테이블"],
"columns": ["주요 컬럼"],
"indexes": ["인덱스"]
},
"code_changes": [
{
"file": "경로",
"action": "create | modify",
"content": "쿼리/스키마"
}
]
},
"performance_notes": ["성능 관련 참고사항"],
"questions": ["명확하지 않은 것들"]
}
```
```
### Agent D (Frontend) 시스템 프롬프트
```markdown
# 역할
너는 Frontend 전문가 에이전트야.
React/Next.js 기반 UI 구현을 담당해.
# 담당 영역 (이것만!)
- frontend/components/
- frontend/pages/ (또는 app/)
- frontend/lib/
- frontend/hooks/
- frontend/styles/
# 담당 아닌 것 (절대 건들지 마)
- backend-node/ → Backend Agent 담당
- DB 관련 → DB Agent 담당
- API 스펙 결정 → PM/Backend와 협의
# 코드 작성 규칙
1. TypeScript 사용
2. React 함수형 컴포넌트
3. 커스텀 훅 활용
4. 주석은 한글로
5. Tailwind CSS 또는 기존 스타일 시스템 따르기
# API 호출 규칙
- 절대 fetch 직접 사용 금지
- lib/api/ 클라이언트 사용
- 에러 핸들링 필수
# 응답 형식
```json
{
"status": "success | partial | failed | need_clarification",
"confidence": "high | medium | low",
"result": {
"summary": "한 줄 요약",
"details": "상세 설명",
"components_affected": ["컴포넌트 목록"],
"code_changes": [
{
"file": "경로",
"action": "create | modify",
"content": "전체 코드"
}
]
},
"needs_from_others": [
{"agent": "backend", "request": "필요한 API"}
],
"ui_notes": ["UX 관련 참고사항"],
"questions": ["명확하지 않은 것들"]
}
```
```
---
## MCP 서버 구현
### 프로젝트 구조
```
mcp-agent-orchestrator/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # 메인 서버
│ ├── agents/
│ │ ├── types.ts # 타입 정의
│ │ ├── pm.ts # PM 에이전트 프롬프트
│ │ ├── backend.ts # Backend 에이전트 프롬프트
│ │ ├── database.ts # DB 에이전트 프롬프트
│ │ └── frontend.ts # Frontend 에이전트 프롬프트
│ └── utils/
│ └── logger.ts # 로깅
└── build/
└── index.js # 컴파일된 파일
```
### 핵심 코드
```typescript
// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import Anthropic from "@anthropic-ai/sdk";
import { PM_PROMPT, BACKEND_PROMPT, DB_PROMPT, FRONTEND_PROMPT } from "./agents";
const server = new Server({
name: "agent-orchestrator",
version: "1.0.0",
});
const anthropic = new Anthropic();
// 에이전트별 설정
const AGENT_CONFIG = {
pm: { model: "claude-opus-4-5-20250214", prompt: PM_PROMPT },
backend: { model: "claude-sonnet-4-20250514", prompt: BACKEND_PROMPT },
db: { model: "claude-sonnet-4-20250514", prompt: DB_PROMPT },
frontend: { model: "claude-sonnet-4-20250514", prompt: FRONTEND_PROMPT },
};
// 도구 목록
server.setRequestHandler("tools/list", async () => ({
tools: [
{
name: "ask_backend_agent",
description: "백엔드 전문가에게 질문하거나 작업 요청",
inputSchema: {
type: "object",
properties: {
task: { type: "string", description: "작업 내용" },
context: { type: "string", description: "배경 정보 (선택)" },
},
required: ["task"],
},
},
{
name: "ask_db_agent",
description: "DB 전문가에게 질문하거나 작업 요청",
inputSchema: {
type: "object",
properties: {
task: { type: "string", description: "작업 내용" },
context: { type: "string", description: "배경 정보 (선택)" },
},
required: ["task"],
},
},
{
name: "ask_frontend_agent",
description: "프론트엔드 전문가에게 질문하거나 작업 요청",
inputSchema: {
type: "object",
properties: {
task: { type: "string", description: "작업 내용" },
context: { type: "string", description: "배경 정보 (선택)" },
},
required: ["task"],
},
},
{
name: "parallel_ask",
description: "여러 전문가에게 동시에 질문 (병렬 실행)",
inputSchema: {
type: "object",
properties: {
requests: {
type: "array",
items: {
type: "object",
properties: {
agent: {
type: "string",
enum: ["backend", "db", "frontend"]
},
task: { type: "string" },
context: { type: "string" },
},
required: ["agent", "task"],
},
},
},
required: ["requests"],
},
},
],
}));
// 도구 실행
server.setRequestHandler("tools/call", async (request) => {
const { name, arguments: args } = request.params;
const callAgent = async (agentType: string, task: string, context?: string) => {
const config = AGENT_CONFIG[agentType];
const response = await anthropic.messages.create({
model: config.model,
max_tokens: 8192,
system: config.prompt,
messages: [
{
role: "user",
content: context ? `${task}\n\n배경 정보:\n${context}` : task,
},
],
});
return response.content[0].text;
};
switch (name) {
case "ask_backend_agent":
return {
content: [
{ type: "text", text: await callAgent("backend", args.task, args.context) },
],
};
case "ask_db_agent":
return {
content: [
{ type: "text", text: await callAgent("db", args.task, args.context) },
],
};
case "ask_frontend_agent":
return {
content: [
{ type: "text", text: await callAgent("frontend", args.task, args.context) },
],
};
case "parallel_ask":
const results = await Promise.all(
args.requests.map(async (req) => ({
agent: req.agent,
result: await callAgent(req.agent, req.task, req.context),
}))
);
return {
content: [
{ type: "text", text: JSON.stringify(results, null, 2) },
],
};
default:
throw new Error(`Unknown tool: ${name}`);
}
});
// 서버 시작
const transport = new StdioServerTransport();
await server.connect(transport);
```
### Cursor 설정
```json
// .cursor/mcp.json
{
"mcpServers": {
"agent-orchestrator": {
"command": "node",
"args": ["C:/Users/defaultuser0/mcp-agent-orchestrator/build/index.js"],
"env": {
"ANTHROPIC_API_KEY": "your-api-key-here"
}
}
}
}
```
---
## 비용 분석
### 토큰 사용량 비교
| 시나리오 | 단일 에이전트 | 멀티 에이전트 | 절감 |
|----------|--------------|--------------|------|
| 기능 1개 추가 | 100,000 토큰 | 60,000 토큰 | 40% |
| 시스템 리팩토링 | 300,000 토큰 | 150,000 토큰 | 50% |
| 새 모듈 개발 | 500,000 토큰 | 200,000 토큰 | 60% |
### 비용 계산 (예시)
```
단일 에이전트 (전부 Opus):
- 300,000 토큰 × $15/M = $4.50
멀티 에이전트 (Opus PM + Sonnet Workers):
- PM (Opus): 50,000 토큰 × $15/M = $0.75
- Workers (Sonnet): 100,000 토큰 × $3/M = $0.30
- 총: $1.05
절감: $4.50 - $1.05 = $3.45 (76% 절감!)
```
### ROI 분석
```
초기 투자:
- MCP 서버 개발: 4-6시간
- 프롬프트 튜닝: 2-4시간
- 테스트: 2시간
- 총: 8-12시간
회수:
- 대규모 작업당 $3-5 절감
- 재작업 시간 70% 감소
- 품질 30% 향상
손익분기점: 대규모 작업 3-5회
```
---
## 한계점 및 해결방안
### 현재 한계
| 한계 | 설명 | 해결방안 |
|------|------|----------|
| 완전 자동화 불가 | Cursor 에이전트 간 직접 통신 없음 | MCP 서버로 우회 |
| 파일 읽기 제한 | 각 에이전트가 모든 파일 접근 어려움 | 컨텍스트에 필요한 정보 전달 |
| 실시간 동기화 | 변경사항 즉시 반영 어려움 | 명시적 갱신 요청 |
| 에러 복구 | 자동 롤백 메커니즘 없음 | 수동 복구 또는 git 활용 |
### 향후 개선 방향
1. **파일 시스템 연동**
- MCP 서버에 파일 읽기/쓰기 도구 추가
- 에이전트가 직접 코드 확인 가능
2. **결과 자동 적용**
- 코드 변경사항 자동 적용
- git 커밋 자동화
3. **피드백 루프**
- 테스트 자동 실행
- 실패 시 자동 재시도
4. **히스토리 관리**
- 대화 이력 저장
- 컨텍스트 캐싱
---
## 체크리스트
### 구현 전 준비
- [ ] Node.js 18+ 설치
- [ ] Anthropic API 키 발급
- [ ] 프로젝트 폴더 생성
### MCP 서버 구현
- [ ] package.json 설정
- [ ] TypeScript 설정
- [ ] 기본 서버 구조
- [ ] 도구 정의 (4개)
- [ ] 에이전트 프롬프트 작성
- [ ] 빌드 및 테스트
### Cursor 연동
- [ ] mcp.json 설정
- [ ] Cursor 재시작
- [ ] 도구 호출 테스트
- [ ] 실제 작업 테스트
### 튜닝
- [ ] 프롬프트 개선
- [ ] 에러 핸들링 강화
- [ ] 로깅 추가
- [ ] 성능 최적화
---
## 참고 자료
- [MCP SDK 문서](https://modelcontextprotocol.io/)
- [Anthropic API 문서](https://docs.anthropic.com/)
- [CrewAI](https://github.com/joaomdmoura/crewAI) - 멀티에이전트 프레임워크 참고
- [AutoGen](https://github.com/microsoft/autogen) - Microsoft 멀티에이전트 참고
---
*작성일: 2026-02-05*
*버전: 1.0*

View File

@ -0,0 +1,331 @@
# 화면 전체 분석 보고서
> **분석 대상**: `/Users/kimjuseok/Downloads/화면개발 8` 폴더 내 핵심 업무 화면
> **분석 기준**: 메뉴별 분류, 3개 이상 재활용 가능한 컴포넌트 식별
> **분석 일자**: 2026-01-30
---
## 1. 현재 사용 중인 V2 컴포넌트 목록
> **중요**: v2- 접두사가 붙은 컴포넌트만 사용합니다.
### 입력 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-input` | V2 입력 | 텍스트, 숫자, 비밀번호, 이메일 등 입력 |
| `v2-select` | V2 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 |
| `v2-date` | V2 날짜 | 날짜, 시간, 날짜범위 입력 |
### 표시 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-text-display` | 텍스트 표시 | 라벨, 텍스트 표시 |
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 |
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수 등 집계 표시 |
### 테이블/데이터 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-table-list` | 테이블 리스트 | 데이터 테이블 표시, 페이지네이션, 정렬, 필터 |
| `v2-table-search-widget` | 검색 필터 | 화면 내 테이블 검색/필터/그룹 기능 |
| `v2-pivot-grid` | 피벗 그리드 | 다차원 데이터 분석 (피벗 테이블) |
### 레이아웃 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 레이아웃 |
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 배치 |
| `v2-section-card` | Section Card | 제목/테두리가 있는 그룹화 컨테이너 |
| `v2-section-paper` | Section Paper | 배경색 기반 미니멀 그룹화 컨테이너 |
| `v2-divider-line` | 구분선 | 영역 구분 |
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 내부 컴포넌트 반복 렌더링 |
| `v2-repeater` | 리피터 | 반복 컨트롤 |
### 액션/기타 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 버튼 |
| `v2-numbering-rule` | 채번규칙 | 자동 코드/번호 생성 |
| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 |
| `v2-location-swap-selector` | 위치 교환 선택기 | 위치 교환 기능 |
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 |
| `v2-media` | 미디어 | 미디어 표시 |
**총 23개 V2 컴포넌트**
---
## 2. 화면 분류 (메뉴별)
### 01. 기준정보 (master-data)
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|--------|--------|------|----------|
| 회사정보 | 회사정보.html | 검색+테이블 | ✅ 완전 |
| 부서정보 | 부서정보.html | 검색+테이블 | ✅ 완전 |
| 품목정보 | 품목정보.html | 검색+테이블+그룹화 | ⚠️ 그룹화 미지원 |
| BOM관리 | BOM관리.html | 분할패널+트리 | ⚠️ 트리뷰 미지원 |
| 공정정보관리 | 공정정보관리.html | 분할패널+테이블 | ✅ 완전 |
| 공정작업기준 | 공정작업기준관리.html | 검색+테이블 | ✅ 완전 |
| 품목라우팅 | 품목라우팅관리.html | 분할패널+테이블 | ✅ 완전 |
### 02. 영업관리 (sales)
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|--------|--------|------|----------|
| 수주관리 | 수주관리.html | 분할패널+테이블 | ✅ 완전 |
| 견적관리 | 견적관리.html | 분할패널+테이블 | ✅ 완전 |
| 거래처관리 | 거래처관리.html | 분할패널+탭+그룹화 | ⚠️ 그룹화 미지원 |
| 판매품목정보 | 판매품목정보.html | 검색+테이블 | ✅ 완전 |
| 출하계획관리 | 출하계획관리.html | 검색+테이블 | ✅ 완전 |
### 03. 생산관리 (production)
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|--------|--------|------|----------|
| 생산계획관리 | 생산계획관리.html | 분할패널+탭+타임라인 | ❌ 타임라인 미지원 |
| 생산관리 | 생산관리.html | 검색+테이블 | ✅ 완전 |
| 생산실적관리 | 생산실적관리.html | 검색+테이블 | ✅ 완전 |
| 작업지시 | 작업지시.html | 탭+그룹화테이블+분할패널 | ⚠️ 그룹화 미지원 |
| 공정관리 | 공정관리.html | 분할패널+테이블 | ✅ 완전 |
### 04. 구매관리 (purchase)
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|--------|--------|------|----------|
| 발주관리 | 발주관리.html | 검색+테이블 | ✅ 완전 |
| 공급업체관리 | 공급업체관리.html | 검색+테이블 | ✅ 완전 |
| 구매입고 | pages/구매입고.html | 검색+테이블 | ✅ 완전 |
### 05. 설비관리 (equipment)
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|--------|--------|------|----------|
| 설비정보 | 설비정보.html | 분할패널+카드+탭 | ✅ v2-card-display 활용 |
### 06. 물류관리 (logistics)
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|--------|--------|------|----------|
| 창고관리 | 창고관리.html | 모바일앱스타일+iframe | ❌ 별도개발 필요 |
| 창고정보관리 | 창고정보관리.html | 검색+테이블 | ✅ 완전 |
| 입출고관리 | 입출고관리.html | 검색+테이블+그룹화 | ⚠️ 그룹화 미지원 |
| 재고현황 | 재고현황.html | 검색+테이블 | ✅ 완전 |
### 07. 품질관리 (quality)
| 화면명 | 파일명 | 패턴 | 구현 가능 |
|--------|--------|------|----------|
| 검사기준 | 검사기준.html | 검색+테이블 | ✅ 완전 |
| 검사정보관리 | 검사정보관리.html | 탭+테이블 | ✅ 완전 |
| 검사장비관리 | 검사장비관리.html | 검색+테이블 | ✅ 완전 |
| 불량관리 | 불량관리.html | 검색+테이블 | ✅ 완전 |
| 클레임관리 | 클레임관리.html | 검색+테이블 | ✅ 완전 |
---
## 3. 화면 UI 패턴 분석
### 패턴 A: 검색 + 테이블 (가장 기본)
**해당 화면**: 약 60% (15개 이상)
**사용 컴포넌트**:
- `v2-table-search-widget`: 검색 필터
- `v2-table-list`: 데이터 테이블
```
┌─────────────────────────────────────────┐
│ [검색필드들...] [조회] [엑셀] │ ← v2-table-search-widget
├─────────────────────────────────────────┤
│ 테이블 제목 [신규등록] [삭제] │
│ ────────────────────────────────────── │
│ □ | 코드 | 이름 | 상태 | 등록일 | │ ← v2-table-list
│ □ | A001 | 테스트| 사용 | 2026-01-30 | │
└─────────────────────────────────────────┘
```
### 패턴 B: 분할 패널 (마스터-디테일)
**해당 화면**: 약 25% (8개)
**사용 컴포넌트**:
- `v2-split-panel-layout`: 좌우 분할
- `v2-table-list`: 마스터/디테일 테이블
- `v2-tabs-widget`: 상세 탭 (선택)
```
┌──────────────────┬──────────────────────┐
│ 마스터 리스트 │ 상세 정보 / 탭 │
│ ─────────────── │ ┌────┬────┬────┐ │
│ □ A001 제품A │ │기본│이력│첨부│ │
│ □ A002 제품B ← │ └────┴────┴────┘ │
│ □ A003 제품C │ [테이블 or 폼] │
└──────────────────┴──────────────────────┘
```
### 패턴 C: 탭 + 테이블
**해당 화면**: 약 10% (3개)
**사용 컴포넌트**:
- `v2-tabs-widget`: 탭 전환
- `v2-table-list`: 탭별 테이블
```
┌─────────────────────────────────────────┐
│ [탭1] [탭2] [탭3] │
├─────────────────────────────────────────┤
│ [테이블 영역] │
└─────────────────────────────────────────┘
```
### 패턴 D: 특수 UI
**해당 화면**: 약 5% (2개)
- 생산계획관리: 타임라인/간트 차트 → **v2-timeline 미존재**
- 창고관리: 모바일 앱 스타일 → **별도 개발 필요**
---
## 4. 신규 컴포넌트 분석 (3개 이상 재활용 기준)
### 4.1 v2-grouped-table (그룹화 테이블)
**재활용 화면 수**: 5개 이상 ✅
| 화면 | 그룹화 기준 |
|------|------------|
| 품목정보 | 품목구분, 카테고리 |
| 거래처관리 | 거래처유형, 지역 |
| 작업지시 | 작업일자, 공정 |
| 입출고관리 | 입출고구분, 창고 |
| 견적관리 | 상태, 거래처 |
**기능 요구사항**:
- 특정 컬럼 기준 그룹핑
- 그룹 접기/펼치기
- 그룹 헤더에 집계 표시
- 다중 그룹핑 지원
**구현 복잡도**: 중
### 4.2 v2-tree-view (트리 뷰)
**재활용 화면 수**: 3개 ✅
| 화면 | 트리 용도 |
|------|----------|
| BOM관리 | BOM 구조 (정전개/역전개) |
| 부서정보 | 조직도 |
| 메뉴관리 | 메뉴 계층 |
**기능 요구사항**:
- 노드 접기/펼치기
- 드래그앤드롭 (선택)
- 정전개/역전개 전환
- 노드 선택 이벤트
**구현 복잡도**: 중상
### 4.3 v2-timeline-scheduler (타임라인)
**재활용 화면 수**: 1~2개 (기준 미달)
| 화면 | 용도 |
|------|------|
| 생산계획관리 | 간트 차트 |
| 설비 가동 현황 | 타임라인 |
**기능 요구사항**:
- 시간축 기반 배치
- 드래그로 일정 변경
- 공정별 색상 구분
- 줌 인/아웃
**구현 복잡도**: 상
> **참고**: 3개 미만이므로 우선순위 하향
---
## 5. 컴포넌트 커버리지
### 현재 V2 컴포넌트로 구현 가능
```
┌─────────────────────────────────────────────────┐
│ 17개 화면 (65%) │
│ - 기본 검색 + 테이블 패턴 │
│ - 분할 패널 │
│ - 탭 전환 │
│ - 카드 디스플레이 │
└─────────────────────────────────────────────────┘
```
### v2-grouped-table 개발 후
```
┌─────────────────────────────────────────────────┐
│ +5개 화면 (22개, 85%) │
│ - 품목정보, 거래처관리, 작업지시 │
│ - 입출고관리, 견적관리 │
└─────────────────────────────────────────────────┘
```
### v2-tree-view 개발 후
```
┌─────────────────────────────────────────────────┐
│ +2개 화면 (24개, 92%) │
│ - BOM관리, 부서정보(계층) │
└─────────────────────────────────────────────────┘
```
### 별도 개발 필요
```
┌─────────────────────────────────────────────────┐
│ 2개 화면 (8%) │
│ - 생산계획관리 (타임라인) │
│ - 창고관리 (모바일 앱 스타일) │
└─────────────────────────────────────────────────┘
```
---
## 6. 신규 컴포넌트 개발 우선순위
| 순위 | 컴포넌트 | 재활용 화면 수 | 복잡도 | ROI |
|------|----------|--------------|--------|-----|
| 1 | v2-grouped-table | 5+ | 중 | ⭐⭐⭐⭐⭐ |
| 2 | v2-tree-view | 3 | 중상 | ⭐⭐⭐⭐ |
| 3 | v2-timeline-scheduler | 1~2 | 상 | ⭐⭐ |
---
## 7. 권장 구현 전략
### Phase 1: 즉시 구현 (현재 V2 컴포넌트)
- 회사정보, 부서정보
- 발주관리, 공급업체관리
- 검사기준, 검사장비관리, 불량관리
- 창고정보관리, 재고현황
- 공정작업기준관리
- 수주관리, 견적관리, 공정관리
- 설비정보 (v2-card-display 활용)
- 검사정보관리
### Phase 2: v2-grouped-table 개발 후
- 품목정보, 거래처관리, 입출고관리
- 작업지시
### Phase 3: v2-tree-view 개발 후
- BOM관리
- 부서정보 (계층 뷰)
### Phase 4: 개별 개발
- 생산계획관리 (타임라인)
- 창고관리 (모바일 스타일)
---
## 8. 요약
| 항목 | 수치 |
|------|------|
| 전체 분석 화면 수 | 26개 |
| 현재 즉시 구현 가능 | 17개 (65%) |
| v2-grouped-table 추가 시 | 22개 (85%) |
| v2-tree-view 추가 시 | 24개 (92%) |
| 별도 개발 필요 | 2개 (8%) |
**핵심 결론**:
1. **현재 V2 컴포넌트**로 65% 화면 구현 가능
2. **v2-grouped-table** 1개 컴포넌트 개발로 85%까지 확대
3. **v2-tree-view** 추가로 92% 도달
4. 나머지 8%는 화면별 특수 UI (타임라인, 모바일 스타일)로 개별 개발 필요

View File

@ -0,0 +1,581 @@
# 다음 구현 필요 컴포넌트 개발 계획
> **작성일**: 2026-01-30
> **상태**: 계획 수립 완료
> **우선순위**: v2-table-grouped (1순위) → v2-timeline-scheduler (2순위)
---
## 개요
생산계획관리 화면의 정식 버전 구현을 위해 필요한 2개의 신규 컴포넌트 개발 계획입니다.
| 컴포넌트 | 용도 | 난이도 | 예상 작업량 |
|----------|------|:------:|:----------:|
| `v2-table-grouped` | 그룹화 테이블 (접기/펼치기) | 중 | 2-3일 |
| `v2-timeline-scheduler` | 타임라인/간트차트 스케줄러 | 상 | 5-7일 |
---
## 1. v2-table-grouped (그룹화 테이블)
### 1.1 컴포넌트 개요
| 항목 | 내용 |
|------|------|
| **컴포넌트 ID** | `v2-table-grouped` |
| **카테고리** | DISPLAY |
| **용도** | 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능 제공 |
| **기반 컴포넌트** | `v2-table-list` 확장 |
| **참고 UI** | Excel 그룹화, VS Code 파일 그룹화 |
### 1.2 핵심 기능
| 기능 | 설명 | 우선순위 |
|------|------|:--------:|
| 그룹화 | 지정된 컬럼 기준으로 데이터 그룹핑 | 필수 |
| 접기/펼치기 | 그룹 행 클릭 시 하위 항목 토글 | 필수 |
| 그룹 요약 | 그룹별 합계/개수 표시 | 필수 |
| 다중 그룹 | 여러 컬럼 기준 중첩 그룹화 | 선택 |
| 그룹 선택 | 그룹 체크박스로 하위 전체 선택 | 필수 |
| 전체 펼치기/접기 | 모든 그룹 일괄 토글 | 필수 |
### 1.3 UI 목업
```
┌─────────────────────────────────────────────────────────────────┐
│ [전체 펼치기] [전체 접기] [3개 그룹] │
├─────────────────────────────────────────────────────────────────┤
│ ▼ □ 품목A (P001) 수량: 150 3건 │
│ ├─ □ 2026-01-15 생산계획001 50개 설비A │
│ ├─ □ 2026-01-16 생산계획002 50개 설비B │
│ └─ □ 2026-01-17 생산계획003 50개 설비A │
├─────────────────────────────────────────────────────────────────┤
│ ► □ 품목B (P002) 수량: 200 2건 │ ← 접힌 상태
├─────────────────────────────────────────────────────────────────┤
│ ▼ □ 품목C (P003) 수량: 100 1건 │
│ └─ □ 2026-01-18 생산계획004 100개 설비C │
└─────────────────────────────────────────────────────────────────┘
```
### 1.4 타입 정의 (types.ts)
```typescript
import { ColumnConfig } from "../v2-table-list/types";
/**
* 그룹화 설정
*/
export interface GroupConfig {
/** 그룹화 기준 컬럼 */
groupByColumn: string;
/** 그룹 표시 형식 (예: "{item_name} ({item_code})") */
groupLabelFormat?: string;
/** 그룹 요약 설정 */
summary?: {
/** 합계 컬럼 */
sumColumns?: string[];
/** 개수 표시 여부 */
showCount?: boolean;
};
/** 초기 펼침 상태 */
defaultExpanded?: boolean;
/** 중첩 그룹 (다중 그룹화) */
nestedGroup?: GroupConfig;
}
/**
* 그룹화 테이블 설정
*/
export interface TableGroupedConfig {
/** 테이블명 */
selectedTable?: string;
/** 커스텀 테이블 사용 */
useCustomTable?: boolean;
customTableName?: string;
/** 그룹화 설정 */
groupConfig: GroupConfig;
/** 컬럼 설정 (v2-table-list와 동일) */
columns?: ColumnConfig[];
/** 체크박스 표시 */
showCheckbox?: boolean;
/** 체크박스 모드 */
checkboxMode?: "single" | "multi";
/** 페이지네이션 (그룹 단위) */
pagination?: {
enabled: boolean;
pageSize: number;
};
/** 정렬 설정 */
defaultSort?: {
column: string;
direction: "asc" | "desc";
};
}
/**
* 그룹 상태
*/
export interface GroupState {
/** 그룹 키 (groupByColumn 값) */
groupKey: string;
/** 펼침 여부 */
expanded: boolean;
/** 그룹 내 데이터 */
items: any[];
/** 그룹 요약 데이터 */
summary?: Record<string, number>;
}
```
### 1.5 파일 구조
```
frontend/lib/registry/components/v2-table-grouped/
├── index.ts # Definition (V2TableGroupedDefinition)
├── types.ts # 타입 정의
├── config.ts # 기본 설정값
├── TableGroupedComponent.tsx # 메인 컴포넌트
├── TableGroupedConfigPanel.tsx # 설정 패널
├── TableGroupedRenderer.tsx # 레지스트리 등록
├── components/
│ ├── GroupHeader.tsx # 그룹 헤더 (펼치기/접기)
│ ├── GroupSummary.tsx # 그룹 요약
│ └── GroupCheckbox.tsx # 그룹 체크박스
├── hooks/
│ └── useGroupedData.ts # 그룹화 로직 훅
└── README.md
```
### 1.6 구현 단계
| 단계 | 작업 내용 | 예상 시간 |
|:----:|----------|:---------:|
| 1 | 타입 정의 및 기본 구조 생성 | 2시간 |
| 2 | `useGroupedData` 훅 구현 (데이터 그룹화 로직) | 4시간 |
| 3 | `GroupHeader` 컴포넌트 (펼치기/접기 UI) | 2시간 |
| 4 | `TableGroupedComponent` 메인 구현 | 6시간 |
| 5 | 그룹 체크박스 연동 | 2시간 |
| 6 | 그룹 요약 (합계/개수) | 2시간 |
| 7 | `TableGroupedConfigPanel` 설정 패널 | 4시간 |
| 8 | 테스트 및 문서화 | 2시간 |
**총 예상: 24시간 (약 3일)**
### 1.7 v2-table-list와의 차이점
| 항목 | v2-table-list | v2-table-grouped |
|------|---------------|------------------|
| 데이터 구조 | 플랫 리스트 | 계층 구조 (그룹 > 아이템) |
| 렌더링 | 행 단위 | 그룹 헤더 + 상세 행 |
| 선택 | 개별 행 | 그룹 단위 / 개별 단위 |
| 요약 | 전체 합계 (선택) | 그룹별 요약 |
| 페이지네이션 | 행 단위 | 그룹 단위 |
---
## 2. v2-timeline-scheduler (타임라인 스케줄러)
### 2.1 컴포넌트 개요
| 항목 | 내용 |
|------|------|
| **컴포넌트 ID** | `v2-timeline-scheduler` |
| **카테고리** | DISPLAY |
| **용도** | 간트차트 형태의 일정/계획 시각화 및 편집 |
| **참고 UI** | MS Project, Jira Timeline, dhtmlxGantt |
| **외부 라이브러리** | 고려 중: `@tanstack/react-virtual` (가상 스크롤) |
### 2.2 핵심 기능
| 기능 | 설명 | 우선순위 |
|------|------|:--------:|
| 타임라인 그리드 | 날짜 기준 그리드 표시 (일/주/월) | 필수 |
| 스케줄 바 | 시작~종료 기간 바 렌더링 | 필수 |
| 리소스 행 | 설비/작업자별 행 구분 | 필수 |
| 드래그 이동 | 스케줄 바 드래그로 날짜 변경 | 필수 |
| 리사이즈 | 바 양쪽 핸들로 기간 조정 | 필수 |
| 줌 레벨 | 일/주/월 단위 전환 | 필수 |
| 충돌 표시 | 같은 리소스 시간 겹침 경고 | 선택 |
| 진행률 표시 | 바 내부 진행률 표시 | 선택 |
| 마일스톤 | 단일 일정 마커 | 선택 |
### 2.3 UI 목업
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ [◀ 이전] [오늘] [다음 ▶] 2026년 1월 [일] [주] [월] [+ 추가] │
├────────────┬─────────────────────────────────────────────────────────────────┤
│ │ 15(수) │ 16(목) │ 17(금) │ 18(토) │ 19(일) │ 20(월) │ 21(화) │
├────────────┼─────────────────────────────────────────────────────────────────┤
│ 설비A │ ████████████████ │
│ │ [생산계획001] │ │
├────────────┼─────────────────────────────────────────────────────────────────┤
│ 설비B │ █████████████████████████ │
│ │ [생산계획002 ] │
├────────────┼─────────────────────────────────────────────────────────────────┤
│ 설비C │ ████████████████ │
│ │ [생산계획003] │
└────────────┴─────────────────────────────────────────────────────────────────┘
범례: ██ 진행중 ██ 완료 ██ 지연 ◆ 마일스톤
```
### 2.4 타입 정의 (types.ts)
```typescript
/**
* 줌 레벨
*/
export type ZoomLevel = "day" | "week" | "month";
/**
* 스케줄 상태
*/
export type ScheduleStatus = "planned" | "in_progress" | "completed" | "delayed" | "cancelled";
/**
* 스케줄 항목
*/
export interface ScheduleItem {
/** 고유 ID */
id: string;
/** 리소스 ID (설비/작업자) */
resourceId: string;
/** 표시 제목 */
title: string;
/** 시작 일시 */
startDate: string; // ISO 8601 format
/** 종료 일시 */
endDate: string;
/** 상태 */
status: ScheduleStatus;
/** 진행률 (0-100) */
progress?: number;
/** 색상 (CSS color) */
color?: string;
/** 추가 데이터 */
data?: Record<string, any>;
}
/**
* 리소스 (행)
*/
export interface Resource {
/** 리소스 ID */
id: string;
/** 표시명 */
name: string;
/** 그룹 (선택) */
group?: string;
/** 아이콘 (선택) */
icon?: string;
/** 용량 (선택, 충돌 계산용) */
capacity?: number;
}
/**
* 타임라인 설정
*/
export interface TimelineSchedulerConfig {
/** 테이블명 (스케줄 데이터) */
selectedTable?: string;
/** 리소스 테이블명 */
resourceTable?: string;
/** 필드 매핑 */
fieldMapping: {
id: string;
resourceId: string;
title: string;
startDate: string;
endDate: string;
status?: string;
progress?: string;
color?: string;
};
/** 리소스 필드 매핑 */
resourceFieldMapping?: {
id: string;
name: string;
group?: string;
};
/** 초기 줌 레벨 */
defaultZoomLevel?: ZoomLevel;
/** 초기 표시 날짜 */
initialDate?: string;
/** 편집 가능 여부 */
editable?: boolean;
/** 드래그 이동 허용 */
allowDrag?: boolean;
/** 리사이즈 허용 */
allowResize?: boolean;
/** 충돌 체크 */
checkConflicts?: boolean;
/** 상태별 색상 */
statusColors?: Record<ScheduleStatus, string>;
/** 리소스 컬럼 너비 */
resourceColumnWidth?: number;
/** 행 높이 */
rowHeight?: number;
/** 셀 너비 (줌 레벨별) */
cellWidth?: {
day: number;
week: number;
month: number;
};
/** 툴바 표시 */
showToolbar?: boolean;
/** 범례 표시 */
showLegend?: boolean;
}
/**
* 이벤트 핸들러
*/
export interface TimelineEvents {
/** 스케줄 클릭 */
onScheduleClick?: (schedule: ScheduleItem) => void;
/** 스케줄 더블클릭 */
onScheduleDoubleClick?: (schedule: ScheduleItem) => void;
/** 드래그 완료 */
onScheduleDrag?: (schedule: ScheduleItem, newStart: Date, newEnd: Date) => void;
/** 리사이즈 완료 */
onScheduleResize?: (schedule: ScheduleItem, newStart: Date, newEnd: Date) => void;
/** 빈 영역 클릭 (새 스케줄 추가용) */
onEmptyClick?: (resourceId: string, date: Date) => void;
}
```
### 2.5 파일 구조
```
frontend/lib/registry/components/v2-timeline-scheduler/
├── index.ts # Definition (V2TimelineSchedulerDefinition)
├── types.ts # 타입 정의
├── config.ts # 기본 설정값
├── TimelineSchedulerComponent.tsx # 메인 컴포넌트
├── TimelineSchedulerConfigPanel.tsx # 설정 패널
├── TimelineSchedulerRenderer.tsx # 레지스트리 등록
├── components/
│ ├── TimelineHeader.tsx # 날짜 헤더
│ ├── TimelineGrid.tsx # 그리드 배경
│ ├── ResourceColumn.tsx # 리소스 컬럼 (좌측)
│ ├── ScheduleBar.tsx # 스케줄 바 (드래그/리사이즈)
│ ├── TimelineToolbar.tsx # 툴바 (줌, 네비게이션)
│ ├── TimelineLegend.tsx # 범례
│ └── ConflictIndicator.tsx # 충돌 표시
├── hooks/
│ ├── useTimelineState.ts # 타임라인 상태 관리
│ ├── useScheduleDrag.ts # 드래그 로직
│ ├── useScheduleResize.ts # 리사이즈 로직
│ └── useDateCalculation.ts # 날짜/위치 계산
├── utils/
│ ├── dateUtils.ts # 날짜 유틸리티
│ └── conflictDetection.ts # 충돌 감지
└── README.md
```
### 2.6 구현 단계
| 단계 | 작업 내용 | 예상 시간 |
|:----:|----------|:---------:|
| 1 | 타입 정의 및 기본 구조 생성 | 3시간 |
| 2 | `TimelineHeader` (날짜 헤더, 줌 레벨) | 4시간 |
| 3 | `TimelineGrid` (그리드 배경) | 3시간 |
| 4 | `ResourceColumn` (리소스 목록) | 2시간 |
| 5 | `ScheduleBar` 기본 렌더링 | 4시간 |
| 6 | 드래그 이동 구현 | 6시간 |
| 7 | 리사이즈 구현 | 4시간 |
| 8 | 줌 레벨 전환 (일/주/월) | 3시간 |
| 9 | 날짜 네비게이션 | 2시간 |
| 10 | 충돌 감지 및 표시 | 4시간 |
| 11 | 가상 스크롤 (대용량 데이터) | 4시간 |
| 12 | `TimelineSchedulerConfigPanel` | 4시간 |
| 13 | API 연동 (저장/로드) | 4시간 |
| 14 | 테스트 및 문서화 | 3시간 |
**총 예상: 50시간 (약 6-7일)**
### 2.7 핵심 알고리즘
#### 날짜 → 픽셀 위치 변환
```typescript
function dateToPosition(date: Date, viewStart: Date, cellWidth: number, zoomLevel: ZoomLevel): number {
const diffMs = date.getTime() - viewStart.getTime();
switch (zoomLevel) {
case "day":
const diffDays = diffMs / (1000 * 60 * 60 * 24);
return diffDays * cellWidth;
case "week":
const diffWeeks = diffMs / (1000 * 60 * 60 * 24 * 7);
return diffWeeks * cellWidth;
case "month":
// 월 단위는 일수가 다르므로 별도 계산
return calculateMonthPosition(date, viewStart, cellWidth);
}
}
```
#### 충돌 감지
```typescript
function detectConflicts(schedules: ScheduleItem[], resourceId: string): ScheduleItem[][] {
const resourceSchedules = schedules
.filter(s => s.resourceId === resourceId)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const conflicts: ScheduleItem[][] = [];
for (let i = 0; i < resourceSchedules.length; i++) {
const current = resourceSchedules[i];
const overlapping = resourceSchedules.filter(s =>
s.id !== current.id &&
new Date(s.startDate) < new Date(current.endDate) &&
new Date(s.endDate) > new Date(current.startDate)
);
if (overlapping.length > 0) {
conflicts.push([current, ...overlapping]);
}
}
return conflicts;
}
```
---
## 3. 구현 우선순위 및 일정
### 3.1 권장 순서
```
1단계: v2-table-grouped (2-3일)
2단계: v2-timeline-scheduler (5-7일)
3단계: 생산계획관리 정식 버전 화면 구성 (1-2일)
```
### 3.2 이유
1. **v2-table-grouped 먼저**:
- `v2-table-list` 기반 확장으로 난이도 낮음
- 생산계획 외 다른 화면(BOM, 수주 등)에서도 활용 가능
- 타임라인 개발 중에도 테스트용으로 사용 가능
2. **v2-timeline-scheduler 나중**:
- 복잡도가 높아 집중 개발 필요
- 드래그/리사이즈 등 인터랙션 테스트 필요
- 생산계획관리 전용 컴포넌트
### 3.3 체크리스트
#### v2-table-grouped ✅ 구현 완료 (2026-01-30)
- [x] 타입 정의 완료
- [x] 기본 구조 생성
- [x] useGroupedData 훅 구현
- [x] GroupHeader 컴포넌트
- [x] 메인 컴포넌트 구현
- [x] 그룹 체크박스 연동
- [x] 그룹 요약 (합계/개수)
- [x] 설정 패널 구현
- [x] 레지스트리 등록
- [x] 문서화 (README.md)
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
- [x] 타입 정의 완료
- [x] 기본 구조 생성
- [x] TimelineHeader (날짜)
- [x] TimelineGrid (배경)
- [x] ResourceColumn (리소스)
- [x] ScheduleBar 기본 렌더링
- [x] 드래그 이동 (기본)
- [x] 리사이즈 (기본)
- [x] 줌 레벨 전환
- [x] 날짜 네비게이션
- [ ] 충돌 감지 (향후)
- [ ] 가상 스크롤 (향후)
- [x] 설정 패널 구현
- [x] API 연동
- [x] 레지스트리 등록
- [ ] 테스트 완료
- [x] 문서화 (README.md)
---
## 4. 참고 자료
### 기존 V2 컴포넌트 참고
- `v2-table-list`: 테이블 렌더링, 체크박스, 페이지네이션
- `v2-pivot-grid`: 복잡한 그리드 렌더링, 가상 스크롤
- `v2-split-panel-layout`: 커스텀 모드 컴포넌트 배치
### 외부 라이브러리 검토
| 라이브러리 | 용도 | 고려 사항 |
|----------|------|----------|
| `@tanstack/react-virtual` | 가상 스크롤 | 이미 사용 중, 확장 용이 |
| `date-fns` | 날짜 계산 | 이미 사용 중 |
| `react-dnd` | 드래그앤드롭 | 검토 필요, 현재 네이티브 구현 |
### 관련 문서
- [생산계획관리 화면 설계](../03_production/production-plan.md)
- [V2 컴포넌트 분석 가이드](../../V2_컴포넌트_분석_가이드.md)
- [컴포넌트 개발 가이드](../../../frontend/docs/component-development-guide.md)
---
**작성자**: Claude AI
**최종 수정**: 2026-01-30

View File

@ -0,0 +1,894 @@
# 스케줄 자동 생성 기능 구현 가이드
> 버전: 2.0
> 최종 수정: 2025-02-02
> 적용 화면: 생산계획관리, 설비계획관리, 출하계획관리 등
## 1. 개요
### 1.1 기능 설명
좌측 테이블에서 선택한 데이터(수주, 작업지시 등)를 기반으로 우측 타임라인에 스케줄을 자동 생성하는 기능입니다.
### 1.2 주요 특징
- **범용성**: 설정 기반으로 다양한 화면에서 재사용 가능
- **미리보기**: 적용 전 변경사항 확인 가능
- **소스 추적**: 스케줄이 어디서 생성되었는지 추적 가능
- **연결 필터**: 좌측 선택 시 우측 타임라인 자동 필터링
- **이벤트 버스 기반**: 컴포넌트 간 느슨한 결합 (Loose Coupling)
### 1.3 아키텍처 원칙
**이벤트 버스 패턴**을 활용하여 컴포넌트 간 직접 참조를 제거합니다:
```
┌─────────────────┐ 이벤트 발송 ┌─────────────────┐
│ v2-button │ ──────────────────▶ │ EventBus │
│ (발송만 함) │ │ (중재자) │
└─────────────────┘ └────────┬────────┘
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ ScheduleService │ │ v2-timeline │ │ 기타 리스너 │
│ (처리 담당) │ │ (갱신) │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**장점**:
- 버튼은 데이터가 어디서 오는지 알 필요 없음
- 테이블은 누가 데이터를 사용하는지 알 필요 없음
- 컴포넌트 교체/추가 시 기존 코드 수정 불필요
---
## 2. 데이터 흐름
### 2.1 전체 흐름도
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 분할 패널 (SplitPanelLayout) │
├───────────────────────────────┬─────────────────────────────────────────────┤
│ 좌측 패널 │ 우측 패널 │
│ │ │
│ ┌─────────────────────────┐ │ ┌─────────────────────────────────────┐ │
│ │ v2-table-grouped │ │ │ 자동 스케줄 생성 버튼 │ │
│ │ (수주 목록) │ │ │ ↓ │ │
│ │ │ │ │ ① 좌측 선택 데이터 가져오기 │ │
│ │ ☑ ITEM-001 (탕핑 A) │──┼──│ ② 백엔드 API 호출 (미리보기) │ │
│ │ └ SO-2025-101 │ │ │ ③ 변경사항 다이얼로그 표시 │ │
│ │ └ SO-2025-102 │ │ │ ④ 적용 API 호출 │ │
│ │ ☐ ITEM-002 (탕핑 B) │ │ │ ⑤ 타임라인 새로고침 │ │
│ │ └ SO-2025-201 │ │ └─────────────────────────────────────┘ │
│ └─────────────────────────┘ │ │
│ │ │ ┌─────────────────────────────────────┐ │
│ │ linkedFilter │ │ v2-timeline-scheduler │ │
│ └──────────────────┼──│ (생산 타임라인) │ │
│ │ │ │ │
│ │ │ part_code = 선택된 품목 필터링 │ │
│ │ └─────────────────────────────────────┘ │
└───────────────────────────────┴─────────────────────────────────────────────┘
```
### 2.2 단계별 데이터 흐름
| 단계 | 동작 | 데이터 |
|------|------|--------|
| 1 | 좌측 테이블에서 품목 선택 | `selectedItems[]` (그룹 선택 시 자식 포함) |
| 2 | 자동 스케줄 생성 버튼 클릭 | 버튼 액션 실행 |
| 3 | 미리보기 API 호출 | `{ config, sourceData, period }` |
| 4 | 변경사항 다이얼로그 표시 | `{ toCreate, toDelete, summary }` |
| 5 | 적용 API 호출 | `{ config, preview, options }` |
| 6 | 타임라인 새로고침 | `TABLE_REFRESH` 이벤트 발송 |
| 7 | 다음 방문 시 좌측 선택 | `linkedFilter`로 우측 자동 필터링 |
---
## 3. 테이블 구조 설계
### 3.1 범용 스케줄 테이블 (schedule_mng)
```sql
CREATE TABLE schedule_mng (
schedule_id SERIAL PRIMARY KEY,
company_code VARCHAR(20) NOT NULL,
-- 스케줄 기본 정보
schedule_type VARCHAR(50) NOT NULL, -- 'PRODUCTION', 'SHIPPING', 'MAINTENANCE' 등
schedule_name VARCHAR(200),
-- 리소스 연결 (타임라인 Y축)
resource_type VARCHAR(50) NOT NULL, -- 'ITEM', 'MACHINE', 'WORKER' 등
resource_id VARCHAR(50) NOT NULL, -- 품목코드, 설비코드 등
resource_name VARCHAR(200),
-- 일정
start_date TIMESTAMP NOT NULL,
end_date TIMESTAMP NOT NULL,
-- 수량/값
plan_qty NUMERIC(15,3),
actual_qty NUMERIC(15,3),
-- 상태
status VARCHAR(20) DEFAULT 'PLANNED', -- PLANNED, IN_PROGRESS, COMPLETED, CANCELLED
-- 소스 추적 (어디서 생성되었는지)
source_table VARCHAR(100), -- 'sales_order_mng', 'work_order_mng' 등
source_id VARCHAR(50), -- 소스 테이블의 PK
source_group_key VARCHAR(100), -- 그룹 키 (품목코드 등)
-- 자동 생성 여부
auto_generated BOOLEAN DEFAULT FALSE,
generated_at TIMESTAMP,
generated_by VARCHAR(50),
-- 메타데이터 (추가 정보 JSON)
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_schedule_company FOREIGN KEY (company_code)
REFERENCES company_mng(company_code)
);
-- 인덱스
CREATE INDEX idx_schedule_company ON schedule_mng(company_code);
CREATE INDEX idx_schedule_type ON schedule_mng(schedule_type);
CREATE INDEX idx_schedule_resource ON schedule_mng(resource_type, resource_id);
CREATE INDEX idx_schedule_source ON schedule_mng(source_table, source_id);
CREATE INDEX idx_schedule_date ON schedule_mng(start_date, end_date);
CREATE INDEX idx_schedule_status ON schedule_mng(status);
```
### 3.2 소스-스케줄 매핑 테이블 (N:M 관계)
```sql
-- 하나의 스케줄이 여러 소스에서 생성될 수 있음
CREATE TABLE schedule_source_mapping (
mapping_id SERIAL PRIMARY KEY,
company_code VARCHAR(20) NOT NULL,
schedule_id INTEGER REFERENCES schedule_mng(schedule_id) ON DELETE CASCADE,
-- 소스 정보
source_table VARCHAR(100) NOT NULL,
source_id VARCHAR(50) NOT NULL,
source_qty NUMERIC(15,3), -- 해당 소스에서 기여한 수량
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_mapping_company FOREIGN KEY (company_code)
REFERENCES company_mng(company_code)
);
CREATE INDEX idx_mapping_schedule ON schedule_source_mapping(schedule_id);
CREATE INDEX idx_mapping_source ON schedule_source_mapping(source_table, source_id);
```
### 3.3 테이블 관계도
```
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ sales_order_mng │ │ schedule_mng │ │ schedule_source_ │
│ (소스 테이블) │ │ (스케줄 테이블) │ │ mapping │
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
│ order_id (PK) │───────│ source_id │ │ mapping_id (PK) │
│ part_code │ │ schedule_id (PK) │──1:N──│ schedule_id (FK) │
│ order_qty │ │ resource_id │ │ source_table │
│ balance_qty │ │ start_date │ │ source_id │
│ due_date │ │ end_date │ │ source_qty │
└─────────────────────┘ │ plan_qty │ └─────────────────────┘
│ status │
│ auto_generated │
└─────────────────────┘
```
---
## 4. 스케줄 생성 설정 구조
### 4.1 TypeScript 인터페이스
```typescript
// 화면 레벨 설정 (screen_definitions 또는 screen_layouts_v2에 저장)
interface ScheduleGenerationConfig {
// 스케줄 타입
scheduleType: "PRODUCTION" | "SHIPPING" | "MAINTENANCE" | "WORK_ASSIGN";
// 소스 설정 (컴포넌트 ID 불필요 - 이벤트로 데이터 수신)
source: {
tableName: string; // 소스 테이블명
groupByField: string; // 그룹화 기준 필드 (part_code)
quantityField: string; // 수량 필드 (order_qty, balance_qty)
dueDateField?: string; // 납기일 필드 (선택)
};
// 리소스 매핑 (타임라인 Y축)
resource: {
type: string; // 'ITEM', 'MACHINE', 'WORKER' 등
idField: string; // part_code, machine_code 등
nameField: string; // part_name, machine_name 등
};
// 생성 규칙
rules: {
leadTimeDays?: number; // 리드타임 (일)
dailyCapacity?: number; // 일일 생산능력
workingDays?: number[]; // 작업일 [1,2,3,4,5] = 월~금
considerStock?: boolean; // 재고 고려 여부
stockTableName?: string; // 재고 테이블명
stockQtyField?: string; // 재고 수량 필드
safetyStockField?: string; // 안전재고 필드
};
// 타겟 설정
target: {
tableName: string; // 스케줄 테이블명 (schedule_mng 또는 전용 테이블)
};
}
```
> **주의**: 기존 설계와 달리 `source.componentId``target.timelineComponentId`가 제거되었습니다.
> 이벤트 버스를 통해 데이터가 전달되므로 컴포넌트 ID를 직접 참조할 필요가 없습니다.
### 4.2 화면별 설정 예시
#### 생산계획관리 화면
```json
{
"scheduleType": "PRODUCTION",
"source": {
"tableName": "sales_order_mng",
"groupByField": "part_code",
"quantityField": "balance_qty",
"dueDateField": "due_date"
},
"resource": {
"type": "ITEM",
"idField": "part_code",
"nameField": "part_name"
},
"rules": {
"leadTimeDays": 3,
"dailyCapacity": 100,
"workingDays": [1, 2, 3, 4, 5],
"considerStock": true,
"stockTableName": "inventory_mng",
"stockQtyField": "current_qty",
"safetyStockField": "safety_stock"
},
"target": {
"tableName": "schedule_mng"
}
}
```
#### 설비계획관리 화면
```json
{
"scheduleType": "MAINTENANCE",
"source": {
"tableName": "work_order_mng",
"groupByField": "machine_code",
"quantityField": "work_hours"
},
"resource": {
"type": "MACHINE",
"idField": "machine_code",
"nameField": "machine_name"
},
"rules": {
"workingDays": [1, 2, 3, 4, 5, 6]
},
"target": {
"tableName": "schedule_mng"
}
}
```
---
## 5. 백엔드 API 설계
### 5.1 미리보기 API
```typescript
// POST /api/schedule/preview
interface PreviewRequest {
config: ScheduleGenerationConfig;
sourceData: any[]; // 선택된 소스 데이터
period: {
start: string; // ISO 날짜 문자열
end: string;
};
}
interface PreviewResponse {
success: boolean;
preview: {
toCreate: ScheduleItem[]; // 생성될 스케줄
toDelete: ScheduleItem[]; // 삭제될 기존 스케줄
toUpdate: ScheduleItem[]; // 수정될 스케줄
summary: {
createCount: number;
deleteCount: number;
updateCount: number;
totalQty: number;
};
};
}
```
### 5.2 적용 API
```typescript
// POST /api/schedule/apply
interface ApplyRequest {
config: ScheduleGenerationConfig;
preview: PreviewResponse["preview"];
options: {
deleteExisting: boolean; // 기존 스케줄 삭제 여부
updateMode: "replace" | "merge";
};
}
interface ApplyResponse {
success: boolean;
applied: {
created: number;
deleted: number;
updated: number;
};
}
```
### 5.3 스케줄 조회 API (타임라인용)
```typescript
// GET /api/schedule/list
interface ListQuery {
scheduleType: string;
resourceType: string;
resourceId?: string; // 필터링 (linkedFilter에서 사용)
startDate: string;
endDate: string;
}
interface ListResponse {
success: boolean;
data: ScheduleItem[];
total: number;
}
```
---
## 6. 이벤트 버스 기반 구현
### 6.1 이벤트 타입 정의
```typescript
// frontend/lib/v2-core/events/types.ts에 추가
export const V2_EVENTS = {
// ... 기존 이벤트들
// 스케줄 생성 이벤트
SCHEDULE_GENERATE_REQUEST: "v2:schedule:generate:request",
SCHEDULE_GENERATE_PREVIEW: "v2:schedule:generate:preview",
SCHEDULE_GENERATE_APPLY: "v2:schedule:generate:apply",
SCHEDULE_GENERATE_COMPLETE: "v2:schedule:generate:complete",
SCHEDULE_GENERATE_ERROR: "v2:schedule:generate:error",
} as const;
/** 스케줄 생성 요청 이벤트 */
export interface V2ScheduleGenerateRequestEvent {
requestId: string;
scheduleType: "PRODUCTION" | "MAINTENANCE" | "SHIPPING" | "WORK_ASSIGN";
sourceData?: any[]; // 선택 데이터 (없으면 TABLE_SELECTION_CHANGE로 받은 데이터 사용)
period?: { start: string; end: string };
}
/** 스케줄 미리보기 결과 이벤트 */
export interface V2ScheduleGeneratePreviewEvent {
requestId: string;
preview: {
toCreate: any[];
toDelete: any[];
summary: { createCount: number; deleteCount: number; totalQty: number };
};
}
/** 스케줄 적용 이벤트 */
export interface V2ScheduleGenerateApplyEvent {
requestId: string;
confirmed: boolean;
}
/** 스케줄 생성 완료 이벤트 */
export interface V2ScheduleGenerateCompleteEvent {
requestId: string;
success: boolean;
applied: { created: number; deleted: number };
scheduleType: string;
}
```
### 6.2 버튼 설정 (간소화)
```json
{
"componentType": "v2-button-primary",
"componentId": "btn_auto_schedule",
"componentConfig": {
"label": "자동 스케줄 생성",
"variant": "default",
"icon": "Calendar",
"action": {
"type": "event",
"eventName": "SCHEDULE_GENERATE_REQUEST",
"eventPayload": {
"scheduleType": "PRODUCTION"
}
}
}
}
```
> **핵심**: 버튼은 이벤트만 발송하고, 데이터가 어디서 오는지 알 필요 없음
### 6.3 스케줄 생성 서비스 (이벤트 리스너)
```typescript
// frontend/lib/v2-core/services/ScheduleGeneratorService.ts
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
import apiClient from "@/lib/api/client";
import { toast } from "sonner";
export function useScheduleGenerator(scheduleConfig: ScheduleGenerationConfig) {
const [selectedData, setSelectedData] = useState<any[]>([]);
const [previewResult, setPreviewResult] = useState<any>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [currentRequestId, setCurrentRequestId] = useState<string>("");
// 1. 테이블 선택 데이터 추적 (TABLE_SELECTION_CHANGE 이벤트 수신)
useEffect(() => {
const unsubscribe = v2EventBus.on(
V2_EVENTS.TABLE_SELECTION_CHANGE,
(payload) => {
// 설정된 소스 테이블과 일치하는 경우에만 저장
if (payload.tableName === scheduleConfig.source.tableName) {
setSelectedData(payload.selectedRows);
}
}
);
return unsubscribe;
}, [scheduleConfig.source.tableName]);
// 2. 스케줄 생성 요청 처리 (SCHEDULE_GENERATE_REQUEST 수신)
useEffect(() => {
const unsubscribe = v2EventBus.on(
V2_EVENTS.SCHEDULE_GENERATE_REQUEST,
async (payload) => {
// 스케줄 타입이 일치하는 경우에만 처리
if (payload.scheduleType !== scheduleConfig.scheduleType) {
return;
}
const dataToUse = payload.sourceData || selectedData;
if (dataToUse.length === 0) {
toast.warning("품목을 선택해주세요.");
return;
}
setCurrentRequestId(payload.requestId);
try {
// 미리보기 API 호출
const response = await apiClient.post("/api/schedule/preview", {
config: scheduleConfig,
sourceData: dataToUse,
period: payload.period || getDefaultPeriod(),
});
if (!response.data.success) {
toast.error(response.data.message);
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_ERROR, {
requestId: payload.requestId,
error: response.data.message,
});
return;
}
setPreviewResult(response.data.preview);
setShowConfirmDialog(true);
// 미리보기 결과 이벤트 발송 (다른 컴포넌트가 필요할 수 있음)
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_PREVIEW, {
requestId: payload.requestId,
preview: response.data.preview,
});
} catch (error: any) {
toast.error("스케줄 생성 중 오류가 발생했습니다.");
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_ERROR, {
requestId: payload.requestId,
error: error.message,
});
}
}
);
return unsubscribe;
}, [selectedData, scheduleConfig]);
// 3. 스케줄 적용 처리 (SCHEDULE_GENERATE_APPLY 수신)
useEffect(() => {
const unsubscribe = v2EventBus.on(
V2_EVENTS.SCHEDULE_GENERATE_APPLY,
async (payload) => {
if (payload.requestId !== currentRequestId) return;
if (!payload.confirmed) {
setShowConfirmDialog(false);
return;
}
try {
const response = await apiClient.post("/api/schedule/apply", {
config: scheduleConfig,
preview: previewResult,
options: { deleteExisting: true, updateMode: "replace" },
});
if (!response.data.success) {
toast.error(response.data.message);
return;
}
// 완료 이벤트 발송
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, {
requestId: payload.requestId,
success: true,
applied: response.data.applied,
scheduleType: scheduleConfig.scheduleType,
});
// 테이블 새로고침 이벤트 발송
v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, {
tableName: scheduleConfig.target.tableName,
});
toast.success(`${response.data.applied.created}건의 스케줄이 생성되었습니다.`);
setShowConfirmDialog(false);
} catch (error: any) {
toast.error("스케줄 적용 중 오류가 발생했습니다.");
}
}
);
return unsubscribe;
}, [currentRequestId, previewResult, scheduleConfig]);
// 확인 다이얼로그 핸들러
const handleConfirm = (confirmed: boolean) => {
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_APPLY, {
requestId: currentRequestId,
confirmed,
});
};
return {
showConfirmDialog,
previewResult,
handleConfirm,
};
}
function getDefaultPeriod() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
start: start.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
};
}
```
### 6.4 타임라인 컴포넌트 (이벤트 수신)
```typescript
// v2-timeline-scheduler에서 이벤트 수신
useEffect(() => {
// 스케줄 생성 완료 시 자동 새로고침
const unsubscribe1 = v2EventBus.on(
V2_EVENTS.SCHEDULE_GENERATE_COMPLETE,
(payload) => {
if (payload.success && payload.scheduleType === config.scheduleType) {
fetchSchedules();
}
}
);
// TABLE_REFRESH 이벤트로도 새로고침
const unsubscribe2 = v2EventBus.on(
V2_EVENTS.TABLE_REFRESH,
(payload) => {
if (payload.tableName === config.selectedTable) {
fetchSchedules();
}
}
);
return () => {
unsubscribe1();
unsubscribe2();
};
}, [config.selectedTable, config.scheduleType]);
```
### 6.5 버튼 액션 핸들러 (이벤트 발송)
```typescript
// frontend/lib/utils/buttonActions.ts
// 기존 handleButtonAction에 추가
case "event":
const eventName = action.eventName as keyof typeof V2_EVENTS;
const eventPayload = {
requestId: crypto.randomUUID(),
...action.eventPayload,
};
v2EventBus.emit(V2_EVENTS[eventName], eventPayload);
return true;
```
---
## 7. 컴포넌트 연동 설정
### 7.1 분할 패널 연결 필터 (linkedFilters)
좌측 테이블 선택 시 우측 타임라인 자동 필터링:
```json
{
"componentType": "v2-split-panel-layout",
"componentConfig": {
"linkedFilters": [
{
"sourceComponentId": "order_table",
"sourceField": "part_code",
"targetColumn": "resource_id"
}
]
}
}
```
### 7.2 타임라인 설정
```json
{
"componentType": "v2-timeline-scheduler",
"componentId": "production_timeline",
"componentConfig": {
"selectedTable": "production_plan_mng",
"fieldMapping": {
"id": "schedule_id",
"resourceId": "resource_id",
"title": "schedule_name",
"startDate": "start_date",
"endDate": "end_date",
"status": "status"
},
"useLinkedFilter": true
}
}
```
### 7.3 이벤트 흐름도 (Event-Driven)
```
[좌측 테이블 선택]
v2-table-grouped.onSelectionChange
▼ emit(TABLE_SELECTION_CHANGE)
├───────────────────────────────────────────────────┐
│ │
▼ ▼
ScheduleGeneratorService SplitPanelContext
(selectedData 저장) (linkedFilter 업데이트)
v2-timeline-scheduler
(자동 필터링)
[자동 스케줄 생성 버튼 클릭]
▼ emit(SCHEDULE_GENERATE_REQUEST)
ScheduleGeneratorService (이벤트 리스너)
├─── selectedData (이미 저장됨)
POST /api/schedule/preview
▼ emit(SCHEDULE_GENERATE_PREVIEW)
확인 다이얼로그 표시
▼ (확인 클릭) emit(SCHEDULE_GENERATE_APPLY)
POST /api/schedule/apply
├─── emit(SCHEDULE_GENERATE_COMPLETE)
├─── emit(TABLE_REFRESH)
v2-timeline-scheduler (on TABLE_REFRESH)
fetchSchedules() → 화면 갱신
```
### 7.4 이벤트 시퀀스 다이어그램
```
┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ Table │ │ Button │ │ ScheduleSvc │ │ Backend │ │ Timeline │
└────┬─────┘ └────┬─────┘ └──────┬───────┘ └────┬─────┘ └────┬─────┘
│ │ │ │ │
│ SELECT │ │ │ │
├──────────────────────────────▶ │ │ │
│ TABLE_SELECTION_CHANGE │ │ │
│ │ │ │ │
│ │ CLICK │ │ │
│ ├────────────────▶│ │ │
│ │ SCHEDULE_GENERATE_REQUEST │ │
│ │ │ │ │
│ │ ├────────────────▶│ │
│ │ │ POST /preview │ │
│ │ │◀────────────────┤ │
│ │ │ │ │
│ │ │ CONFIRM DIALOG │ │
│ │ │─────────────────│ │
│ │ │ │ │
│ │ ├────────────────▶│ │
│ │ │ POST /apply │ │
│ │ │◀────────────────┤ │
│ │ │ │ │
│ │ ├─────────────────────────────────▶
│ │ │ SCHEDULE_GENERATE_COMPLETE │
│ │ │ │
│ │ ├─────────────────────────────────▶
│ │ │ TABLE_REFRESH │
│ │ │ │
│ │ │ │ ├──▶ refresh
│ │ │ │ │
```
---
## 8. 범용성 활용 가이드
### 8.1 다른 화면에서 재사용
| 화면 | 소스 테이블 | 그룹 필드 | 스케줄 타입 | 리소스 타입 |
|------|------------|----------|------------|------------|
| 생산계획 | sales_order_mng | part_code | PRODUCTION | ITEM |
| 설비계획 | work_order_mng | machine_code | MAINTENANCE | MACHINE |
| 출하계획 | shipment_order_mng | customer_code | SHIPPING | CUSTOMER |
| 작업자 배치 | task_mng | worker_id | WORK_ASSIGN | WORKER |
### 8.2 새 화면 추가 시 체크리스트
- [ ] 소스 테이블 정의 (어떤 데이터를 선택할 것인지)
- [ ] 그룹화 기준 필드 정의 (품목, 설비, 고객 등)
- [ ] 스케줄 테이블 생성 또는 기존 schedule_mng 사용
- [ ] ScheduleGenerationConfig 작성
- [ ] 버튼에 scheduleConfig 설정
- [ ] 분할 패널 linkedFilters 설정
- [ ] 타임라인 fieldMapping 설정
---
## 9. 구현 순서
| 단계 | 작업 | 상태 |
|------|------|------|
| 1 | 테이블 마이그레이션 (schedule_mng, schedule_source_mapping) | 대기 |
| 2 | 백엔드 API (scheduleController, scheduleService) | 대기 |
| 3 | 버튼 액션 핸들러 (autoGenerateSchedule) | 대기 |
| 4 | 확인 다이얼로그 (기존 AlertDialog 활용) | 대기 |
| 5 | 타임라인 linkedFilter 연동 | 대기 |
| 6 | 테스트 및 검증 | 대기 |
---
## 10. 참고 사항
### 관련 컴포넌트
- `v2-table-grouped`: 그룹화된 테이블 (소스 데이터, TABLE_SELECTION_CHANGE 발송)
- `v2-timeline-scheduler`: 타임라인 스케줄러 (TABLE_REFRESH 수신)
- `v2-button-primary`: 액션 버튼 (SCHEDULE_GENERATE_REQUEST 발송)
- `v2-split-panel-layout`: 분할 패널
### 관련 파일
- `frontend/lib/v2-core/events/types.ts`: 이벤트 타입 정의
- `frontend/lib/v2-core/events/EventBus.ts`: 이벤트 버스
- `frontend/lib/v2-core/services/ScheduleGeneratorService.ts`: 스케줄 생성 서비스 (이벤트 리스너)
- `frontend/lib/utils/buttonActions.ts`: 버튼 액션 핸들러 (이벤트 발송)
- `backend-node/src/services/scheduleService.ts`: 스케줄 서비스
- `backend-node/src/controllers/scheduleController.ts`: 스케줄 컨트롤러
### 특이 사항
- v2-table-grouped의 `selectedItems`는 그룹 선택 시 자식 행까지 포함됨
- 스케줄 생성 시 기존 스케줄과 비교하여 변경사항만 적용 (미리보기 제공)
- source_table, source_id로 소스 추적 가능
- **컴포넌트 ID 직접 참조 없음** - 이벤트 버스로 느슨한 결합
---
## 11. 이벤트 버스 패턴의 장점
### 11.1 기존 방식 vs 이벤트 버스 방식
| 항목 | 기존 (직접 참조) | 이벤트 버스 |
|------|------------------|-------------|
| 결합도 | 강 (componentId 필요) | 약 (이벤트명만 필요) |
| 버튼 설정 | `source.componentId: "order_table"` | `eventPayload.scheduleType: "PRODUCTION"` |
| 컴포넌트 교체 | 설정 수정 필요 | 이벤트만 발송/수신하면 됨 |
| 테스트 | 컴포넌트 모킹 필요 | 이벤트 발송으로 테스트 가능 |
| 디버깅 | 쉬움 | 이벤트 로깅 필요 |
### 11.2 확장성
새로운 컴포넌트 추가 시:
1. 기존 컴포넌트 수정 불필요
2. 새 컴포넌트에서 이벤트 구독만 추가
3. 이벤트 페이로드 구조만 유지하면 됨
```typescript
// 새로운 컴포넌트에서 스케줄 생성 완료 이벤트 구독
useEffect(() => {
const unsubscribe = v2EventBus.on(
V2_EVENTS.SCHEDULE_GENERATE_COMPLETE,
(payload) => {
// 새로운 로직 추가
console.log("스케줄 생성 완료:", payload);
}
);
return unsubscribe;
}, []);
```
### 11.3 디버깅 팁
```typescript
// 이벤트 디버깅용 전역 리스너 (개발 환경에서만)
if (process.env.NODE_ENV === "development") {
v2EventBus.on("*", (event, payload) => {
console.log(`[EventBus] ${event}:`, payload);
});
}
```

View File

@ -0,0 +1,580 @@
# V2 공통 컴포넌트 사용 가이드
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
> **대상**: 화면 설계자, 개발자
> **버전**: 1.0.0
> **작성일**: 2026-01-30
---
## 1. V2 컴포넌트로 가능한 것 / 불가능한 것
### 1.1 가능한 화면 유형
| 화면 유형 | 설명 | 대표 예시 |
|-----------|------|----------|
| 마스터 관리 | 단일 테이블 CRUD | 회사정보, 부서정보, 코드관리 |
| 마스터-디테일 | 좌측 선택 → 우측 상세 | 공정관리, 품목라우팅, 견적관리 |
| 탭 기반 화면 | 탭별 다른 테이블/뷰 | 검사정보관리, 거래처관리 |
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
### 1.2 불가능한 화면 유형 (별도 개발 필요)
| 화면 유형 | 이유 | 해결 방안 |
|-----------|------|----------|
| 간트 차트 / 타임라인 | 시간축 기반 UI 없음 | 별도 컴포넌트 개발 or 외부 라이브러리 |
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
| 그룹화 테이블 | 그룹핑 기능 미지원 | `v2-grouped-table` 개발 필요 |
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
---
## 2. V2 컴포넌트 전체 목록 (23개)
### 2.1 입력 컴포넌트 (3개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 이메일, 전화번호, URL, 여러 줄 | inputType, required, readonly, maxLength |
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 | mode, source(distinct/static/code/entity), multiple |
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 | dateType, format, showTime |
### 2.2 표시 컴포넌트 (3개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, showImage, columnMapping |
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
### 2.3 테이블/데이터 컴포넌트 (3개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter |
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector |
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields, totals, aggregation |
### 2.4 레이아웃 컴포넌트 (8개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** |
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId |
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
| `v2-repeater` | 리피터 | 반복 컨트롤 | - |
| `v2-repeat-screen-modal` | 반복 화면 모달 | 모달 반복 | - |
### 2.5 액션/특수 컴포넌트 (6개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 | text, actionType, variant |
| `v2-numbering-rule` | 채번규칙 | 자동 코드/번호 생성 | rule, prefix, format |
| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 UI | - |
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
---
## 3. 화면 패턴별 컴포넌트 조합
### 3.1 패턴 A: 기본 마스터 화면 (가장 흔함)
**적용 화면**: 코드관리, 사용자관리, 부서정보, 창고정보 등
```
┌─────────────────────────────────────────────────┐
│ v2-table-search-widget │
│ [검색필드1] [검색필드2] [조회] [엑셀] │
├─────────────────────────────────────────────────┤
│ v2-table-list │
│ 제목 [신규] [삭제] │
│ ─────────────────────────────────────────────── │
│ □ | 코드 | 이름 | 상태 | 등록일 | │
└─────────────────────────────────────────────────┘
```
**필수 컴포넌트**:
- `v2-table-search-widget` (1개)
- `v2-table-list` (1개)
**설정 포인트**:
- 테이블명 지정
- 검색 대상 컬럼 설정
- 컬럼 표시/숨김 설정
---
### 3.2 패턴 B: 마스터-디테일 화면
**적용 화면**: 공정관리, 견적관리, 수주관리, 품목라우팅 등
```
┌──────────────────┬──────────────────────────────┐
│ v2-table-list │ v2-table-list 또는 폼 │
│ (마스터) │ (디테일) │
│ ─────────────── │ │
│ □ A001 항목1 │ [상세 정보] │
│ □ A002 항목2 ← │ │
│ □ A003 항목3 │ │
└──────────────────┴──────────────────────────────┘
v2-split-panel-layout
```
**필수 컴포넌트**:
- `v2-split-panel-layout` (1개)
- `v2-table-list` (2개: 마스터, 디테일)
**설정 포인트**:
- `splitRatio`: 좌우 비율 (기본 30:70)
- `relation.type`: join / detail / custom
- `relation.foreignKey`: 연결 키 컬럼
---
### 3.3 패턴 C: 마스터-디테일 + 탭
**적용 화면**: 거래처관리, 품목정보, 설비정보 등
```
┌──────────────────┬──────────────────────────────┐
│ v2-table-list │ v2-tabs-widget │
│ (마스터) │ ┌────┬────┬────┐ │
│ │ │기본│이력│첨부│ │
│ □ A001 거래처1 │ └────┴────┴────┘ │
│ □ A002 거래처2 ← │ [탭별 컨텐츠] │
└──────────────────┴──────────────────────────────┘
```
**필수 컴포넌트**:
- `v2-split-panel-layout` (1개)
- `v2-table-list` (1개: 마스터)
- `v2-tabs-widget` (1개)
**설정 포인트**:
- 탭별 표시할 테이블/폼 설정
- 마스터 선택 시 탭 컨텐츠 연동
---
### 3.4 패턴 D: 카드 뷰
**적용 화면**: 설비정보, 대시보드, 상품 카탈로그 등
```
┌─────────────────────────────────────────────────┐
│ v2-table-search-widget │
├─────────────────────────────────────────────────┤
│ v2-card-display │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ [이미지]│ │ [이미지]│ │ [이미지]│ │
│ │ 제목 │ │ 제목 │ │ 제목 │ │
│ │ 설명 │ │ 설명 │ │ 설명 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────┘
```
**필수 컴포넌트**:
- `v2-table-search-widget` (1개)
- `v2-card-display` (1개)
**설정 포인트**:
- `cardsPerRow`: 한 행당 카드 수
- `columnMapping`: 제목, 부제목, 이미지, 상태 필드 매핑
- `cardStyle`: 이미지 위치, 크기
---
### 3.5 패턴 E: 피벗 분석
**적용 화면**: 매출분석, 재고현황, 생산실적 분석 등
```
┌─────────────────────────────────────────────────┐
│ v2-pivot-grid │
│ │ 2024년 │ 2025년 │ 2026년 │ 합계 │
│ ─────────────────────────────────────────────── │
│ 지역A │ 1,000 │ 1,200 │ 1,500 │ 3,700 │
│ 지역B │ 2,000 │ 2,500 │ 3,000 │ 7,500 │
│ 합계 │ 3,000 │ 3,700 │ 4,500 │ 11,200 │
└─────────────────────────────────────────────────┘
```
**필수 컴포넌트**:
- `v2-pivot-grid` (1개)
**설정 포인트**:
- `fields[].area`: row / column / data / filter
- `fields[].summaryType`: sum / avg / count / min / max
- `fields[].groupInterval`: 날짜 그룹화 (year/quarter/month)
---
## 4. 회사별 개발 시 핵심 체크포인트
### 4.1 테이블 설계 확인
**가장 먼저 확인**:
1. 회사에서 사용할 테이블 목록
2. 테이블 간 관계 (FK)
3. 조회 조건으로 쓸 컬럼
```
✅ 체크리스트:
□ 테이블명이 DB에 존재하는가?
□ company_code 컬럼이 있는가? (멀티테넌시)
□ 마스터-디테일 관계의 FK가 정의되어 있는가?
□ 검색 대상 컬럼에 인덱스가 있는가?
```
### 4.2 화면 패턴 판단
**질문을 통한 판단**:
| 질문 | 예 → 패턴 |
|------|----------|
| 단일 테이블만 조회/편집? | 패턴 A (기본 마스터) |
| 마스터 선택 시 디테일 표시? | 패턴 B (마스터-디테일) |
| 상세에 탭이 필요? | 패턴 C (마스터-디테일+탭) |
| 이미지+정보 카드 형태? | 패턴 D (카드 뷰) |
| 다차원 집계/분석? | 패턴 E (피벗) |
### 4.3 컴포넌트 설정 필수 항목
#### v2-table-list 필수 설정
```typescript
{
selectedTable: "테이블명", // 필수
columns: [ // 표시할 컬럼
{ columnName: "id", displayName: "ID", visible: true, sortable: true },
// ...
],
pagination: {
enabled: true,
pageSize: 20
}
}
```
#### v2-split-panel-layout 필수 설정
```typescript
{
leftPanel: {
tableName: "마스터_테이블명"
},
rightPanel: {
tableName: "디테일_테이블명",
relation: {
type: "detail", // join | detail | custom
foreignKey: "master_id" // 연결 키
}
},
splitRatio: 30 // 좌측 비율
}
```
#### v2-split-panel-layout 커스텀 모드 (NEW)
패널 내부에 자유롭게 컴포넌트를 배치할 수 있습니다. (v2-tabs-widget과 동일 구조)
```typescript
{
leftPanel: {
displayMode: "custom", // 커스텀 모드 활성화
components: [ // 내부 컴포넌트 배열
{
id: "btn-save",
componentType: "v2-button-primary",
label: "저장",
position: { x: 10, y: 10 },
size: { width: 100, height: 40 },
componentConfig: { buttonAction: "save" }
},
{
id: "tbl-list",
componentType: "v2-table-list",
label: "목록",
position: { x: 10, y: 60 },
size: { width: 400, height: 300 },
componentConfig: { selectedTable: "테이블명" }
}
]
},
rightPanel: {
displayMode: "table" // 기존 모드 유지
}
}
```
**디자인 모드 기능**:
- 컴포넌트 클릭 → 좌측 설정 패널에서 속성 편집
- 드래그 핸들(상단)로 이동
- 리사이즈 핸들(모서리)로 크기 조절
- 실제 컴포넌트 미리보기 렌더링
#### v2-card-display 필수 설정
```typescript
{
dataSource: "table",
columnMapping: {
title: "name", // 제목 필드
subtitle: "code", // 부제목 필드
image: "image_url", // 이미지 필드 (선택)
status: "status" // 상태 필드 (선택)
},
cardsPerRow: 3
}
```
---
## 5. 공통 컴포넌트 한계점
### 5.1 현재 불가능한 기능
| 기능 | 상태 | 대안 |
|------|------|------|
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
| 그룹화 테이블 | ❌ 미지원 | 일반 테이블로 대체 or 별도 개발 |
| 간트 차트 | ❌ 미지원 | 별도 개발 필요 |
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
### 5.2 권장하지 않는 조합
| 조합 | 이유 |
|------|------|
| 3단계 이상 중첩 분할 | 화면 복잡도 증가, 성능 저하 |
| 탭 안에 탭 | 사용성 저하 |
| 한 화면에 3개 이상 테이블 | 데이터 로딩 성능 |
| 피벗 + 상세 테이블 동시 | 데이터 과부하 |
---
## 6. 제어관리 (비즈니스 로직) - 별도 설정 필수
> **핵심**: V2 컴포넌트는 **UI만 담당**합니다. 비즈니스 로직은 **제어관리**에서 별도 설정해야 합니다.
### 6.1 UI vs 제어 분리 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ 화면 구성 │
├─────────────────────────────┬───────────────────────────────────┤
│ UI 레이아웃 │ 제어관리 │
│ (screen_layouts_v2) │ (dataflow_diagrams) │
├─────────────────────────────┼───────────────────────────────────┤
│ • 컴포넌트 배치 │ • 버튼 클릭 시 액션 │
│ • 검색 필드 구성 │ • INSERT/UPDATE/DELETE 설정 │
│ • 테이블 컬럼 표시 │ • 조건부 실행 │
│ • 카드/탭 레이아웃 │ • 다중 행 처리 │
│ │ • 테이블 간 데이터 이동 │
└─────────────────────────────┴───────────────────────────────────┘
```
### 6.2 HTML에서 파악 가능/불가능
| 구분 | HTML에서 파악 | 이유 |
|------|--------------|------|
| 컴포넌트 배치 | ✅ 가능 | HTML 구조에서 보임 |
| 검색 필드 | ✅ 가능 | input 태그로 확인 |
| 테이블 컬럼 | ✅ 가능 | thead에서 확인 |
| **저장 테이블** | ❌ 불가능 | JS/백엔드에서 처리 |
| **버튼 액션** | ❌ 불가능 | 제어관리에서 설정 |
| **전/후 처리** | ❌ 불가능 | 제어관리에서 설정 |
| **다중 행 처리** | ❌ 불가능 | 제어관리에서 설정 |
| **테이블 간 관계** | ❌ 불가능 | DB/제어관리에서 설정 |
### 6.3 제어관리 설정 항목
#### 트리거 타입
- **버튼 클릭 전 (before)**: 클릭 직전 실행
- **버튼 클릭 후 (after)**: 클릭 완료 후 실행
#### 액션 타입
- **INSERT**: 새로운 데이터 삽입
- **UPDATE**: 기존 데이터 수정
- **DELETE**: 데이터 삭제
#### 조건 설정
```typescript
// 예: 선택된 행의 상태가 '대기'인 경우에만 실행
{
field: "status",
operator: "=",
value: "대기",
dataType: "string"
}
```
#### 필드 매핑
```typescript
// 예: 소스 테이블의 값을 타겟 테이블로 이동
{
sourceTable: "order_master",
sourceField: "order_no",
targetTable: "order_history",
targetField: "order_no"
}
```
### 6.4 제어관리 예시: 수주 확정 버튼
**시나리오**: 수주 목록에서 3건 선택 후 [확정] 버튼 클릭
```
┌─────────────────────────────────────────────────────────────────┐
│ [확정] 버튼 클릭 │
├─────────────────────────────────────────────────────────────────┤
│ 1. 조건 체크: status = '대기' 인 행만 │
│ 2. UPDATE order_master SET status = '확정' WHERE id IN (선택) │
│ 3. INSERT order_history (수주이력 테이블에 기록) │
│ 4. 외부 시스템 호출 (ERP 연동) │
└─────────────────────────────────────────────────────────────────┘
```
**제어관리 설정**:
```json
{
"triggerType": "after",
"actions": [
{
"actionType": "update",
"targetTable": "order_master",
"conditions": [{ "field": "status", "operator": "=", "value": "대기" }],
"fieldMappings": [{ "targetField": "status", "defaultValue": "확정" }]
},
{
"actionType": "insert",
"targetTable": "order_history",
"fieldMappings": [
{ "sourceField": "order_no", "targetField": "order_no" },
{ "sourceField": "customer_name", "targetField": "customer_name" }
]
}
]
}
```
### 6.5 회사별 개발 시 제어관리 체크리스트
```
□ 버튼별 액션 정의
- 어떤 버튼이 있는가?
- 각 버튼 클릭 시 무슨 동작?
□ 저장/수정/삭제 대상 테이블
- 메인 테이블은?
- 이력 테이블은?
- 연관 테이블은?
□ 조건부 실행
- 특정 상태일 때만 실행?
- 특정 값 체크 필요?
□ 다중 행 처리
- 여러 행 선택 후 일괄 처리?
- 각 행별 개별 처리?
□ 외부 연동
- ERP/MES 등 외부 시스템 호출?
- API 연동 필요?
```
---
## 7. 회사별 커스터마이징 영역
### 7.1 컴포넌트로 처리되는 영역 (표준화)
| 영역 | 설명 |
|------|------|
| UI 레이아웃 | 컴포넌트 배치, 크기, 위치 |
| 검색 조건 | 화면 디자이너에서 설정 |
| 테이블 컬럼 | 표시/숨김, 순서, 너비 |
| 기본 CRUD | 조회, 저장, 삭제 자동 처리 |
| 페이지네이션 | 자동 처리 |
| 정렬/필터 | 자동 처리 |
### 7.2 회사별 개발 필요 영역
| 영역 | 설명 | 개발 방법 |
|------|------|----------|
| 비즈니스 로직 | 저장 전/후 검증, 계산 | 데이터플로우 또는 백엔드 API |
| 특수 UI | 간트, 트리, 차트 등 | 별도 컴포넌트 개발 |
| 외부 연동 | ERP, MES 등 연계 | 외부 호출 설정 |
| 리포트/인쇄 | 전표, 라벨 출력 | 리포트 컴포넌트 |
| 결재 프로세스 | 승인/반려 흐름 | 워크플로우 설정 |
---
## 8. 빠른 개발 가이드
### Step 1: 화면 분석
1. 어떤 테이블을 사용하는가?
2. 테이블 간 관계는?
3. 어떤 패턴인가? (A/B/C/D/E)
### Step 2: 컴포넌트 배치
1. 화면 디자이너에서 패턴에 맞는 컴포넌트 배치
2. 각 컴포넌트에 테이블/컬럼 설정
### Step 3: 연동 설정
1. 마스터-디테일 관계 설정 (FK)
2. 검색 조건 설정
3. 버튼 액션 설정
### Step 4: 테스트
1. 데이터 조회 확인
2. 마스터 선택 시 디테일 연동 확인
3. 저장/삭제 동작 확인
---
## 9. 요약
### V2 컴포넌트 커버리지
| 화면 유형 | 지원 여부 | 주요 컴포넌트 |
|-----------|----------|--------------|
| 기본 CRUD | ✅ 완전 | v2-table-list, v2-table-search-widget |
| 마스터-디테일 | ✅ 완전 | v2-split-panel-layout |
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
| 카드 뷰 | ✅ 완전 | v2-card-display |
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
| 그룹화 테이블 | ❌ 미지원 | 개발 필요 |
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
| 간트 차트 | ❌ 미지원 | 개발 필요 |
### 개발 시 핵심 원칙
1. **테이블 먼저**: DB 테이블 구조 확인이 최우선
2. **패턴 판단**: 5가지 패턴 중 어디에 해당하는지 판단
3. **표준 조합**: 검증된 컴포넌트 조합 사용
4. **한계 인식**: 불가능한 UI는 조기에 식별하여 별도 개발 계획
5. **멀티테넌시**: 모든 테이블에 company_code 필터링 필수
6. **제어관리 필수**: UI 완성 후 버튼별 비즈니스 로직 설정 필수
### UI vs 제어 구분
| 영역 | 담당 | 설정 위치 |
|------|------|----------|
| 화면 레이아웃 | V2 컴포넌트 | 화면 디자이너 |
| 비즈니스 로직 | 제어관리 | dataflow_diagrams |
| 외부 연동 | 외부호출 설정 | external_call_configs |
**HTML에서 배낄 수 있는 것**: UI 구조만
**별도 설정 필요한 것**: 저장 테이블, 버튼 액션, 조건 처리, 다중 행 처리

View File

@ -0,0 +1,255 @@
# BOM관리 화면 구현 가이드
> **화면명**: BOM관리
> **파일**: BOM관리.html
> **분류**: 기준정보
> **구현 가능**: ⚠️ 부분 (트리 뷰 컴포넌트 필요)
---
## 1. 화면 개요
BOM(Bill of Materials) 관리 화면으로, 제품의 부품 구성을 트리 구조로 관리합니다.
### 핵심 기능
- BOM 목록 조회/검색
- BOM 구조 트리 표시 (정전개/역전개)
- BOM 등록/수정/삭제
- 버전/차수 관리
- 엑셀 업로드/다운로드
---
## 2. 화면 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ [품목코드] [품목명] [품목구분▼] [버전▼] [사용여부▼] [초기화][조회]│
│ [사용자옵션][업로드][다운로드]│
├───────────────────────┬─────────────────────────────────────────┤
│ 📦 BOM 목록 │ 📋 BOM 상세정보 │
│ ─────────────── │ [이력] [버전] [수정] [삭제] │
│ [신규등록] │ ───────────────────────── │
│ ┌──────────────────┐ │ 품목코드: PRD-001 │
│ │□|코드|품목명|구분..│ │ 품목명: 제품A │
│ │□|P01|제품A |제품 │ │ 기준수량: 1 │
│ │□|P02|제품B |제품 │ ├─────────────────────────────────────────┤
│ └──────────────────┘ │ 🌳 BOM 구조 │
│ │ 기준수량:[1] [트리|레벨] [정전개|역전개] │
│ 리사이저 ↔ │ ───────────────────────── │
│ │ ▼ PRD-001 제품A (1.00 EA) │
│ │ ├─ MAT-001 원자재A (2.00 KG) │
│ │ └─ SEM-001 반제품A (1.00 EA) │
│ │ └─ MAT-002 원자재B (0.50 KG) │
└───────────────────────┴─────────────────────────────────────────┘
```
---
## 3. V2 컴포넌트 매핑
### 3.1 구현 가능 영역
| HTML 영역 | V2 컴포넌트 | 상태 |
|-----------|-------------|------|
| 검색 섹션 | `v2-table-search-widget` | ✅ 가능 |
| BOM 목록 테이블 | `v2-table-list` | ✅ 가능 |
| 분할 패널 | `v2-split-panel-layout` | ⚠️ 부분 |
### 3.2 신규 컴포넌트 필요
| HTML 영역 | 필요 컴포넌트 | 재활용 가능성 |
|-----------|---------------|--------------|
| BOM 트리 구조 | `v2-tree-view` | 3개 화면 (부서정보, 메뉴관리) |
| BOM 등록 모달 | `v2-modal-form` | 모든 화면 |
---
## 4. 테이블 정의
### 4.1 BOM 목록 테이블 (좌측)
```typescript
columns: [
{ id: 'checkbox', type: 'checkbox', width: 50 },
{ id: 'item_code', label: '품목코드', width: 100 },
{ id: 'item_name', label: '품목명', width: 150 },
{ id: 'item_type', label: '품목구분', width: 80 },
{ id: 'version', label: '버전', width: 70 },
{ id: 'revision', label: '차수', width: 70 },
{ id: 'status', label: '사용여부', width: 80 },
{ id: 'reg_date', label: '등록일', width: 100 }
]
```
### 4.2 BOM 상세 필드
```typescript
detailFields: [
{ id: 'item_code', label: '품목코드' },
{ id: 'item_name', label: '품목명' },
{ id: 'item_type', label: '품목구분' },
{ id: 'unit', label: '단위' },
{ id: 'base_qty', label: '기준수량' },
{ id: 'version', label: '버전' },
{ id: 'revision', label: '차수' },
{ id: 'status', label: '사용여부' },
{ id: 'remark', label: '비고' }
]
```
---
## 5. 검색 조건
| 필드명 | 컴포넌트 | 옵션 |
|--------|----------|------|
| 품목코드 | `v2-input` | placeholder: "품목코드" |
| 품목명 | `v2-input` | placeholder: "품목명" |
| 품목구분 | `v2-select` | 제품, 반제품, 원자재 |
| 버전 | `v2-select` | 1.0, 2.0, 3.0 |
| 사용여부 | `v2-select` | 사용, 미사용 |
---
## 6. 특수 기능: BOM 트리 (신규 컴포넌트 필요)
### 6.1 트리 노드 구조
```typescript
interface BomTreeNode {
id: string;
itemCode: string;
itemName: string;
itemType: string;
quantity: number;
unit: string;
level: number;
children: BomTreeNode[];
expanded: boolean;
}
```
### 6.2 정전개 vs 역전개
| 모드 | 설명 |
|------|------|
| 정전개 (Forward) | 선택 품목 → 하위 구성품목 표시 |
| 역전개 (Reverse) | 선택 품목 → 상위 사용처 표시 |
### 6.3 필요 인터랙션
- 노드 클릭: 펼치기/접기
- 전체 펼치기/접기 버튼
- 레벨 뷰/트리 뷰 전환
- 기준수량 변경 시 수량 재계산
---
## 7. 모달 폼 정의
### 7.1 BOM 등록 모달
```typescript
modalFields: [
{ id: 'item_code', label: '품목코드', type: 'autocomplete', required: true },
{ id: 'item_name', label: '품목명', type: 'autocomplete', required: true },
{ id: 'item_type', label: '품목구분', type: 'select', required: true },
{ id: 'unit', label: '단위', type: 'select', required: true },
{ id: 'base_qty', label: '기준수량', type: 'number', required: true },
{ id: 'version', label: '버전', type: 'text', readonly: true },
{ id: 'revision', label: '차수', type: 'text', readonly: true },
{ id: 'status', label: '사용여부', type: 'radio', options: ['사용', '미사용'] },
{ id: 'remark', label: '비고', type: 'textarea' }
]
// 하위 품목 섹션
childItemsSection: {
title: '하위 품목 구성',
addButton: '품목추가',
columns: [
{ id: 'item_code', label: '품목코드' },
{ id: 'item_name', label: '품목명' },
{ id: 'quantity', label: '소요량' },
{ id: 'unit', label: '단위' },
{ id: 'loss_rate', label: '로스율(%)' },
{ id: 'actions', label: '' }
]
}
```
---
## 8. 현재 구현 가능 범위
### ✅ 가능
- 검색 영역 (v2-table-search-widget)
- BOM 목록 테이블 (v2-table-list)
- 분할 패널 레이아웃 (v2-split-panel-layout)
- 기본 상세 정보 표시
### ⚠️ 부분 가능 (대체 구현)
- BOM 구조: 트리 대신 레벨 테이블로 표시 가능
### ❌ 불가능 (신규 개발 필요)
- 진정한 트리 뷰 (접기/펼치기)
- 정전개/역전개 전환
- 하위 품목 동적 추가 모달
---
## 9. 간소화 구현 JSON
```json
{
"screen_code": "BOM_MAIN",
"screen_name": "BOM관리",
"components": [
{
"type": "v2-table-search-widget",
"config": {
"searchFields": [
{ "type": "input", "id": "item_code", "placeholder": "품목코드" },
{ "type": "input", "id": "item_name", "placeholder": "품목명" },
{ "type": "select", "id": "item_type", "placeholder": "품목구분" },
{ "type": "select", "id": "status", "placeholder": "사용여부" }
],
"buttons": [
{ "label": "초기화", "action": "reset" },
{ "label": "조회", "action": "search", "variant": "primary" }
]
}
},
{
"type": "v2-split-panel-layout",
"config": {
"masterPanel": {
"title": "BOM 목록",
"entityId": "bom_header",
"columns": [
{ "id": "item_code", "label": "품목코드", "width": 100 },
{ "id": "item_name", "label": "품목명", "width": 150 },
{ "id": "item_type", "label": "품목구분", "width": 80 },
{ "id": "version", "label": "버전", "width": 70 }
]
},
"detailPanel": {
"title": "BOM 상세정보",
"entityId": "bom_detail",
"relationType": "one-to-many"
}
}
}
]
}
```
---
## 10. 개발 권장사항
1. **1단계**: 현재 컴포넌트로 기본 CRUD 구현
2. **2단계**: `v2-tree-view` 개발 후 BOM 구조 통합
3. **3단계**: 버전/차수 관리 기능 추가
**예상 재활용**: `v2-tree-view`는 부서정보, 메뉴관리에서도 사용 가능 (3개 화면)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,256 @@
# 거래처관리 화면 구현 가이드
> **화면명**: 거래처관리
> **파일**: 거래처관리.html
> **분류**: 영업관리
> **구현 가능**: ⚠️ 부분 (그룹화 테이블 필요)
---
## 1. 화면 개요
고객사 및 공급업체 정보를 통합 관리하는 화면입니다.
### 핵심 기능
- 거래처 목록 조회/검색
- 그룹화 기능 (거래처유형, 지역별)
- 거래처 등록/수정/삭제
- 거래처별 품목코드/단가 관리
- 담당자 정보 관리
---
## 2. 화면 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ [거래처코드] [거래처명] [거래처유형▼] [사용여부▼] [초기화][조회] │
│ [사용자옵션][업로드][다운로드]│
├───────────────────────┬─────────────────────────────────────────┤
│ 📋 거래처 목록 │ [기본정보][품목코드][단가정보][담당자] │
│ ───────────────── │ ───────────────────────────────────── │
│ Group by: [거래처유형▼] │ 거래처코드: C-001 │
│ ┌──────────────────┐ │ 거래처명: (주)테스트 │
│ │▼ 고객사 (15) │ │ 사업자번호: 123-45-67890 │
│ │ C-001 | A사 │ │ 대표자: 홍길동 │
│ │ C-002 | B사 │ ├─────────────────────────────────────────┤
│ │▼ 공급업체 (8) │ │ [품목코드 탭 내용] │
│ │ S-001 | 원자재사 │ │ ┌────────────────────────────────┐ │
│ └──────────────────┘ │ │거래처품목코드|품목명|자사품목코드│ │
│ │ │CP-001 |원료A |M-001 │ │
│ 리사이저 ↔ │ └────────────────────────────────┘ │
└───────────────────────┴─────────────────────────────────────────┘
```
---
## 3. V2 컴포넌트 매핑
| HTML 영역 | V2 컴포넌트 | 상태 |
|-----------|-------------|------|
| 검색 섹션 | `v2-table-search-widget` | ✅ 가능 |
| 거래처 목록 (그룹화) | `v2-table-list` | ⚠️ 그룹화 미지원 |
| 분할 패널 | `v2-split-panel-layout` | ✅ 가능 |
| 상세 탭 | `v2-tabs-widget` | ✅ 가능 |
---
## 4. 테이블 정의
### 4.1 거래처 목록
```typescript
columns: [
{ id: 'checkbox', type: 'checkbox', width: 50 },
{ id: 'customer_code', label: '거래처코드', width: 100 },
{ id: 'customer_name', label: '거래처명', width: 200 },
{ id: 'customer_type', label: '거래처유형', width: 100 },
{ id: 'business_no', label: '사업자번호', width: 120 },
{ id: 'ceo_name', label: '대표자', width: 100 },
{ id: 'tel', label: '전화번호', width: 120 },
{ id: 'status', label: '사용여부', width: 80 }
]
```
### 4.2 품목코드 탭
```typescript
itemCodeColumns: [
{ id: 'customer_item_code', label: '거래처품목코드', width: 150 },
{ id: 'item_name', label: '품목명', width: 200 },
{ id: 'our_item_code', label: '자사품목코드', width: 150 },
{ id: 'spec', label: '규격', width: 150 }
]
```
### 4.3 단가정보 탭
```typescript
priceColumns: [
{ id: 'item_code', label: '품목코드', width: 120 },
{ id: 'item_name', label: '품목명', width: 200 },
{ id: 'unit_price', label: '단가', width: 100, format: 'currency' },
{ id: 'currency', label: '통화', width: 60 },
{ id: 'apply_date', label: '적용일', width: 100 },
{ id: 'remark', label: '비고', width: 150 }
]
```
---
## 5. 그룹화 기능 (신규 컴포넌트 필요)
### 5.1 그룹화 옵션
```typescript
groupByOptions: [
{ id: 'customer_type', label: '거래처유형' },
{ id: 'region', label: '지역' },
{ id: 'status', label: '사용여부' }
]
```
### 5.2 그룹 헤더 표시
```
▼ 고객사 (15) ← 그룹명 + 건수
│ C-001 │ (주)A사 │ ...
│ C-002 │ (주)B사 │ ...
▼ 공급업체 (8)
│ S-001 │ 원자재사 │ ...
```
### 5.3 필요 인터랙션
- 그룹 접기/펼치기
- 그룹 전체 선택 체크박스
- 다중 그룹핑 (선택)
---
## 6. 탭 구성
```typescript
tabs: [
{
id: 'basic',
label: '기본정보',
fields: [
{ id: 'customer_code', label: '거래처코드' },
{ id: 'customer_name', label: '거래처명' },
{ id: 'customer_type', label: '거래처유형' },
{ id: 'business_no', label: '사업자번호' },
{ id: 'ceo_name', label: '대표자' },
{ id: 'address', label: '주소' },
{ id: 'tel', label: '전화번호' },
{ id: 'fax', label: '팩스' },
{ id: 'email', label: '이메일' }
]
},
{
id: 'item_codes',
label: '품목코드',
type: 'table',
entityId: 'customer_item_mapping'
},
{
id: 'prices',
label: '단가정보',
type: 'table',
entityId: 'customer_prices'
},
{
id: 'contacts',
label: '담당자',
type: 'table',
entityId: 'customer_contacts'
}
]
```
---
## 7. 현재 구현 가능 범위
### ✅ 가능
- 검색 영역
- 분할 패널 레이아웃
- 상세 탭
- 품목코드/단가/담당자 테이블
### ⚠️ 부분 가능
- 거래처 목록: 그룹화 없이 일반 테이블로 구현
### ❌ 불가능
- 동적 그룹화 (그룹 접기/펼치기)
---
## 8. 간소화 구현 JSON (그룹화 제외)
```json
{
"screen_code": "CUSTOMER_MAIN",
"screen_name": "거래처관리",
"components": [
{
"type": "v2-table-search-widget",
"config": {
"searchFields": [
{ "type": "input", "id": "customer_code", "placeholder": "거래처코드" },
{ "type": "input", "id": "customer_name", "placeholder": "거래처명" },
{ "type": "select", "id": "customer_type", "placeholder": "거래처유형",
"options": [
{ "value": "customer", "label": "고객사" },
{ "value": "supplier", "label": "공급업체" },
{ "value": "both", "label": "고객사/공급업체" }
]
},
{ "type": "select", "id": "status", "placeholder": "사용여부" }
]
}
},
{
"type": "v2-split-panel-layout",
"config": {
"masterPanel": {
"title": "거래처 목록",
"entityId": "customer_master",
"columns": [
{ "id": "customer_code", "label": "거래처코드", "width": 100 },
{ "id": "customer_name", "label": "거래처명", "width": 200 },
{ "id": "customer_type", "label": "거래처유형", "width": 100 },
{ "id": "ceo_name", "label": "대표자", "width": 100 }
]
},
"detailPanel": {
"tabs": [
{ "id": "basic", "label": "기본정보", "type": "form" },
{ "id": "items", "label": "품목코드", "type": "table", "entityId": "customer_items" },
{ "id": "prices", "label": "단가정보", "type": "table", "entityId": "customer_prices" },
{ "id": "contacts", "label": "담당자", "type": "table", "entityId": "customer_contacts" }
]
}
}
}
]
}
```
---
## 9. v2-grouped-table 개발 시 추가 구현
```typescript
// 그룹화 테이블 설정
groupedTableConfig: {
groupBy: 'customer_type',
groupByOptions: ['customer_type', 'region', 'status'],
showGroupCount: true,
expandAll: false,
groupCheckbox: true
}
```
**예상 재활용**: `v2-grouped-table`은 5개 이상 화면에서 사용 가능
- 거래처관리, 품목정보, 작업지시, 입출고관리, 견적관리

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,308 @@
# 견적관리 화면 구현 가이드
> **화면명**: 견적관리
> **파일**: 견적관리.html
> **분류**: 영업관리
> **구현 가능**: ✅ 완전 (현재 V2 컴포넌트)
---
## 1. 화면 개요
견적서 생성 및 관리 화면으로, 고객 요청에 대한 견적을 작성하고 수주로 전환합니다.
### 핵심 기능
- 견적 목록 조회/검색
- 견적 등록/수정/삭제
- 견적 상세 및 품목 내역 관리
- 견적서 인쇄/PDF 출력
- 수주 전환
---
## 2. 화면 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ [기간] [거래처] [견적번호] [품목명] [상태▼] [초기화][조회] │
│ [사용자옵션][업로드][다운로드]│
├───────────────────────┬─────────────────────────────────────────┤
│ 📋 견적 목록 │ 📄 견적 상세 │
│ ─────────────── │ [인쇄] [복사] [수주전환] [수정] [삭제] │
│ [신규등록] │ ───────────────────────── │
│ ┌──────────────────┐ │ 견적번호: QT-2026-0001 │
│ │견적번호|거래처|금액..│ │ 거래처: (주)테스트 │
│ │QT-001 |A사|1,000..│ │ 견적일: 2026-01-30 │
│ │QT-002 |B사|2,500..│ ├─────────────────────────────────────────┤
│ └──────────────────┘ │ [기본정보] [품목내역] [첨부파일] │
│ │ ─────────────────────────── │
│ 리사이저 ↔ │ │품목코드|품목명|수량|단가|금액|비고│ │
│ │ │P-001 |제품A|100|1,000|100,000| │ │
└───────────────────────┴─────────────────────────────────────────┘
```
---
## 3. V2 컴포넌트 매핑
| HTML 영역 | V2 컴포넌트 | 상태 |
|-----------|-------------|------|
| 검색 섹션 | `v2-table-search-widget` | ✅ 가능 |
| 견적 목록 | `v2-table-list` | ✅ 가능 |
| 분할 패널 | `v2-split-panel-layout` | ✅ 가능 |
| 상세 탭 | `v2-tabs-widget` | ✅ 가능 |
| 품목 내역 테이블 | `v2-table-list` | ✅ 가능 |
---
## 4. 테이블 정의
### 4.1 견적 목록 (좌측)
```typescript
columns: [
{ id: 'checkbox', type: 'checkbox', width: 50 },
{ id: 'quote_no', label: '견적번호', width: 120 },
{ id: 'quote_date', label: '견적일', width: 100 },
{ id: 'customer_name', label: '거래처', width: 150 },
{ id: 'total_amount', label: '견적금액', width: 120, align: 'right', format: 'currency' },
{ id: 'status', label: '상태', width: 80 },
{ id: 'valid_date', label: '유효기간', width: 100 },
{ id: 'manager', label: '담당자', width: 100 }
]
```
### 4.2 품목 내역 (우측 탭)
```typescript
detailColumns: [
{ id: 'seq', label: 'No', width: 50 },
{ id: 'item_code', label: '품목코드', width: 100 },
{ id: 'item_name', label: '품목명', width: 200 },
{ id: 'spec', label: '규격', width: 150 },
{ id: 'quantity', label: '수량', width: 80, align: 'right' },
{ id: 'unit', label: '단위', width: 60 },
{ id: 'unit_price', label: '단가', width: 100, align: 'right', format: 'currency' },
{ id: 'amount', label: '금액', width: 120, align: 'right', format: 'currency' },
{ id: 'remark', label: '비고', width: 150 }
]
```
---
## 5. 검색 조건
| 필드명 | 컴포넌트 | 설정 |
|--------|----------|------|
| 기간 | `v2-date` | dateRange: true |
| 거래처 | `v2-input` | placeholder: "거래처" |
| 견적번호 | `v2-input` | placeholder: "견적번호" |
| 품목명 | `v2-input` | placeholder: "품목명" |
| 상태 | `v2-select` | 작성중, 제출, 승인, 반려, 수주전환 |
---
## 6. 상세 탭 구성
```typescript
tabs: [
{
id: 'basic',
label: '기본정보',
fields: [
{ id: 'quote_no', label: '견적번호' },
{ id: 'quote_date', label: '견적일' },
{ id: 'customer_code', label: '거래처코드' },
{ id: 'customer_name', label: '거래처명' },
{ id: 'manager', label: '담당자' },
{ id: 'valid_date', label: '유효기간' },
{ id: 'delivery_date', label: '납기일' },
{ id: 'payment_term', label: '결제조건' },
{ id: 'remark', label: '비고' }
]
},
{
id: 'items',
label: '품목내역',
type: 'table',
entityId: 'quote_items'
},
{
id: 'files',
label: '첨부파일',
type: 'file-list'
}
]
```
---
## 7. 버튼 액션
### 7.1 목록 버튼
| 버튼 | 액션 |
|------|------|
| 신규등록 | 견적 등록 모달 열기 |
### 7.2 상세 버튼
| 버튼 | 액션 |
|------|------|
| 인쇄 | 견적서 PDF 출력 |
| 복사 | 선택 견적 복사하여 신규 생성 |
| 수주전환 | 견적 → 수주 데이터 생성 |
| 수정 | 견적 수정 모달 열기 |
| 삭제 | 견적 삭제 (확인 후) |
---
## 8. 구현 JSON
```json
{
"screen_code": "QUOTE_MAIN",
"screen_name": "견적관리",
"components": [
{
"type": "v2-table-search-widget",
"position": { "x": 0, "y": 0, "w": 12, "h": 2 },
"config": {
"searchFields": [
{ "type": "date", "id": "date_range", "placeholder": "기간", "dateRange": true },
{ "type": "input", "id": "customer_name", "placeholder": "거래처" },
{ "type": "input", "id": "quote_no", "placeholder": "견적번호" },
{ "type": "input", "id": "item_name", "placeholder": "품목명" },
{ "type": "select", "id": "status", "placeholder": "상태",
"options": [
{ "value": "draft", "label": "작성중" },
{ "value": "submitted", "label": "제출" },
{ "value": "approved", "label": "승인" },
{ "value": "rejected", "label": "반려" },
{ "value": "converted", "label": "수주전환" }
]
}
],
"buttons": [
{ "label": "초기화", "action": "reset", "variant": "outline" },
{ "label": "조회", "action": "search", "variant": "primary" }
],
"rightButtons": [
{ "label": "사용자옵션", "action": "userOptions", "variant": "outline" },
{ "label": "엑셀업로드", "action": "excelUpload", "variant": "outline" },
{ "label": "엑셀다운로드", "action": "excelDownload", "variant": "outline" }
]
}
},
{
"type": "v2-split-panel-layout",
"position": { "x": 0, "y": 2, "w": 12, "h": 10 },
"config": {
"masterPanel": {
"title": "견적 목록",
"entityId": "quote_header",
"buttons": [
{ "label": "신규등록", "action": "create", "variant": "primary" }
],
"columns": [
{ "id": "quote_no", "label": "견적번호", "width": 120 },
{ "id": "quote_date", "label": "견적일", "width": 100 },
{ "id": "customer_name", "label": "거래처", "width": 150 },
{ "id": "total_amount", "label": "견적금액", "width": 120, "align": "right" },
{ "id": "status", "label": "상태", "width": 80 },
{ "id": "manager", "label": "담당자", "width": 100 }
]
},
"detailPanel": {
"title": "견적 상세",
"buttons": [
{ "label": "인쇄", "action": "print", "variant": "outline" },
{ "label": "복사", "action": "copy", "variant": "outline" },
{ "label": "수주전환", "action": "convert", "variant": "secondary" },
{ "label": "수정", "action": "edit", "variant": "outline" },
{ "label": "삭제", "action": "delete", "variant": "destructive" }
],
"tabs": [
{
"id": "basic",
"label": "기본정보",
"type": "form"
},
{
"id": "items",
"label": "품목내역",
"type": "table",
"entityId": "quote_items",
"relationType": "one-to-many",
"relationKey": "quote_id"
},
{
"id": "files",
"label": "첨부파일",
"type": "file"
}
]
},
"defaultRatio": 40,
"resizable": true
}
}
]
}
```
---
## 9. 데이터베이스 테이블
### quote_header (견적 헤더)
```sql
CREATE TABLE quote_header (
id SERIAL PRIMARY KEY,
company_code VARCHAR(20) NOT NULL,
quote_no VARCHAR(50) NOT NULL,
quote_date DATE NOT NULL,
customer_code VARCHAR(50),
customer_name VARCHAR(200),
total_amount NUMERIC(15,2),
tax_amount NUMERIC(15,2),
status VARCHAR(20) DEFAULT 'draft',
valid_date DATE,
delivery_date DATE,
payment_term VARCHAR(100),
manager VARCHAR(100),
remark TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
### quote_items (견적 품목)
```sql
CREATE TABLE quote_items (
id SERIAL PRIMARY KEY,
company_code VARCHAR(20) NOT NULL,
quote_id INTEGER REFERENCES quote_header(id),
seq INTEGER,
item_code VARCHAR(50),
item_name VARCHAR(200),
spec VARCHAR(200),
quantity NUMERIC(15,3),
unit VARCHAR(20),
unit_price NUMERIC(15,2),
amount NUMERIC(15,2),
remark TEXT
);
```
---
## 10. 구현 체크리스트
- [x] 검색 영역: v2-table-search-widget
- [x] 분할 패널: v2-split-panel-layout
- [x] 목록 테이블: v2-table-list
- [x] 상세 탭: v2-tabs-widget
- [x] 품목 내역 테이블: v2-table-list (nested)
- [ ] 인쇄 기능: 별도 구현 필요
- [ ] 수주 전환: 비즈니스 로직 구현
**현재 V2 컴포넌트로 100% 구현 가능**

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,194 @@
# 작업지시 화면 구현 가이드
> **화면명**: 작업지시
> **파일**: 작업지시.html
> **분류**: 생산관리
> **구현 가능**: ⚠️ 부분 (그룹화 테이블 필요)
---
## 1. 화면 개요
생산계획을 기반으로 작업지시를 생성하고 관리하는 화면입니다.
### 핵심 기능
- 작업지시 목록 조회 (탭별 구분)
- 그룹화 기능 (작업일자, 공정별)
- 작업지시 생성/수정/삭제
- 작업지시서 인쇄
- 실적 연계
---
## 2. 화면 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ [기간] [품목] [공정] [작업상태▼] [초기화][조회] [사용자옵션][엑셀] │
├─────────────────────────────────────────────────────────────────┤
│ [전체] [대기] [진행중] [완료] [지연] │
├───────────────────────┬─────────────────────────────────────────┤
│ 📋 작업지시 목록 │ 📄 작업지시 상세 │
│ ─────────────── │ [인쇄] [시작] [완료] [수정] [삭제] │
│ Group by: [작업일자▼] │ ───────────────────────── │
│ ┌──────────────────┐ │ 지시번호: WO-2026-0001 │
│ │▼ 2026-01-30 (5) │ │ 품목명: 제품A │
│ │ WO-001|제품A|대기│ │ 지시수량: 100 EA │
│ │ WO-002|제품B|진행│ ├─────────────────────────────────────────┤
│ │▼ 2026-01-31 (3) │ │ [자재투입] [공정현황] [실적현황] │
│ │ WO-003|제품C|대기│ │ ─────────────────────────── │
│ └──────────────────┘ │ [투입자재 테이블] │
└───────────────────────┴─────────────────────────────────────────┘
```
---
## 3. V2 컴포넌트 매핑
| HTML 영역 | V2 컴포넌트 | 상태 |
|-----------|-------------|------|
| 검색 섹션 | `v2-table-search-widget` | ✅ 가능 |
| 상태 탭 | `v2-tabs-widget` | ✅ 가능 |
| 작업지시 목록 (그룹화) | `v2-table-list` | ⚠️ 그룹화 미지원 |
| 분할 패널 | `v2-split-panel-layout` | ✅ 가능 |
| 상세 탭 | `v2-tabs-widget` | ✅ 가능 |
---
## 4. 테이블 정의
### 4.1 작업지시 목록
```typescript
columns: [
{ id: 'checkbox', type: 'checkbox', width: 50 },
{ id: 'work_order_no', label: '지시번호', width: 120 },
{ id: 'work_date', label: '작업일', width: 100 },
{ id: 'item_code', label: '품목코드', width: 100 },
{ id: 'item_name', label: '품목명', width: 200 },
{ id: 'order_qty', label: '지시수량', width: 100, align: 'right' },
{ id: 'prod_qty', label: '생산수량', width: 100, align: 'right' },
{ id: 'process_name', label: '공정', width: 100 },
{ id: 'status', label: '상태', width: 80 },
{ id: 'worker', label: '작업자', width: 100 }
]
```
### 4.2 자재투입 탭
```typescript
materialColumns: [
{ id: 'item_code', label: '품목코드', width: 100 },
{ id: 'item_name', label: '품목명', width: 200 },
{ id: 'required_qty', label: '소요량', width: 100, align: 'right' },
{ id: 'issued_qty', label: '투입량', width: 100, align: 'right' },
{ id: 'unit', label: '단위', width: 60 },
{ id: 'warehouse', label: '출고창고', width: 100 }
]
```
---
## 5. 상태 탭
```typescript
statusTabs: [
{ id: 'all', label: '전체', count: 25 },
{ id: 'waiting', label: '대기', count: 10 },
{ id: 'progress', label: '진행중', count: 8 },
{ id: 'completed', label: '완료', count: 5 },
{ id: 'delayed', label: '지연', count: 2 }
]
```
---
## 6. 그룹화 기능 (v2-grouped-table 필요)
```typescript
groupByOptions: [
{ id: 'work_date', label: '작업일자' },
{ id: 'process_name', label: '공정' },
{ id: 'item_type', label: '품목구분' }
]
```
---
## 7. 현재 구현 가능 범위
### ✅ 가능
- 검색 영역
- 상태 탭 전환
- 분할 패널
- 상세 탭
- 자재투입/공정현황/실적현황 테이블
### ⚠️ 부분 가능
- 작업지시 목록: 그룹화 없이 일반 테이블
### ❌ 불가능
- 동적 그룹화
---
## 8. 구현 JSON
```json
{
"screen_code": "WORK_ORDER_MAIN",
"screen_name": "작업지시",
"components": [
{
"type": "v2-table-search-widget",
"position": { "x": 0, "y": 0, "w": 12, "h": 1 },
"config": {
"searchFields": [
{ "type": "date", "id": "date_range", "placeholder": "기간", "dateRange": true },
{ "type": "input", "id": "item_name", "placeholder": "품목명" },
{ "type": "select", "id": "process", "placeholder": "공정" },
{ "type": "select", "id": "status", "placeholder": "상태" }
]
}
},
{
"type": "v2-tabs-widget",
"position": { "x": 0, "y": 1, "w": 12, "h": 11 },
"config": {
"tabs": [
{ "id": "all", "label": "전체" },
{ "id": "waiting", "label": "대기" },
{ "id": "progress", "label": "진행중" },
{ "id": "completed", "label": "완료" },
{ "id": "delayed", "label": "지연" }
],
"tabContent": {
"type": "v2-split-panel-layout",
"config": {
"masterPanel": {
"title": "작업지시 목록",
"entityId": "work_order",
"columns": [
{ "id": "work_order_no", "label": "지시번호" },
{ "id": "work_date", "label": "작업일" },
{ "id": "item_name", "label": "품목명" },
{ "id": "order_qty", "label": "지시수량" },
{ "id": "status", "label": "상태" }
]
},
"detailPanel": {
"tabs": [
{ "id": "material", "label": "자재투입", "entityId": "work_order_material" },
{ "id": "process", "label": "공정현황", "entityId": "work_order_process" },
{ "id": "result", "label": "실적현황", "entityId": "work_order_result" }
]
}
}
}
}
}
]
}
```
**v2-grouped-table 개발 시 재활용 가능**

View File

@ -0,0 +1,172 @@
# 발주관리 화면 구현 가이드
> **화면명**: 발주관리
> **파일**: 발주관리.html
> **분류**: 구매관리
> **구현 가능**: ✅ 완전 (현재 V2 컴포넌트)
---
## 1. 화면 개요
자재/원자재 발주를 생성하고 관리하는 화면입니다.
### 핵심 기능
- 발주 목록 조회/검색
- 발주 등록/수정/삭제
- 발주서 인쇄
- 입고 연계
---
## 2. 화면 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ [기간] [공급업체] [발주번호] [품목명] [상태▼] [초기화][조회] │
│ [사용자옵션][OCR][엑셀] │
├─────────────────────────────────────────────────────────────────┤
│ 📋 발주 목록 [신규등록] │
│ ───────────────────────────────────────────────────────────── │
│ │□|발주번호 |발주일 |공급업체 |발주금액 |상태 |담당자│ │
│ │□|PO-2026..|2026-01-30|(주)원자재|5,000,000 |진행중|홍길동│ │
│ │□|PO-2026..|2026-01-29|(주)부품사|3,200,000 |완료 |김철수│ │
│ │□|PO-2026..|2026-01-28|(주)자재사|1,800,000 |진행중|이영희│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 3. V2 컴포넌트 매핑
| HTML 영역 | V2 컴포넌트 | 상태 |
|-----------|-------------|------|
| 검색 섹션 | `v2-table-search-widget` | ✅ 가능 |
| 발주 목록 | `v2-table-list` | ✅ 가능 |
| 발주 등록 모달 | `v2-modal-form` (필요) | ⚠️ 대체 가능 |
---
## 4. 테이블 정의
```typescript
columns: [
{ id: 'checkbox', type: 'checkbox', width: 50 },
{ id: 'po_no', label: '발주번호', width: 120 },
{ id: 'po_date', label: '발주일', width: 100 },
{ id: 'supplier_name', label: '공급업체', width: 200 },
{ id: 'total_amount', label: '발주금액', width: 120, align: 'right', format: 'currency' },
{ id: 'delivery_date', label: '납기일', width: 100 },
{ id: 'status', label: '상태', width: 80 },
{ id: 'receive_status', label: '입고상태', width: 100 },
{ id: 'manager', label: '담당자', width: 100 }
]
```
---
## 5. 검색 조건
| 필드명 | 컴포넌트 | 설정 |
|--------|----------|------|
| 기간 | `v2-date` | dateRange: true |
| 공급업체 | `v2-input` | placeholder: "공급업체" |
| 발주번호 | `v2-input` | placeholder: "발주번호" |
| 품목명 | `v2-input` | placeholder: "품목명" |
| 상태 | `v2-select` | 작성중, 발주, 부분입고, 입고완료 |
---
## 6. 구현 JSON
```json
{
"screen_code": "PO_MAIN",
"screen_name": "발주관리",
"components": [
{
"type": "v2-table-search-widget",
"position": { "x": 0, "y": 0, "w": 12, "h": 2 },
"config": {
"searchFields": [
{ "type": "date", "id": "date_range", "placeholder": "발주기간", "dateRange": true },
{ "type": "input", "id": "supplier_name", "placeholder": "공급업체" },
{ "type": "input", "id": "po_no", "placeholder": "발주번호" },
{ "type": "input", "id": "item_name", "placeholder": "품목명" },
{ "type": "select", "id": "status", "placeholder": "상태" }
],
"buttons": [
{ "label": "초기화", "action": "reset", "variant": "outline" },
{ "label": "조회", "action": "search", "variant": "primary" }
],
"rightButtons": [
{ "label": "사용자옵션", "action": "userOptions" },
{ "label": "OCR입력", "action": "ocr" },
{ "label": "엑셀다운로드", "action": "excelDownload" }
]
}
},
{
"type": "v2-table-list",
"position": { "x": 0, "y": 2, "w": 12, "h": 10 },
"config": {
"title": "발주 목록",
"entityId": "purchase_order",
"buttons": [
{ "label": "신규등록", "action": "create", "variant": "primary" }
],
"columns": [
{ "id": "po_no", "label": "발주번호", "width": 120 },
{ "id": "po_date", "label": "발주일", "width": 100 },
{ "id": "supplier_name", "label": "공급업체", "width": 200 },
{ "id": "total_amount", "label": "발주금액", "width": 120, "align": "right" },
{ "id": "delivery_date", "label": "납기일", "width": 100 },
{ "id": "status", "label": "상태", "width": 80 },
{ "id": "manager", "label": "담당자", "width": 100 }
],
"rowActions": [
{ "label": "상세", "action": "view" },
{ "label": "수정", "action": "edit" },
{ "label": "삭제", "action": "delete" }
]
}
}
]
}
```
---
## 7. 데이터베이스 테이블
### purchase_order (발주 헤더)
```sql
CREATE TABLE purchase_order (
id SERIAL PRIMARY KEY,
company_code VARCHAR(20) NOT NULL,
po_no VARCHAR(50) NOT NULL,
po_date DATE NOT NULL,
supplier_code VARCHAR(50),
supplier_name VARCHAR(200),
total_amount NUMERIC(15,2),
tax_amount NUMERIC(15,2),
status VARCHAR(20) DEFAULT 'draft',
delivery_date DATE,
manager VARCHAR(100),
remark TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
---
## 8. 구현 체크리스트
- [x] 검색 영역: v2-table-search-widget
- [x] 발주 목록 테이블: v2-table-list
- [x] 컬럼 정렬/필터
- [ ] 발주 등록 모달
- [ ] OCR 입력 기능 (별도)
- [ ] 인쇄 기능
**현재 V2 컴포넌트로 핵심 기능 구현 가능**

View File

@ -0,0 +1,244 @@
# 설비정보 화면 구현 가이드
> **화면명**: 설비정보
> **파일**: 설비정보.html
> **분류**: 설비관리
> **구현 가능**: ✅ 완전 (v2-card-display 활용)
---
## 1. 화면 개요
생산 설비의 기본정보 및 상태를 관리하는 화면입니다.
### 핵심 기능
- 설비 목록 조회 (카드 형태)
- 설비 등록/수정/삭제
- 설비 상세 정보 탭 관리
- 설비 이미지 관리
---
## 2. 화면 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ [설비코드] [설비명] [설비유형▼] [상태▼] [초기화][조회] │
│ [사용자옵션][업로드][다운로드]│
├───────────────────────┬─────────────────────────────────────────┤
│ 🏭 설비 목록 │ [기본정보][보전이력][점검이력][가동현황] │
│ ─────────────── │ ───────────────────────────────────── │
│ [신규등록] │ 설비코드: EQ-001 │
│ ┌──────────────────┐ │ 설비명: CNC 밀링머신 1호기 │
│ │ [이미지] EQ-001 │ │ 설비유형: 가공설비 │
│ │ CNC 밀링 [가동중] │ │ 상태: 가동중 │
│ ├──────────────────┤ │ 제조사: 현대공작기계 │
│ │ [이미지] EQ-002 │ ├─────────────────────────────────────────┤
│ │ 선반 1호 [점검중] │ │ [보전이력 테이블] │
│ ├──────────────────┤ │ │일자 |유형 |내용 |담당자│ │
│ │ [이미지] EQ-003 │ │ │2026-01|정기 |오일 교환 |김철수│ │
│ │ 프레스 [고장] │ │ │2026-01|수리 |베어링 교체 |이영희│ │
│ └──────────────────┘ │ │
└───────────────────────┴─────────────────────────────────────────┘
```
---
## 3. V2 컴포넌트 매핑
| HTML 영역 | V2 컴포넌트 | 상태 |
|-----------|-------------|------|
| 검색 섹션 | `v2-table-search-widget` | ✅ 가능 |
| 설비 카드 목록 | `v2-card-display` | ✅ 가능 |
| 분할 패널 | `v2-split-panel-layout` | ✅ 가능 |
| 상세 탭 | `v2-tabs-widget` | ✅ 가능 |
---
## 4. 설비 카드 구조
```typescript
interface EquipmentCard {
id: string;
image: string; // 설비 이미지 URL
code: string; // 설비코드
name: string; // 설비명
type: string; // 설비유형
status: 'running' | 'idle' | 'maintenance' | 'broken';
location: string;
}
// 상태별 스타일
statusStyles: {
running: { bg: '#d1fae5', color: '#065f46', label: '가동중' },
idle: { bg: '#e5e7eb', color: '#374151', label: '대기중' },
maintenance: { bg: '#fef3c7', color: '#92400e', label: '점검중' },
broken: { bg: '#fee2e2', color: '#991b1b', label: '고장' }
}
```
---
## 5. 상세 탭 구성
```typescript
tabs: [
{
id: 'basic',
label: '기본정보',
fields: [
{ id: 'eq_code', label: '설비코드' },
{ id: 'eq_name', label: '설비명' },
{ id: 'eq_type', label: '설비유형' },
{ id: 'status', label: '상태' },
{ id: 'manufacturer', label: '제조사' },
{ id: 'model', label: '모델명' },
{ id: 'serial_no', label: '시리얼번호' },
{ id: 'install_date', label: '설치일' },
{ id: 'location', label: '설치위치' },
{ id: 'manager', label: '담당자' }
]
},
{
id: 'maintenance',
label: '보전이력',
type: 'table',
entityId: 'equipment_maintenance',
columns: [
{ id: 'date', label: '일자' },
{ id: 'type', label: '유형' },
{ id: 'content', label: '내용' },
{ id: 'worker', label: '담당자' },
{ id: 'cost', label: '비용' }
]
},
{
id: 'inspection',
label: '점검이력',
type: 'table',
entityId: 'equipment_inspection'
},
{
id: 'operation',
label: '가동현황',
type: 'chart' // 향후 확장
}
]
```
---
## 6. 검색 조건
| 필드명 | 컴포넌트 | 옵션 |
|--------|----------|------|
| 설비코드 | `v2-input` | placeholder: "설비코드" |
| 설비명 | `v2-input` | placeholder: "설비명" |
| 설비유형 | `v2-select` | 가공설비, 조립설비, 검사설비 등 |
| 상태 | `v2-select` | 가동중, 대기중, 점검중, 고장 |
---
## 7. 현재 구현 가능 범위
### ✅ 가능
- 검색 영역: `v2-table-search-widget`
- 설비 카드 목록: `v2-card-display` (이미지+정보 조합 지원)
- 분할 패널 레이아웃: `v2-split-panel-layout`
- 상세 탭: `v2-tabs-widget`
- 보전이력/점검이력 테이블: `v2-table-list`
### ⚠️ 부분 가능
- 가동현황 차트: 별도 차트 컴포넌트 필요
---
## 8. 테이블 대체 구현 JSON
```json
{
"screen_code": "EQUIPMENT_MAIN",
"screen_name": "설비정보",
"components": [
{
"type": "v2-table-search-widget",
"position": { "x": 0, "y": 0, "w": 12, "h": 2 },
"config": {
"searchFields": [
{ "type": "input", "id": "eq_code", "placeholder": "설비코드" },
{ "type": "input", "id": "eq_name", "placeholder": "설비명" },
{ "type": "select", "id": "eq_type", "placeholder": "설비유형" },
{ "type": "select", "id": "status", "placeholder": "상태",
"options": [
{ "value": "running", "label": "가동중" },
{ "value": "idle", "label": "대기중" },
{ "value": "maintenance", "label": "점검중" },
{ "value": "broken", "label": "고장" }
]
}
]
}
},
{
"type": "v2-split-panel-layout",
"position": { "x": 0, "y": 2, "w": 12, "h": 10 },
"config": {
"masterPanel": {
"title": "설비 목록",
"entityId": "equipment",
"buttons": [
{ "label": "신규등록", "action": "create", "variant": "primary" }
],
"columns": [
{ "id": "eq_code", "label": "설비코드", "width": 100 },
{ "id": "eq_name", "label": "설비명", "width": 200 },
{ "id": "eq_type", "label": "설비유형", "width": 100 },
{ "id": "status", "label": "상태", "width": 80 },
{ "id": "location", "label": "설치위치", "width": 150 }
]
},
"detailPanel": {
"tabs": [
{ "id": "basic", "label": "기본정보", "type": "form" },
{ "id": "maintenance", "label": "보전이력", "type": "table", "entityId": "eq_maintenance" },
{ "id": "inspection", "label": "점검이력", "type": "table", "entityId": "eq_inspection" },
{ "id": "operation", "label": "가동현황", "type": "custom" }
]
}
}
}
]
}
```
---
## 9. v2-card-display 설정 예시
`v2-card-display`는 이미 존재하는 컴포넌트입니다.
```typescript
// v2-card-display 설정
cardDisplayConfig: {
cardsPerRow: 3,
cardSpacing: 16,
cardStyle: {
showTitle: true, // eq_name 표시
showSubtitle: true, // eq_code 표시
showDescription: true,
showImage: true, // 설비 이미지 표시
showActions: true,
imagePosition: "top",
imageSize: "medium",
},
columnMapping: {
title: "eq_name",
subtitle: "eq_code",
image: "image_url",
status: "status"
},
dataSource: "table"
}
```
**현재 V2 컴포넌트로 완전 구현 가능**

View File

@ -0,0 +1,179 @@
# 입출고관리 화면 구현 가이드
> **화면명**: 입출고관리
> **파일**: 입출고관리.html
> **분류**: 물류관리
> **구현 가능**: ⚠️ 부분 (그룹화 테이블 필요)
---
## 1. 화면 개요
자재/제품의 입고 및 출고 내역을 통합 관리하는 화면입니다.
### 핵심 기능
- 입출고 내역 조회/검색
- 그룹화 기능 (입출고구분, 창고, 카테고리별)
- 엑셀 업로드/다운로드
---
## 2. 화면 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ [입출고구분▼][카테고리▼][창고▼][품목코드][품목명][기간][초기화][검색]│
│ [사용자옵션][업로드][다운로드]│
├─────────────────────────────────────────────────────────────────┤
│ 📋 입출고 내역 전체 150건 │
│ ───────────────────────────────────────────────────────────── │
│ Group by: [입출고구분▼] │
│ ───────────────────────────────────────────────────────────── │
│ │▼ 입고 (80) │
│ │ │IN-001|구매입고|2026-01-30|본사창고|P-001|원자재A|100|KG │
│ │ │IN-002|생산입고|2026-01-30|제1창고|P-002|제품A |50 |EA │
│ │▼ 출고 (70) │
│ │ │OUT-001|판매출고|2026-01-30|본사창고|P-003|제품B|30|EA │
└─────────────────────────────────────────────────────────────────┘
```
---
## 3. V2 컴포넌트 매핑
| HTML 영역 | V2 컴포넌트 | 상태 |
|-----------|-------------|------|
| 검색 섹션 | `v2-table-search-widget` | ✅ 가능 |
| 입출고 목록 (그룹화) | `v2-table-list` | ⚠️ 그룹화 미지원 |
---
## 4. 테이블 정의
```typescript
columns: [
{ id: 'checkbox', type: 'checkbox', width: 50 },
{ id: 'inout_type', label: '입출고구분', width: 100 },
{ id: 'category', label: '카테고리', width: 120 },
{ id: 'doc_no', label: '전표번호', width: 120 },
{ id: 'process_date', label: '처리일자', width: 100 },
{ id: 'warehouse', label: '창고', width: 120 },
{ id: 'location', label: '위치', width: 100 },
{ id: 'item_code', label: '품목코드', width: 120 },
{ id: 'item_name', label: '품목명', width: 200 },
{ id: 'quantity', label: '수량', width: 100, align: 'right' },
{ id: 'unit', label: '단위', width: 60 },
{ id: 'lot_no', label: '로트번호', width: 120 },
{ id: 'customer', label: '거래처', width: 120 },
{ id: 'manager', label: '담당자', width: 100 },
{ id: 'remark', label: '비고', width: 200 }
]
```
---
## 5. 검색 조건
| 필드명 | 컴포넌트 | 옵션 |
|--------|----------|------|
| 입출고구분 | `v2-select` | 입고, 출고 |
| 카테고리 | `v2-select` | 구매입고, 생산입고, 반품입고, 판매출고, 생산출고 등 |
| 창고 | `v2-select` | 본사창고, 제1창고, 제2창고 |
| 품목코드 | `v2-input` | - |
| 품목명 | `v2-input` | - |
| 기간 | `v2-date` | dateRange: true |
---
## 6. 그룹화 기능 (v2-grouped-table 필요)
```typescript
groupByOptions: [
{ id: 'inout_type', label: '입출고구분' },
{ id: 'category', label: '카테고리' },
{ id: 'warehouse', label: '창고' },
{ id: 'item_code', label: '품목코드' },
{ id: 'process_date', label: '처리일자' },
{ id: 'customer', label: '거래처' }
]
```
---
## 7. 현재 구현 가능 범위
### ✅ 가능
- 검색 영역
- 일반 테이블 목록
### ⚠️ 부분 가능
- 그룹화 없이 필터로 대체
### ❌ 불가능
- 동적 그룹화
---
## 8. 간소화 구현 JSON
```json
{
"screen_code": "INOUT_MAIN",
"screen_name": "입출고관리",
"components": [
{
"type": "v2-table-search-widget",
"position": { "x": 0, "y": 0, "w": 12, "h": 2 },
"config": {
"searchFields": [
{ "type": "select", "id": "inout_type", "placeholder": "입출고구분",
"options": [
{ "value": "IN", "label": "입고" },
{ "value": "OUT", "label": "출고" }
]
},
{ "type": "select", "id": "category", "placeholder": "카테고리",
"options": [
{ "value": "purchase", "label": "구매입고" },
{ "value": "production_in", "label": "생산입고" },
{ "value": "return_in", "label": "반품입고" },
{ "value": "sales", "label": "판매출고" },
{ "value": "production_out", "label": "생산출고" }
]
},
{ "type": "select", "id": "warehouse", "placeholder": "창고" },
{ "type": "input", "id": "item_code", "placeholder": "품목코드" },
{ "type": "input", "id": "item_name", "placeholder": "품목명" },
{ "type": "date", "id": "date_range", "placeholder": "처리일자", "dateRange": true }
],
"buttons": [
{ "label": "초기화", "action": "reset" },
{ "label": "검색", "action": "search", "variant": "primary" }
]
}
},
{
"type": "v2-table-list",
"position": { "x": 0, "y": 2, "w": 12, "h": 10 },
"config": {
"title": "입출고 내역",
"entityId": "inventory_transaction",
"showTotalCount": true,
"columns": [
{ "id": "inout_type", "label": "입출고구분", "width": 100 },
{ "id": "category", "label": "카테고리", "width": 120 },
{ "id": "doc_no", "label": "전표번호", "width": 120 },
{ "id": "process_date", "label": "처리일자", "width": 100 },
{ "id": "warehouse", "label": "창고", "width": 120 },
{ "id": "item_code", "label": "품목코드", "width": 120 },
{ "id": "item_name", "label": "품목명", "width": 200 },
{ "id": "quantity", "label": "수량", "width": 100, "align": "right" },
{ "id": "unit", "label": "단위", "width": 60 }
]
}
}
]
}
```
**v2-grouped-table 개발 시 그룹화 기능 추가 가능**

View File

@ -0,0 +1,169 @@
# 검사정보관리 화면 구현 가이드
> **화면명**: 검사정보관리
> **파일**: 검사정보관리.html
> **분류**: 품질관리
> **구현 가능**: ✅ 완전 (현재 V2 컴포넌트)
---
## 1. 화면 개요
품질 검사 결과를 등록하고 관리하는 화면입니다.
### 핵심 기능
- 검사 유형별 탭 (수입검사, 공정검사, 출하검사)
- 검사 결과 등록/수정
- 불량 처리 연계
- 검사 이력 관리
---
## 2. 화면 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ [기간] [품목] [거래처] [검사결과▼] [초기화][조회] [사용자옵션][엑셀]│
├─────────────────────────────────────────────────────────────────┤
│ [🔍수입검사(25)][⚙️공정검사(18)][📦출하검사(12)] │
├─────────────────────────────────────────────────────────────────┤
│ 📋 수입검사 목록 [신규등록] │
│ ───────────────────────────────────────────────────────────── │
│ │□|검사번호 |검사일 |품목명 |검사수량|합격수량|불량수량|결과│
│ │□|IQC-001 |2026-01-30|원자재A |100 |98 |2 |합격│
│ │□|IQC-002 |2026-01-30|원자재B |200 |195 |5 |합격│
│ │□|IQC-003 |2026-01-29|부품C |50 |30 |20 |불합격│
└─────────────────────────────────────────────────────────────────┘
```
---
## 3. V2 컴포넌트 매핑
| HTML 영역 | V2 컴포넌트 | 상태 |
|-----------|-------------|------|
| 검색 섹션 | `v2-table-search-widget` | ✅ 가능 |
| 검사유형 탭 | `v2-tabs-widget` | ✅ 가능 |
| 검사 목록 | `v2-table-list` | ✅ 가능 |
---
## 4. 탭 구성
```typescript
tabs: [
{ id: 'incoming', label: '수입검사', icon: '🔍', count: 25 },
{ id: 'process', label: '공정검사', icon: '⚙️', count: 18 },
{ id: 'shipping', label: '출하검사', icon: '📦', count: 12 }
]
```
---
## 5. 테이블 정의
```typescript
columns: [
{ id: 'checkbox', type: 'checkbox', width: 50 },
{ id: 'inspect_no', label: '검사번호', width: 120 },
{ id: 'inspect_date', label: '검사일', width: 100 },
{ id: 'item_code', label: '품목코드', width: 100 },
{ id: 'item_name', label: '품목명', width: 200 },
{ id: 'lot_no', label: '로트번호', width: 120 },
{ id: 'inspect_qty', label: '검사수량', width: 100, align: 'right' },
{ id: 'pass_qty', label: '합격수량', width: 100, align: 'right' },
{ id: 'fail_qty', label: '불량수량', width: 100, align: 'right' },
{ id: 'result', label: '결과', width: 80 },
{ id: 'inspector', label: '검사자', width: 100 }
]
```
---
## 6. 검색 조건
| 필드명 | 컴포넌트 | 설정 |
|--------|----------|------|
| 기간 | `v2-date` | dateRange: true |
| 품목 | `v2-input` | placeholder: "품목" |
| 거래처 | `v2-input` | placeholder: "거래처" |
| 검사결과 | `v2-select` | 전체, 합격, 불합격, 조건부합격 |
---
## 7. 구현 JSON
```json
{
"screen_code": "INSPECTION_MAIN",
"screen_name": "검사정보관리",
"components": [
{
"type": "v2-table-search-widget",
"position": { "x": 0, "y": 0, "w": 12, "h": 2 },
"config": {
"searchFields": [
{ "type": "date", "id": "date_range", "placeholder": "검사기간", "dateRange": true },
{ "type": "input", "id": "item_name", "placeholder": "품목" },
{ "type": "input", "id": "supplier", "placeholder": "거래처" },
{ "type": "select", "id": "result", "placeholder": "검사결과",
"options": [
{ "value": "pass", "label": "합격" },
{ "value": "fail", "label": "불합격" },
{ "value": "conditional", "label": "조건부합격" }
]
}
],
"buttons": [
{ "label": "초기화", "action": "reset" },
{ "label": "조회", "action": "search", "variant": "primary" }
]
}
},
{
"type": "v2-tabs-widget",
"position": { "x": 0, "y": 2, "w": 12, "h": 10 },
"config": {
"tabs": [
{ "id": "incoming", "label": "수입검사" },
{ "id": "process", "label": "공정검사" },
{ "id": "shipping", "label": "출하검사" }
],
"tabContent": {
"type": "v2-table-list",
"config": {
"entityId": "inspection",
"filterByTab": true,
"tabFilterField": "inspect_type",
"buttons": [
{ "label": "신규등록", "action": "create", "variant": "primary" }
],
"columns": [
{ "id": "inspect_no", "label": "검사번호", "width": 120 },
{ "id": "inspect_date", "label": "검사일", "width": 100 },
{ "id": "item_name", "label": "품목명", "width": 200 },
{ "id": "lot_no", "label": "로트번호", "width": 120 },
{ "id": "inspect_qty", "label": "검사수량", "width": 100 },
{ "id": "pass_qty", "label": "합격수량", "width": 100 },
{ "id": "fail_qty", "label": "불량수량", "width": 100 },
{ "id": "result", "label": "결과", "width": 80 }
]
}
}
}
}
]
}
```
---
## 8. 구현 체크리스트
- [x] 검색 영역: v2-table-search-widget
- [x] 검사유형 탭: v2-tabs-widget
- [x] 검사 목록 테이블: v2-table-list
- [ ] 검사 등록 모달
- [ ] 불량 처리 연계
**현재 V2 컴포넌트로 핵심 기능 구현 가능**

View File

@ -0,0 +1,159 @@
# 화면 구현 가이드
V2 컴포넌트를 활용한 ERP 화면 구현 가이드입니다.
---
## 전체 화면 분석 요약 (2026-01-30)
### 컴포넌트 커버리지
| 구분 | 화면 수 | 비율 |
|------|--------|------|
| 현재 즉시 구현 가능 | 17개 | 65% |
| v2-grouped-table 추가 시 | 22개 | 85% |
| v2-tree-view 추가 시 | 24개 | 92% |
| 별도 개발 필요 | 2개 | 8% |
### 신규 컴포넌트 개발 우선순위
| 순위 | 컴포넌트 | 재활용 화면 수 | ROI |
|------|----------|--------------|-----|
| 1 | v2-grouped-table | 5+ | 높음 |
| 2 | v2-tree-view | 3 | 중간 |
| 3 | v2-timeline-scheduler | 1~2 | 낮음 |
> **참고**: 화면 디자이너에서 폼 배치가 자체 규격으로 처리되므로 별도 모달/폼 컴포넌트 불필요.
> `v2-card-display`는 이미 존재합니다.
> 상세 분석: [full-screen-analysis.md](./00_analysis/full-screen-analysis.md)
---
## 폴더 구조
```
screen-implementation-guide/
├── 00_analysis/ # 전체 분석
│ └── full-screen-analysis.md # 화면 전체 분석 보고서
├── 01_master-data/ # 기준정보
│ ├── item-info.md # 품목정보 ✅
│ ├── bom.md # BOM관리 ⚠️
│ ├── company-info.md # 회사정보
│ ├── department.md # 부서관리
│ └── options.md # 옵션설정
├── 02_sales/ # 영업관리
│ ├── order.md # 수주관리 ✅
│ ├── quote.md # 견적관리 ✅
│ ├── customer.md # 거래처관리 ⚠️
│ ├── sales-item.md # 판매품목정보
│ └── options.md # 영업옵션설정
├── 03_production/ # 생산관리
│ ├── production-plan.md # 생산계획관리 ❌
│ ├── work-order.md # 작업지시 ⚠️
│ ├── production-result.md # 생산실적
│ ├── process-info.md # 공정정보관리
│ └── options.md # 생산옵션설정
├── 04_purchase/ # 구매관리
│ ├── purchase-order.md # 발주관리 ✅
│ ├── purchase-item.md # 구매품목정보
│ ├── supplier.md # 공급업체관리
│ ├── receiving.md # 입고관리
│ └── options.md # 구매옵션설정
├── 05_equipment/ # 설비관리
│ ├── equipment-info.md # 설비정보 ✅
│ └── options.md # 설비옵션설정
├── 06_logistics/ # 물류관리
│ ├── inout.md # 입출고관리 ⚠️
│ ├── logistics-info.md # 물류정보관리
│ ├── inventory.md # 재고현황
│ ├── warehouse.md # 창고정보관리
│ ├── shipping.md # 출고관리
│ └── options.md # 물류옵션설정
├── 07_quality/ # 품질관리
│ ├── inspection.md # 검사정보관리 ✅
│ ├── item-inspection.md # 품목검사정보
│ └── options.md # 품질옵션설정
└── README.md
# 범례: ✅ 완전구현 | ⚠️ 부분구현 | ❌ 신규개발필요
```
## 문서 작성 형식
각 화면별 문서는 다음 구조로 작성됩니다:
### 1. 테이블 선택 및 화면 구조
- 사용할 데이터베이스 테이블
- 테이블 간 관계 (FK, 조인)
- 화면 전체 레이아웃
### 2. 컴포넌트 배치도
- ASCII 다이어그램으로 컴포넌트 배치
- 각 영역별 사용 컴포넌트 명시
### 3. 각 컴포넌트별 설정
- 컴포넌트 타입
- 상세 설정 (config)
- 연동 설정
### 4. 사용자 사용 예시 시나리오
- 테스트 시나리오
- 기대 동작
- 검증 포인트
## 메뉴별 Screen ID 매핑
| 메뉴 | Screen ID | 상태 |
|------|-----------|------|
| **기준정보** | | |
| 회사정보 | /screens/138 | 활성화 |
| 부서관리 | /screens/1487 | 활성화 |
| 품목정보 | /screens/140 | 활성화 |
| 옵션설정 | /screens/1421 | 활성화 |
| **영업관리** | | |
| 견적관리 | - | 활성화 |
| 수주관리 | /screens/156 | 활성화 |
| 거래처관리 | - | 활성화 |
| 판매품목정보 | - | 활성화 |
| 영업옵션설정 | /screens/1552 | 활성화 |
| **생산관리** | | |
| 생산계획 | - | 활성화 |
| 작업지시 | - | 활성화 |
| 생산실적 | - | 활성화 |
| 공정정보관리 | /screens/1599 | 활성화 |
| BOM관리 | - | 활성화 |
| 생산옵션설정 | /screens/1606 | 활성화 |
| **구매관리** | | |
| 발주관리 | /screens/1244 | 활성화 |
| 구매품목정보 | /screens/1061 | 활성화 |
| 공급업체관리 | /screens/1053 | 활성화 |
| 입고관리 | /screens/1064 | 활성화 |
| 구매옵션설정 | /screens/1057 | 활성화 |
| **설비관리** | | |
| 설비정보 | /screens/1253 | 활성화 |
| 설비옵션설정 | /screens/1264 | 활성화 |
| **물류관리** | | |
| 물류정보관리 | /screens/1556 | 활성화 |
| 입출고관리 | - | 활성화 |
| 재고현황 | /screens/1587 | 활성화 |
| 창고정보관리 | /screens/1562 | 활성화 |
| 출고관리 | /screens/2296 | 활성화 |
| 물류옵션설정 | /screens/1559 | 활성화 |
| **품질관리** | | |
| 검사정보관리 | /screens/1616 | 활성화 |
| 품목검사정보 | /screens/2089 | 활성화 |
| 품질옵션설정 | /screens/1622 | 활성화 |
## 참고 문서
- [V2 컴포넌트 분석 가이드](../V2_컴포넌트_분석_가이드.md)
- [V2 컴포넌트 연동 가이드](../V2_컴포넌트_연동_가이드.md)

View File

@ -0,0 +1,572 @@
# Screen Development Standard Guide (AI Agent Reference)
> **Purpose**: Ensure consistent screen development output regardless of who develops it
> **Target**: AI Agents (Cursor, etc.), Developers
> **Version**: 1.0.0
---
## CRITICAL RULES
1. **ONLY use V2 components** (components with `v2-` prefix)
2. **SEPARATE UI and Logic**: UI in `screen_layouts_v2`, Logic in `dataflow_diagrams`
3. **ALWAYS apply company_code filtering** (multi-tenancy)
---
## AVAILABLE V2 COMPONENTS (23 total)
### Input Components
| ID | Name | Purpose |
|----|------|---------|
| `v2-input` | Input | text, number, password, email, tel, url, textarea |
| `v2-select` | Select | dropdown, combobox, radio, checkbox |
| `v2-date` | Date | date, time, datetime, daterange, month, year |
### Display Components
| ID | Name | Purpose |
|----|------|---------|
| `v2-text-display` | Text Display | labels, titles |
| `v2-card-display` | Card Display | table data as cards |
| `v2-aggregation-widget` | Aggregation Widget | sum, avg, count, min, max |
### Table/Data Components
| ID | Name | Purpose |
|----|------|---------|
| `v2-table-list` | Table List | data grid with CRUD |
| `v2-table-search-widget` | Search Widget | table search/filter |
| `v2-pivot-grid` | Pivot Grid | multi-dimensional analysis |
### Layout Components
| ID | Name | Purpose |
|----|------|---------|
| `v2-split-panel-layout` | Split Panel | master-detail layout |
| `v2-tabs-widget` | Tabs Widget | tab navigation |
| `v2-section-card` | Section Card | titled grouping container |
| `v2-section-paper` | Section Paper | background grouping |
| `v2-divider-line` | Divider | area separator |
| `v2-repeat-container` | Repeat Container | data-driven repeat |
| `v2-repeater` | Repeater | repeat control |
| `v2-repeat-screen-modal` | Repeat Screen Modal | modal repeat |
### Action/Special Components
| ID | Name | Purpose |
|----|------|---------|
| `v2-button-primary` | Primary Button | save, delete, etc. |
| `v2-numbering-rule` | Numbering Rule | auto code generation |
| `v2-category-manager` | Category Manager | category management |
| `v2-location-swap-selector` | Location Swap | location selection |
| `v2-rack-structure` | Rack Structure | warehouse rack visualization |
| `v2-media` | Media | image/video display |
---
## SCREEN PATTERNS (5 types)
### Pattern A: Basic Master Screen
**When**: Single table CRUD
**Components**:
```
v2-table-search-widget
v2-table-list
v2-button-primary
```
### Pattern B: Master-Detail Screen
**When**: Master selection → Detail display
**Components**:
```
v2-split-panel-layout
├─ left: v2-table-list (master)
└─ right: v2-table-list (detail)
```
**Required Config**:
```json
{
"leftPanel": { "tableName": "master_table" },
"rightPanel": {
"tableName": "detail_table",
"relation": { "type": "detail", "foreignKey": "master_id" }
},
"splitRatio": 30
}
```
### Pattern C: Master-Detail + Tabs
**When**: Master selection → Multiple tabs
**Components**:
```
v2-split-panel-layout
├─ left: v2-table-list (master)
└─ right: v2-tabs-widget
```
### Pattern D: Card View
**When**: Image + info card display
**Components**:
```
v2-table-search-widget
v2-card-display
```
**Required Config**:
```json
{
"cardsPerRow": 3,
"columnMapping": {
"title": "name",
"subtitle": "code",
"image": "image_url"
}
}
```
### Pattern E: Pivot Analysis
**When**: Multi-dimensional aggregation
**Components**:
```
v2-pivot-grid
```
---
## DATABASE TABLES
### Screen Definition
```sql
-- screen_definitions: Screen basic info
INSERT INTO screen_definitions (
screen_name, screen_code, description, table_name, company_code
) VALUES (...) RETURNING screen_id;
-- screen_layouts_v2: UI layout (JSON)
INSERT INTO screen_layouts_v2 (
screen_id, company_code, layout_data
) VALUES (...);
-- screen_menu_assignments: Menu connection
INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code
) VALUES (...);
```
### Control Management (Business Logic)
```sql
-- dataflow_diagrams: Business logic
INSERT INTO dataflow_diagrams (
diagram_name, company_code, control, plan
) VALUES (...);
```
---
## UI SETTING vs BUSINESS LOGIC
### UI Setting (Screen Designer)
| Item | Storage |
|------|---------|
| Component placement | screen_layouts_v2.layout_data |
| Table name | layout_data.tableName |
| Column visibility | layout_data.columns |
| Search fields | layout_data.searchFields |
| Basic save/delete | button config.action.type |
### Business Logic (Control Management)
| Item | Storage |
|------|---------|
| Conditional execution | dataflow_diagrams.control |
| Multi-table save | dataflow_diagrams.plan |
| Before/after trigger | control.triggerType |
| Field mapping | plan.mappings |
---
## BUSINESS LOGIC JSON STRUCTURE
### Control (Conditions)
```json
{
"control": {
"actionType": "update|insert|delete",
"triggerType": "before|after",
"conditions": [
{
"id": "unique-id",
"type": "condition",
"field": "column_name",
"operator": "=|!=|>|<|>=|<=|LIKE|IN|IS NULL",
"value": "compare_value",
"dataType": "string|number|date|boolean"
}
]
}
}
```
### Plan (Actions)
```json
{
"plan": {
"actions": [
{
"id": "action-id",
"actionType": "update|insert|delete",
"targetTable": "table_name",
"fieldMappings": [
{
"sourceField": "source_column",
"targetField": "target_column",
"defaultValue": "static_value",
"valueType": "field|static"
}
]
}
]
}
}
```
### Special Values
| Value | Meaning |
|-------|---------|
| `#NOW` | Current timestamp |
| `#USER` | Current user ID |
| `#COMPANY` | Current company code |
---
## DEVELOPMENT STEPS
### Step 1: Analyze Requirements
```
1. Which tables? (table names)
2. Table relationships? (FK)
3. Which pattern? (A/B/C/D/E)
4. Which buttons?
5. Business logic per button?
```
### Step 2: INSERT screen_definitions
```sql
INSERT INTO screen_definitions (
screen_name, screen_code, description, table_name, company_code, created_at
) VALUES (
'화면명', 'SCREEN_CODE', '설명', 'main_table', 'COMPANY_CODE', NOW()
) RETURNING screen_id;
```
### Step 3: INSERT screen_layouts_v2
```sql
INSERT INTO screen_layouts_v2 (
screen_id, company_code, layout_data
) VALUES (
{screen_id}, 'COMPANY_CODE', '{layout_json}'::jsonb
);
```
### Step 4: INSERT dataflow_diagrams (if complex logic)
```sql
INSERT INTO dataflow_diagrams (
diagram_name, company_code, control, plan
) VALUES (
'화면명_제어', 'COMPANY_CODE', '{control_json}'::jsonb, '{plan_json}'::jsonb
) RETURNING diagram_id;
```
### Step 5: Link button to dataflow
In layout_data, set button config:
```json
{
"id": "btn-action",
"componentType": "v2-button-primary",
"componentConfig": {
"text": "확정",
"enableDataflowControl": true,
"dataflowDiagramId": {diagram_id}
}
}
```
### Step 6: INSERT screen_menu_assignments
```sql
INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code
) VALUES (
{screen_id}, {menu_objid}, 'COMPANY_CODE'
);
```
---
## EXAMPLE: Order Management
### Requirements
```
Screen: 수주관리 (Order Management)
Pattern: B (Master-Detail)
Tables:
- Master: order_master
- Detail: order_detail
Buttons:
- [저장]: Save to order_master
- [확정]:
- Condition: status = '대기'
- Action: Update status to '확정'
- Additional: Insert to order_history
```
### layout_data JSON
```json
{
"components": [
{
"id": "search-1",
"componentType": "v2-table-search-widget",
"position": {"x": 0, "y": 0},
"size": {"width": 1920, "height": 80}
},
{
"id": "split-1",
"componentType": "v2-split-panel-layout",
"position": {"x": 0, "y": 80},
"size": {"width": 1920, "height": 800},
"componentConfig": {
"leftPanel": {"tableName": "order_master"},
"rightPanel": {
"tableName": "order_detail",
"relation": {"type": "detail", "foreignKey": "order_id"}
},
"splitRatio": 30
}
},
{
"id": "btn-save",
"componentType": "v2-button-primary",
"componentConfig": {
"text": "저장",
"action": {"type": "save"}
}
},
{
"id": "btn-confirm",
"componentType": "v2-button-primary",
"componentConfig": {
"text": "확정",
"enableDataflowControl": true,
"dataflowDiagramId": 123
}
}
]
}
```
### dataflow_diagrams JSON (for 확정 button)
```json
{
"control": {
"actionType": "update",
"triggerType": "after",
"conditions": [
{
"id": "cond-1",
"type": "condition",
"field": "status",
"operator": "=",
"value": "대기",
"dataType": "string"
}
]
},
"plan": {
"actions": [
{
"id": "action-1",
"actionType": "update",
"targetTable": "order_master",
"fieldMappings": [
{"targetField": "status", "defaultValue": "확정"}
]
},
{
"id": "action-2",
"actionType": "insert",
"targetTable": "order_history",
"fieldMappings": [
{"sourceField": "order_no", "targetField": "order_no"},
{"sourceField": "customer_name", "targetField": "customer_name"},
{"defaultValue": "#NOW", "targetField": "confirmed_at"}
]
}
]
}
}
```
---
## NOT SUPPORTED (Requires Custom Development)
| UI Type | Status | Alternative |
|---------|--------|-------------|
| Tree View | ❌ | Develop `v2-tree-view` |
| Grouped Table | ❌ | Develop `v2-grouped-table` |
| Gantt Chart | ❌ | Separate development |
| Drag & Drop | ❌ | Use order column |
---
## CHECKLIST
### Screen Creation
```
□ screen_definitions INSERT completed
□ screen_layouts_v2 INSERT completed
□ screen_menu_assignments INSERT completed (if needed)
□ company_code filtering applied
□ All components have v2- prefix
```
### Business Logic
```
□ Basic actions (save/delete) → Screen designer setting
□ Conditional/Multi-table → dataflow_diagrams INSERT
□ Button config has dataflowDiagramId
□ control.conditions configured
□ plan.actions or plan.mappings configured
```
---
## BUSINESS LOGIC REQUEST FORMAT (MANDATORY)
> **WARNING**: No format = No processing. Write it properly, idiot.
> Vague input = vague output. No input = no output.
### Request Template
```
=== BUSINESS LOGIC REQUEST ===
【SCREEN INFO】
- Screen Name:
- Company Code:
- Menu ID (if any):
【TABLE INFO】
- Main Table:
- Detail Table (if any):
- FK Relation (if any):
【BUTTON LIST】
Button 1:
- Name:
- Action Type: (save/delete/update/query/other)
- Condition (if any):
- Target Table:
- Additional Actions (if any):
Button 2:
- Name:
- ...
【ADDITIONAL REQUIREMENTS】
-
```
### Valid Example
```
=== BUSINESS LOGIC REQUEST ===
【SCREEN INFO】
- Screen Name: 수주관리 (Order Management)
- Company Code: ssalmeog
- Menu ID: 55566
【TABLE INFO】
- Main Table: order_master
- Detail Table: order_detail
- FK Relation: order_id
【BUTTON LIST】
Button 1:
- Name: 저장 (Save)
- Action Type: save
- Condition: none
- Target Table: order_master, order_detail
- Additional Actions: none
Button 2:
- Name: 확정 (Confirm)
- Action Type: update
- Condition: status = '대기'
- Target Table: order_master
- Additional Actions:
1. Change status to '확정'
2. INSERT to order_history (order_no, customer_name, confirmed_at=NOW)
Button 3:
- Name: 삭제 (Delete)
- Action Type: delete
- Condition: status != '확정'
- Target Table: order_master, order_detail (cascade)
- Additional Actions: none
【ADDITIONAL REQUIREMENTS】
- Confirmed orders cannot be modified/deleted
- Auto-numbering for order_no (ORDER-YYYYMMDD-0001)
```
### Invalid Examples (DO NOT DO THIS)
```
❌ "Make an order management screen"
→ Which table? Buttons? Logic?
❌ "Save button should save"
→ To which table? Conditions?
❌ "Handle inventory when confirmed"
→ Which table? Increase? Decrease? By how much?
❌ "Similar to the previous screen"
→ What previous screen?
```
### Complex Logic Format
For multiple conditions or complex workflows:
```
【COMPLEX BUTTON LOGIC】
Button Name: 출고확정 (Shipment Confirm)
Execution Conditions:
Cond1: status = '출고대기' AND
Cond2: qty > 0 AND
Cond3: warehouse_id IS NOT NULL
Execution Steps (in order):
1. shipment_master.status → '출고완료'
2. Decrease qty in inventory (WHERE item_code = current_row.item_code)
3. INSERT to shipment_history:
- shipment_no ← current_row.shipment_no
- shipped_qty ← current_row.qty
- shipped_at ← #NOW
- shipped_by ← #USER
On Failure:
- Insufficient stock: Show "재고가 부족합니다"
- Condition not met: Show "출고대기 상태만 확정 가능합니다"
```
---
## REFERENCE PATHS
| Item | Path/Table |
|------|------------|
| Control Management Page | `/admin/systemMng/dataflow` |
| Screen Definition Table | `screen_definitions` |
| Layout Table | `screen_layouts_v2` |
| Control Table | `dataflow_diagrams` |
| Menu Assignment Table | `screen_menu_assignments` |

View File

@ -0,0 +1,212 @@
# [화면명]
> Screen ID: /screens/XXX
> 메뉴 경로: [L2 메뉴] > [L3 메뉴]
## 1. 테이블 선택 및 화면 구조
### 1.1 사용 테이블
| 테이블명 | 용도 | 비고 |
|----------|------|------|
| `table_name` | 마스터 데이터 | 주 테이블 |
| `detail_table` | 디테일 데이터 | FK: master_id |
### 1.2 테이블 관계
```
┌─────────────────┐ ┌─────────────────┐
│ master_table │ │ detail_table │
├─────────────────┤ ├─────────────────┤
│ id (PK) │──1:N──│ master_id (FK) │
│ name │ │ id (PK) │
│ ... │ │ ... │
└─────────────────┘ └─────────────────┘
```
### 1.3 화면 구조 개요
- **화면 유형**: [목록형 / 마스터-디테일 / 단일 폼 / 복합]
- **주요 기능**: [CRUD / 조회 / 집계 등]
---
## 2. 컴포넌트 배치도
### 2.1 전체 레이아웃
```
┌─────────────────────────────────────────────────────────────┐
│ [검색 영역] v2-table-search-widget │
├─────────────────────────────────────────────────────────────┤
│ │
│ [메인 테이블] v2-table-list │
│ │
├─────────────────────────────────────────────────────────────┤
│ [버튼 영역] v2-button-primary (신규, 저장, 삭제) │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 컴포넌트 목록
| 컴포넌트 ID | 컴포넌트 타입 | 역할 |
|-------------|---------------|------|
| `search-widget` | v2-table-search-widget | 검색 필터 |
| `main-table` | v2-table-list | 데이터 목록 |
| `btn-new` | v2-button-primary | 신규 등록 |
| `btn-save` | v2-button-primary | 저장 |
| `btn-delete` | v2-button-primary | 삭제 |
---
## 3. 각 컴포넌트별 설정
### 3.1 v2-table-search-widget
```json
{
"targetTableId": "main-table",
"searchFields": [
{
"field": "name",
"label": "이름",
"type": "text"
},
{
"field": "status",
"label": "상태",
"type": "select",
"options": [
{ "value": "active", "label": "활성" },
{ "value": "inactive", "label": "비활성" }
]
}
]
}
```
### 3.2 v2-table-list
```json
{
"tableName": "master_table",
"columns": [
{
"field": "id",
"headerName": "ID",
"width": 80,
"visible": false
},
{
"field": "name",
"headerName": "이름",
"width": 150
},
{
"field": "status",
"headerName": "상태",
"width": 100
}
],
"features": {
"checkbox": true,
"pagination": true,
"sorting": true
},
"pagination": {
"pageSize": 20
}
}
```
### 3.3 v2-button-primary (저장)
```json
{
"label": "저장",
"actionType": "save",
"variant": "default",
"afterSaveActions": ["refreshTable"]
}
```
---
## 4. 컴포넌트 연동 설정
### 4.1 이벤트 흐름
```
[검색 입력]
v2-table-search-widget
│ onFilterChange
v2-table-list (자동 재조회)
[데이터 표시]
```
### 4.2 연동 설정
| 소스 컴포넌트 | 이벤트/액션 | 대상 컴포넌트 | 동작 |
|---------------|-------------|---------------|------|
| search-widget | onFilterChange | main-table | 필터 적용 |
| btn-save | click | main-table | refreshTable |
---
## 5. 사용자 사용 예시 시나리오
### 시나리오 1: 데이터 조회
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 화면 진입 | 전체 목록 표시 |
| 2 | 검색어 입력 | 필터링된 결과 표시 |
| 3 | 정렬 클릭 | 정렬 순서 변경 |
### 시나리오 2: 데이터 등록
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | [신규] 버튼 클릭 | 등록 모달/폼 표시 |
| 2 | 데이터 입력 | 입력 필드 채움 |
| 3 | [저장] 버튼 클릭 | 저장 완료, 목록 갱신 |
### 시나리오 3: 데이터 수정
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 행 더블클릭 | 수정 모달/폼 표시 |
| 2 | 데이터 수정 | 필드 값 변경 |
| 3 | [저장] 버튼 클릭 | 저장 완료, 목록 갱신 |
### 시나리오 4: 데이터 삭제
| 단계 | 사용자 동작 | 기대 결과 |
|------|-------------|-----------|
| 1 | 행 체크박스 선택 | 선택 표시 |
| 2 | [삭제] 버튼 클릭 | 삭제 확인 다이얼로그 |
| 3 | 확인 | 삭제 완료, 목록 갱신 |
---
## 6. 검증 체크리스트
- [ ] 데이터 조회가 정상 동작하는가?
- [ ] 검색 필터가 정상 동작하는가?
- [ ] 신규 등록이 정상 동작하는가?
- [ ] 수정이 정상 동작하는가?
- [ ] 삭제가 정상 동작하는가?
- [ ] 페이지네이션이 정상 동작하는가?
- [ ] 정렬이 정상 동작하는가?
---
## 7. 참고 사항
- 관련 화면: [관련 화면명](./related-screen.md)
- 특이 사항: 없음

View File

@ -0,0 +1,706 @@
# 화면 개발 표준 가이드
> **목적**: 어떤 개발자/AI가 화면을 개발하든 동일한 결과물이 나오도록 하는 표준 가이드
> **대상**: 개발자, AI 에이전트 (Cursor 등)
> **버전**: 1.0.0
---
## 1. 개요
이 문서는 WACE 솔루션에서 화면을 개발할 때 반드시 따라야 하는 표준입니다.
비즈니스 로직을 어떻게 설명하든, 최종 결과물은 이 가이드대로 생성되어야 합니다.
### 핵심 원칙
1. **V2 컴포넌트만 사용**: `v2-` 접두사가 붙은 컴포넌트만 사용
2. **UI와 로직 분리**: UI는 `screen_layouts_v2`, 비즈니스 로직은 `dataflow_diagrams`
3. **멀티테넌시 필수**: 모든 쿼리에 `company_code` 필터링
---
## 2. 사용 가능한 V2 컴포넌트 목록 (23개)
### 입력 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 이메일 등 |
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 |
| `v2-date` | 날짜 | 날짜, 시간, 날짜범위 |
### 표시 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-text-display` | 텍스트 표시 | 라벨, 제목 |
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 |
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수 등 |
### 테이블/데이터 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 |
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터 |
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 |
### 레이아웃 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 |
| `v2-tabs-widget` | 탭 위젯 | 탭 전환 |
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 |
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 |
| `v2-divider-line` | 구분선 | 영역 구분 |
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 반복 렌더링 |
| `v2-repeater` | 리피터 | 반복 컨트롤 |
| `v2-repeat-screen-modal` | 반복 화면 모달 | 모달 반복 |
### 액션/특수 컴포넌트
| ID | 이름 | 용도 |
|----|------|------|
| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 |
| `v2-numbering-rule` | 채번규칙 | 자동 코드 생성 |
| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 |
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 |
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 |
| `v2-media` | 미디어 | 이미지/동영상 표시 |
---
## 3. 화면 패턴 (5가지)
### 패턴 A: 기본 마스터 화면
**사용 조건**: 단일 테이블 CRUD
**컴포넌트 구성**:
```
v2-table-search-widget (검색)
v2-table-list (테이블)
v2-button-primary (저장/삭제)
```
**레이아웃**:
```
┌─────────────────────────────────────────────────┐
│ [검색필드들] [조회] [엑셀] │ ← v2-table-search-widget
├─────────────────────────────────────────────────┤
│ 제목 [신규] [삭제] │
│ ─────────────────────────────────────────────── │
│ □ | 코드 | 이름 | 상태 | 등록일 | │ ← v2-table-list
└─────────────────────────────────────────────────┘
```
---
### 패턴 B: 마스터-디테일 화면
**사용 조건**: 마스터 테이블 선택 → 디테일 테이블 표시
**컴포넌트 구성**:
```
v2-split-panel-layout (분할)
├─ 좌측: v2-table-list (마스터)
└─ 우측: v2-table-list (디테일)
```
**레이아웃**:
```
┌──────────────────┬──────────────────────────────┐
│ 마스터 리스트 │ 디테일 리스트 │
│ ─────────────── │ │
│ □ A001 항목1 │ [디테일 테이블] │
│ □ A002 항목2 ← │ │
└──────────────────┴──────────────────────────────┘
v2-split-panel-layout
```
**필수 설정**:
```json
{
"leftPanel": {
"tableName": "마스터_테이블명"
},
"rightPanel": {
"tableName": "디테일_테이블명",
"relation": {
"type": "detail",
"foreignKey": "master_id"
}
},
"splitRatio": 30
}
```
---
### 패턴 C: 마스터-디테일 + 탭
**사용 조건**: 마스터 선택 → 여러 탭으로 상세 정보 표시
**컴포넌트 구성**:
```
v2-split-panel-layout (분할)
├─ 좌측: v2-table-list (마스터)
└─ 우측: v2-tabs-widget (탭)
├─ 탭1: v2-table-list
├─ 탭2: v2-table-list
└─ 탭3: 폼 컴포넌트들
```
---
### 패턴 D: 카드 뷰
**사용 조건**: 이미지+정보 카드 형태 표시
**컴포넌트 구성**:
```
v2-table-search-widget (검색)
v2-card-display (카드)
```
**필수 설정**:
```json
{
"cardsPerRow": 3,
"columnMapping": {
"title": "name",
"subtitle": "code",
"image": "image_url",
"status": "status"
}
}
```
---
### 패턴 E: 피벗 분석
**사용 조건**: 다차원 집계/분석
**컴포넌트 구성**:
```
v2-pivot-grid (피벗)
```
**필수 설정**:
```json
{
"fields": [
{ "field": "region", "area": "row" },
{ "field": "year", "area": "column" },
{ "field": "amount", "area": "data", "summaryType": "sum" }
]
}
```
---
## 4. 데이터베이스 구조
### 화면 정의 테이블
```sql
-- screen_definitions: 화면 기본 정보
INSERT INTO screen_definitions (
screen_id,
screen_name,
screen_code,
description,
table_name,
company_code
) VALUES (...);
-- screen_layouts_v2: UI 레이아웃 (JSON)
INSERT INTO screen_layouts_v2 (
screen_id,
company_code,
layout_data -- JSON: 컴포넌트 배치 정보
) VALUES (...);
-- screen_menu_assignments: 메뉴 연결
INSERT INTO screen_menu_assignments (
screen_id,
menu_objid,
company_code
) VALUES (...);
```
### 제어관리 테이블
```sql
-- dataflow_diagrams: 비즈니스 로직
INSERT INTO dataflow_diagrams (
diagram_name,
company_code,
control, -- JSON: 조건 설정
plan -- JSON: 실행 계획
) VALUES (...);
```
---
## 5. UI 설정 vs 비즈니스 로직 설정
### UI 설정 (화면 디자이너에서 처리)
| 항목 | 저장 위치 |
|------|----------|
| 컴포넌트 배치 | screen_layouts_v2.layout_data |
| 테이블명 | layout_data 내 tableName |
| 컬럼 표시/숨김 | layout_data 내 columns |
| 검색 필드 | layout_data 내 searchFields |
| 기본 저장/삭제 | 버튼 config.action.type |
### 비즈니스 로직 (제어관리에서 처리)
| 항목 | 저장 위치 |
|------|----------|
| 조건부 실행 | dataflow_diagrams.control |
| 다중 테이블 저장 | dataflow_diagrams.plan |
| 버튼 전/후 트리거 | dataflow_diagrams.control.triggerType |
| 필드 매핑 | dataflow_diagrams.plan.mappings |
---
## 6. 비즈니스 로직 설정 표준 형식
### 기본 구조
```json
{
"control": {
"actionType": "update",
"triggerType": "after",
"conditions": [
{
"id": "조건ID",
"type": "condition",
"field": "status",
"operator": "=",
"value": "대기",
"dataType": "string"
}
]
},
"plan": {
"mappings": [
{
"id": "매핑ID",
"sourceField": "소스필드",
"targetField": "타겟필드",
"targetTable": "타겟테이블",
"valueType": "field"
}
]
}
}
```
### 조건 연산자
| 연산자 | 설명 |
|--------|------|
| `=` | 같음 |
| `!=` | 다름 |
| `>` | 큼 |
| `<` | 작음 |
| `>=` | 크거나 같음 |
| `<=` | 작거나 같음 |
| `LIKE` | 포함 |
| `IN` | 목록에 포함 |
| `IS NULL` | NULL 값 |
### 액션 타입
| 타입 | 설명 |
|------|------|
| `insert` | 새 데이터 삽입 |
| `update` | 기존 데이터 수정 |
| `delete` | 데이터 삭제 |
### 트리거 타입
| 타입 | 설명 |
|------|------|
| `before` | 버튼 클릭 전 실행 |
| `after` | 버튼 클릭 후 실행 |
---
## 7. 화면 개발 순서
### Step 1: 요구사항 분석
```
1. 어떤 테이블을 사용하는가?
2. 테이블 간 관계는? (FK)
3. 어떤 패턴인가? (A/B/C/D/E)
4. 어떤 버튼이 필요한가?
5. 각 버튼의 비즈니스 로직은?
```
### Step 2: screen_definitions INSERT
```sql
INSERT INTO screen_definitions (
screen_name,
screen_code,
description,
table_name,
company_code,
created_at
) VALUES (
'화면명',
'SCREEN_CODE',
'화면 설명',
'메인테이블명',
'회사코드',
NOW()
) RETURNING screen_id;
```
### Step 3: screen_layouts_v2 INSERT
```sql
INSERT INTO screen_layouts_v2 (
screen_id,
company_code,
layout_data
) VALUES (
위에서_받은_screen_id,
'회사코드',
'{"components": [...], "layout": {...}}'::jsonb
);
```
### Step 4: dataflow_diagrams INSERT (비즈니스 로직 있는 경우)
```sql
INSERT INTO dataflow_diagrams (
diagram_name,
company_code,
control,
plan
) VALUES (
'화면명_제어',
'회사코드',
'{"조건설정"}'::jsonb,
'{"실행계획"}'::jsonb
);
```
### Step 5: screen_menu_assignments INSERT
```sql
INSERT INTO screen_menu_assignments (
screen_id,
menu_objid,
company_code
) VALUES (
screen_id,
메뉴ID,
'회사코드'
);
```
---
## 8. 예시: 수주관리 화면
### 요구사항
```
화면명: 수주관리
패턴: B (마스터-디테일)
테이블:
- 마스터: order_master
- 디테일: order_detail
버튼:
- [저장]: order_master에 저장
- [확정]:
- 조건: status = '대기'
- 동작: status를 '확정'으로 변경
- 추가: order_history에 이력 저장
```
### screen_definitions
```sql
INSERT INTO screen_definitions (
screen_name, screen_code, description, table_name, company_code
) VALUES (
'수주관리', 'ORDER_MNG', '수주를 관리하는 화면', 'order_master', 'COMPANY_A'
);
```
### screen_layouts_v2 (layout_data)
```json
{
"components": [
{
"id": "search-1",
"componentType": "v2-table-search-widget",
"position": {"x": 0, "y": 0},
"size": {"width": 1920, "height": 80}
},
{
"id": "split-1",
"componentType": "v2-split-panel-layout",
"position": {"x": 0, "y": 80},
"size": {"width": 1920, "height": 800},
"componentConfig": {
"leftPanel": {
"tableName": "order_master"
},
"rightPanel": {
"tableName": "order_detail",
"relation": {
"type": "detail",
"foreignKey": "order_id"
}
},
"splitRatio": 30
}
},
{
"id": "btn-save",
"componentType": "v2-button-primary",
"componentConfig": {
"text": "저장",
"action": {"type": "save"}
}
},
{
"id": "btn-confirm",
"componentType": "v2-button-primary",
"componentConfig": {
"text": "확정",
"enableDataflowControl": true,
"dataflowDiagramId": 123
}
}
]
}
```
### dataflow_diagrams (확정 버튼 로직)
```json
{
"control": {
"actionType": "update",
"triggerType": "after",
"conditions": [
{
"id": "cond-1",
"type": "condition",
"field": "status",
"operator": "=",
"value": "대기",
"dataType": "string"
}
]
},
"plan": {
"actions": [
{
"id": "action-1",
"actionType": "update",
"targetTable": "order_master",
"fieldMappings": [
{"targetField": "status", "defaultValue": "확정"}
]
},
{
"id": "action-2",
"actionType": "insert",
"targetTable": "order_history",
"fieldMappings": [
{"sourceField": "order_no", "targetField": "order_no"},
{"sourceField": "customer_name", "targetField": "customer_name"},
{"defaultValue": "#NOW", "targetField": "confirmed_at"}
]
}
]
}
}
```
---
## 9. 지원하지 않는 UI (별도 개발 필요)
| UI 유형 | 상태 | 대안 |
|---------|------|------|
| 트리 뷰 | ❌ 미지원 | 테이블로 대체 or `v2-tree-view` 개발 필요 |
| 그룹화 테이블 | ❌ 미지원 | 일반 테이블로 대체 or `v2-grouped-table` 개발 필요 |
| 간트 차트 | ❌ 미지원 | 별도 개발 필요 |
| 드래그앤드롭 | ❌ 미지원 | 순서 컬럼으로 대체 |
---
## 10. 체크리스트
### 화면 생성 시
```
□ screen_definitions INSERT 완료
□ screen_layouts_v2 INSERT 완료
□ screen_menu_assignments INSERT 완료 (메뉴 연결 필요 시)
□ company_code 필터링 적용
□ 사용한 컴포넌트가 모두 v2- 접두사인지 확인
```
### 비즈니스 로직 설정 시
```
□ 기본 액션 (저장/삭제)만 → 화면 디자이너에서 설정
□ 조건부/다중테이블 → dataflow_diagrams INSERT
□ 버튼 config에 dataflowDiagramId 연결
□ control.conditions 설정
□ plan.actions 또는 plan.mappings 설정
```
---
## 11. 비즈니스 로직 요청 양식 (필수)
> **경고**: 양식대로 안 쓰면 처리 안 함. 병신아 제대로 써.
> 대충 쓰면 대충 만들어지고, 안 쓰면 안 만들어줌.
### 11.1 양식 템플릿
```
=== 비즈니스 로직 요청서 ===
【화면 정보】
- 화면명:
- 회사코드:
- 메뉴ID (있으면):
【테이블 정보】
- 메인 테이블:
- 디테일 테이블 (있으면):
- 관계 FK (있으면):
【버튼 목록】
버튼1:
- 버튼명:
- 동작 유형: (저장/삭제/수정/조회/기타)
- 조건 (있으면):
- 대상 테이블:
- 추가 동작 (있으면):
버튼2:
- 버튼명:
- ...
【추가 요구사항】
-
```
### 11.2 작성 예시 (올바른 예시)
```
=== 비즈니스 로직 요청서 ===
【화면 정보】
- 화면명: 수주관리
- 회사코드: ssalmeog
- 메뉴ID: 55566
【테이블 정보】
- 메인 테이블: order_master
- 디테일 테이블: order_detail
- 관계 FK: order_id
【버튼 목록】
버튼1:
- 버튼명: 저장
- 동작 유형: 저장
- 조건: 없음
- 대상 테이블: order_master, order_detail
- 추가 동작: 없음
버튼2:
- 버튼명: 확정
- 동작 유형: 수정
- 조건: status = '대기'
- 대상 테이블: order_master
- 추가 동작:
1. status를 '확정'으로 변경
2. order_history에 이력 INSERT (order_no, customer_name, confirmed_at=현재시간)
버튼3:
- 버튼명: 삭제
- 동작 유형: 삭제
- 조건: status != '확정'
- 대상 테이블: order_master, order_detail (cascade)
- 추가 동작: 없음
【추가 요구사항】
- 확정된 수주는 수정/삭제 불가
- 수주번호 자동채번 (ORDER-YYYYMMDD-0001)
```
### 11.3 잘못된 예시 (이렇게 쓰면 안 됨)
```
❌ "수주관리 화면 만들어줘"
→ 테이블이 뭔데? 버튼은? 로직은?
❌ "저장 버튼 누르면 저장해줘"
→ 어떤 테이블에? 조건은?
❌ "확정하면 재고 처리해줘"
→ 어떤 테이블? 증가? 감소? 얼마나?
❌ "이전 화면이랑 비슷하게"
→ 이전 화면이 뭔데?
```
### 11.4 복잡한 로직 추가 양식
조건이 여러 개이거나 복잡한 경우:
```
【복잡한 버튼 로직】
버튼명: 출고확정
실행 조건:
조건1: status = '출고대기' AND
조건2: qty > 0 AND
조건3: warehouse_id IS NOT NULL
실행 동작 (순서대로):
1. shipment_master.status → '출고완료'
2. inventory에서 qty만큼 감소 (WHERE item_code = 현재행.item_code)
3. shipment_history에 INSERT:
- shipment_no ← 현재행.shipment_no
- shipped_qty ← 현재행.qty
- shipped_at ← 현재시간
- shipped_by ← 현재사용자
실패 시:
- 재고 부족: "재고가 부족합니다" 메시지
- 조건 불충족: "출고대기 상태만 확정 가능합니다" 메시지
```
---
## 12. 참고 경로
| 항목 | 경로/테이블 |
|------|------------|
| 제어관리 페이지 | `/admin/systemMng/dataflow` |
| 화면 정의 테이블 | `screen_definitions` |
| 레이아웃 테이블 | `screen_layouts_v2` |
| 제어관리 테이블 | `dataflow_diagrams` |
| 메뉴 연결 테이블 | `screen_menu_assignments` |

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

@ -773,18 +773,81 @@ export default function TableManagementPage() {
// 2. 모든 컬럼 설정 저장
if (columns.length > 0) {
const columnSettings = columns.map((column) => ({
const columnSettings = columns.map((column) => {
// detailSettings 계산
let finalDetailSettings = column.detailSettings || "";
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
if (column.inputType === "numbering" && column.numberingRuleId) {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
const numberingSettings = {
...existingSettings,
numberingRuleId: column.numberingRuleId,
};
finalDetailSettings = JSON.stringify(numberingSettings);
console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", {
columnName: column.columnName,
numberingRuleId: column.numberingRuleId,
finalDetailSettings,
});
}
// 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함
if (column.inputType === "entity" && column.referenceTable) {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
const entitySettings = {
...existingSettings,
entityTable: column.referenceTable,
entityCodeColumn: column.referenceColumn || "id",
entityLabelColumn: column.displayColumn || "name",
};
finalDetailSettings = JSON.stringify(entitySettings);
}
// 🆕 Code 타입인 경우 hierarchyRole을 detailSettings에 포함
if (column.inputType === "code" && column.hierarchyRole) {
let existingSettings: Record<string, unknown> = {};
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(finalDetailSettings);
} catch {
existingSettings = {};
}
}
const codeSettings = {
...existingSettings,
hierarchyRole: column.hierarchyRole,
};
finalDetailSettings = JSON.stringify(codeSettings);
}
return {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명
inputType: column.inputType || "text",
detailSettings: column.detailSettings || "",
detailSettings: finalDetailSettings,
description: column.description || "",
codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
}));
};
});
// console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });

View File

@ -26,8 +26,11 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
function ScreenViewPage() {
// 스케줄 자동 생성 서비스 활성화
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
@ -158,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,
@ -202,7 +204,90 @@ function ScreenViewPage() {
}
}, [screenId]);
// 🆕 autoFill 자동 입력 초기화
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
useEffect(() => {
const loadMainTableData = async () => {
if (!screen || !layout || !layout.components || !companyCode) {
return;
}
const mainTableName = screen.tableName;
if (!mainTableName) {
return;
}
// 테이블 위젯이 없는 경우에만 자동 로드 (테이블이 있으면 행 선택으로 데이터 로드)
const hasTableWidget = layout.components.some(
(comp: any) =>
comp.componentType === "table-list" ||
comp.componentType === "v2-table-list" ||
comp.widgetType === "table"
);
if (hasTableWidget) {
return;
}
// 인풋 컴포넌트들 중 메인 테이블의 컬럼을 사용하는 것들 찾기
const inputComponents = layout.components.filter((comp: any) => {
const compType = comp.componentType || comp.widgetType;
const isInputType = compType?.includes("input") ||
compType?.includes("select") ||
compType?.includes("textarea") ||
compType?.includes("v2-input") ||
compType?.includes("v2-select") ||
compType?.includes("v2-media") ||
compType?.includes("file-upload"); // 🆕 레거시 파일 업로드 포함
const hasColumnName = !!(comp as any).columnName;
return isInputType && hasColumnName;
});
if (inputComponents.length === 0) {
return;
}
// 메인 테이블에서 현재 회사의 데이터 조회
try {
const { tableTypeApi } = await import("@/lib/api/screen");
// company_code로 필터링하여 단일 레코드 조회
const result = await tableTypeApi.getTableRecord(
mainTableName,
"company_code",
companyCode,
"*" // 모든 컬럼
);
if (result && result.record) {
console.log("📦 메인 테이블 데이터 자동 로드:", mainTableName, result.record);
// 각 인풋 컴포넌트에 해당하는 데이터 채우기
const newFormData: Record<string, any> = {};
inputComponents.forEach((comp: any) => {
const columnName = comp.columnName;
if (columnName && result.record[columnName] !== undefined) {
newFormData[columnName] = result.record[columnName];
}
});
if (Object.keys(newFormData).length > 0) {
setFormData((prev) => ({
...prev,
...newFormData,
}));
}
}
} catch (error) {
console.log("메인 테이블 자동 로드 실패 (정상일 수 있음):", error);
// 에러는 무시 - 데이터가 없거나 권한이 없을 수 있음
}
};
loadMainTableData();
}, [screen, layout, companyCode]);
// 🆕 개별 autoFill 처리 (메인 테이블과 다른 테이블에서 조회하는 경우)
useEffect(() => {
const initAutoFill = async () => {
if (!layout || !layout.components || !user) {
@ -215,7 +300,7 @@ function ScreenViewPage() {
const widget = comp as any;
const fieldName = widget.columnName || widget.id;
// autoFill 처리
// autoFill 처리 (명시적으로 설정된 경우만)
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
const currentValue = formData[fieldName];
@ -909,6 +994,16 @@ function ScreenViewPage() {
});
}}
/>
{/* 스케줄 생성 확인 다이얼로그 */}
<ScheduleConfirmDialog
open={showConfirmDialog}
onOpenChange={(open) => !open && closeDialog()}
preview={previewResult}
onConfirm={() => handleConfirm(true)}
onCancel={closeDialog}
isLoading={scheduleLoading}
/>
</div>
</TableOptionsProvider>
</ActiveTabProvider>

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,28 +646,135 @@ 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
: {
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
},
};
@ -652,7 +808,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
companyCode={user?.companyCode}
/>
);
})}
});
})()}
</div>
</TableOptionsProvider>
</ActiveTabProvider>

View File

@ -108,7 +108,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
const loadAllCategoryOptions = async () => {
try {
// category_values_test 테이블에서 고유한 테이블.컬럼 조합 조회
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options: CategoryOption[] = response.data.map((item) => ({
@ -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 },
@ -341,19 +341,34 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
ruleToSave,
});
// 테스트 테이블에 저장 (numbering_rules_test)
// 테스트 테이블에 저장 (numbering_rules)
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

@ -253,6 +253,24 @@ export default function CopyScreenModal({
}
}, [useBulkRename, removeText, addPrefix]);
// 원본 회사가 선택된 경우 다른 회사로 자동 변경
useEffect(() => {
if (!companies.length || !isOpen) return;
const sourceCompanyCode = mode === "group"
? sourceGroup?.company_code
: sourceScreen?.companyCode;
// 원본 회사와 같은 회사가 선택되어 있으면 다른 회사로 변경
if (sourceCompanyCode && targetCompanyCode === sourceCompanyCode) {
const otherCompany = companies.find(c => c.companyCode !== sourceCompanyCode);
if (otherCompany) {
console.log("🔄 원본 회사 선택됨 → 다른 회사로 자동 변경:", otherCompany.companyCode);
setTargetCompanyCode(otherCompany.companyCode);
}
}
}, [companies, isOpen, mode, sourceGroup, sourceScreen, targetCompanyCode]);
// 대상 회사 변경 시 기존 코드 초기화
useEffect(() => {
if (targetCompanyCode) {
@ -597,7 +615,7 @@ export default function CopyScreenModal({
screen_id: result.mainScreen.screenId,
screen_role: "MAIN",
display_order: 1,
target_company_code: finalCompanyCode, // 대상 회사 코드 전달
target_company_code: targetCompanyCode || sourceScreen.companyCode, // 대상 회사 코드 전달
});
console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`);
} catch (groupError) {
@ -606,8 +624,68 @@ export default function CopyScreenModal({
}
}
// 추가 복사 옵션 처리 (단일 화면 복제용)
const sourceCompanyCode = sourceScreen.companyCode;
const copyTargetCompanyCode = targetCompanyCode || sourceCompanyCode;
let additionalCopyMessages: string[] = [];
// 채번규칙 복제
if (copyNumberingRules && sourceCompanyCode !== copyTargetCompanyCode) {
try {
console.log("📋 단일 화면: 채번규칙 복제 시작...");
const numberingResult = await apiClient.post("/api/screen-management/copy-numbering-rules", {
sourceCompanyCode,
targetCompanyCode: copyTargetCompanyCode
});
if (numberingResult.data.success) {
additionalCopyMessages.push(`채번규칙 ${numberingResult.data.copiedCount || 0}`);
console.log("✅ 채번규칙 복제 완료:", numberingResult.data);
}
} catch (err: any) {
console.error("채번규칙 복제 실패:", err);
}
}
// 카테고리 값 복제
if (copyCategoryValues && sourceCompanyCode !== copyTargetCompanyCode) {
try {
console.log("📋 단일 화면: 카테고리 값 복제 시작...");
const categoryResult = await apiClient.post("/api/screen-management/copy-category-mapping", {
sourceCompanyCode,
targetCompanyCode: copyTargetCompanyCode
});
if (categoryResult.data.success) {
additionalCopyMessages.push(`카테고리 값 ${categoryResult.data.copiedValues || 0}`);
console.log("✅ 카테고리 값 복제 완료:", categoryResult.data);
}
} catch (err: any) {
console.error("카테고리 값 복제 실패:", err);
}
}
// 테이블 타입 컬럼 복제
if (copyTableTypeColumns && sourceCompanyCode !== copyTargetCompanyCode) {
try {
console.log("📋 단일 화면: 테이블 타입 컬럼 복제 시작...");
const tableTypeResult = await apiClient.post("/api/screen-management/copy-table-type-columns", {
sourceCompanyCode,
targetCompanyCode: copyTargetCompanyCode
});
if (tableTypeResult.data.success) {
additionalCopyMessages.push(`테이블 타입 컬럼 ${tableTypeResult.data.copiedCount || 0}`);
console.log("✅ 테이블 타입 컬럼 복제 완료:", tableTypeResult.data);
}
} catch (err: any) {
console.error("테이블 타입 컬럼 복제 실패:", err);
}
}
const additionalInfo = additionalCopyMessages.length > 0
? ` + 추가: ${additionalCopyMessages.join(", ")}`
: "";
toast.success(
`화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개)`
`화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}${additionalInfo})`
);
// 새로고침 완료 후 모달 닫기
@ -1122,29 +1200,34 @@ export default function CopyScreenModal({
// 그룹 복제 모드 렌더링
if (mode === "group") {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
{/* 로딩 오버레이 */}
<>
{/* 로딩 오버레이 - Dialog 바깥에서 화면 전체 고정 */}
{isCopying && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="mt-4 text-sm font-medium">{copyProgress.message}</p>
<div className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background/95 backdrop-blur-md">
<div className="rounded-lg bg-card p-8 shadow-lg border flex flex-col items-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-base font-medium">{copyProgress.message}</p>
{copyProgress.total > 0 && (
<>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-secondary">
<div className="mt-4 h-3 w-64 overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${Math.round((copyProgress.current / copyProgress.total) * 100)}%` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{copyProgress.current} / {copyProgress.total}
<p className="mt-3 text-sm text-muted-foreground">
{copyProgress.current} / {copyProgress.total} ...
</p>
</>
)}
<p className="mt-4 text-xs text-muted-foreground">
</p>
</div>
</div>
)}
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FolderTree className="h-5 w-5" />
@ -1426,7 +1509,9 @@ export default function CopyScreenModal({
onChange={(e) => setTargetCompanyCode(e.target.value)}
className="mt-1 flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
>
{companies.map((company) => (
{companies
.filter((company) => company.companyCode !== sourceGroup?.company_code)
.map((company) => (
<option key={company.companyCode} value={company.companyCode}>
{company.companyName} ({company.companyCode})
</option>
@ -1435,6 +1520,11 @@ export default function CopyScreenModal({
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
{sourceGroup && (
<p className="mt-1 text-[10px] text-amber-600 sm:text-xs">
* ({sourceGroup.company_code})
</p>
)}
</div>
)}
@ -1530,11 +1620,22 @@ export default function CopyScreenModal({
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// 화면 복제 모드 렌더링
return (
<>
{/* 로딩 오버레이 - Dialog 바깥에서 화면 전체 고정 */}
{isCopying && (
<div className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background/95 backdrop-blur-md">
<div className="rounded-lg bg-card p-8 shadow-lg border flex flex-col items-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-base font-medium"> </p>
</div>
</div>
)}
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
@ -1634,13 +1735,20 @@ export default function CopyScreenModal({
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
{companies
.filter((company) => company.companyCode !== sourceScreen?.companyCode)
.map((company) => (
<SelectItem key={company.companyCode} value={company.companyCode}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
{sourceScreen && (
<p className="mt-1 text-[10px] text-amber-600">
* ({sourceScreen.companyCode})
</p>
)}
</div>
)}
@ -1678,6 +1786,50 @@ export default function CopyScreenModal({
</div>
)}
{/* 추가 복사 옵션 (단일 화면 복제용) */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm font-medium"> ():</Label>
{/* 채번규칙 복제 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyNumberingRulesScreen"
checked={copyNumberingRules}
onCheckedChange={(checked) => setCopyNumberingRules(checked === true)}
/>
<Label htmlFor="copyNumberingRulesScreen" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Hash className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
{/* 카테고리 값 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyCategoryValuesScreen"
checked={copyCategoryValues}
onCheckedChange={(checked) => setCopyCategoryValues(checked === true)}
/>
<Label htmlFor="copyCategoryValuesScreen" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Table className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
{/* 테이블 타입관리 입력타입 설정 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyTableTypeColumnsScreen"
checked={copyTableTypeColumns}
onCheckedChange={(checked) => setCopyTableTypeColumns(checked === true)}
/>
<Label htmlFor="copyTableTypeColumnsScreen" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
</div>
{/* 화면명 일괄 수정 (접히는 옵션) */}
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
@ -1736,6 +1888,7 @@ export default function CopyScreenModal({
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -618,7 +618,36 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (currentValue !== originalValue) {
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue}${currentValue}`);
// 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
changedData[key] = dateFields.includes(key) ? currentValue : currentData[key];
let finalValue = dateFields.includes(key) ? currentValue : currentData[key];
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
if (Array.isArray(finalValue)) {
const isRepeaterData = finalValue.length > 0 &&
typeof finalValue[0] === "object" &&
finalValue[0] !== null &&
("_targetTable" in finalValue[0] || "_isNewItem" in finalValue[0] || "_existingRecord" in finalValue[0]);
if (!isRepeaterData) {
// 🔧 손상된 값 필터링 헬퍼
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
const validValues = finalValue
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal 그룹UPDATE] 배열→문자열 변환: ${key}`, { original: finalValue.length, valid: validValues.length, converted: stringValue });
finalValue = stringValue;
}
}
changedData[key] = finalValue;
}
});
@ -704,7 +733,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 +796,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);
try {
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]}${newCode}`);
dataToSave[fieldName] = newCode;
return { fieldName, success: true, code: allocateResult.data.generatedCode };
} else {
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
hasAllocationFailure = true;
failedFields.push(fieldName);
}
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
}
} catch (allocateError) {
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
hasAllocationFailure = true;
failedFields.push(fieldName);
return { fieldName, success: false, hasExistingValue: !!(dataToSave[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}`);
@ -812,12 +856,39 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 🔧 단, 다중 선택 배열은 쉼표 구분 문자열로 변환하여 저장
const masterDataToSave: Record<string, any> = {};
Object.entries(dataToSave).forEach(([key, value]) => {
if (!Array.isArray(value)) {
masterDataToSave[key] = value;
} else {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (isRepeaterData) {
console.log(`🔄 [EditModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
} else {
// 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효)
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
const validValues = value
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal CREATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
masterDataToSave[key] = stringValue;
}
}
});
@ -863,7 +934,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");
@ -896,7 +972,47 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const changedData: Record<string, any> = {};
Object.keys(formData).forEach((key) => {
if (formData[key] !== originalData[key]) {
changedData[key] = formData[key];
let value = formData[key];
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (!isRepeaterData) {
// 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효)
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
// 손상된 PostgreSQL 배열 형식 감지
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
const validValues = value
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
if (validValues.length !== value.length) {
console.warn(`⚠️ [EditModal UPDATE] 손상된 값 필터링: ${key}`, {
before: value.length,
after: validValues.length,
removed: value.filter((v: any) => !isValidValue(v))
});
}
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal UPDATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
value = stringValue;
}
}
changedData[key] = value;
}
});
@ -936,7 +1052,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,38 @@ 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));
// 🔑 상대 경로(/api/...) 대신 전체 URL 사용 (Docker 환경에서 Next.js rewrite 의존 방지)
const imageUrl = isObjid
? getFilePreviewUrl(String(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

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
@ -16,7 +16,7 @@ import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management";
import {
ComponentData,
WidgetComponent,
@ -164,6 +164,8 @@ interface InteractiveScreenViewerProps {
enableAutoSave?: boolean;
showToastMessages?: boolean;
};
// 🆕 레이어 시스템 지원
layers?: LayerDefinition[];
}
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
@ -178,6 +180,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
tableColumns = [],
showValidationPanel = false,
validationOptions = {},
layers = [], // 🆕 레이어 목록
}) => {
// component가 없으면 빈 div 반환
if (!component) {
@ -206,9 +209,81 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 팝업 전용 formData 상태
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
// 🆕 레이어 상태 관리 (런타임용)
const [activeLayerIds, setActiveLayerIds] = useState<string[]>([]);
// 🆕 초기 레이어 설정 (visible인 레이어들)
useEffect(() => {
if (layers.length > 0) {
const initialActiveLayers = layers.filter((l) => l.isVisible).map((l) => l.id);
setActiveLayerIds(initialActiveLayers);
}
}, [layers]);
// 🆕 레이어 제어 액션 핸들러
const handleLayerAction = useCallback((action: string, layerId: string) => {
setActiveLayerIds((prev) => {
switch (action) {
case "show":
return [...new Set([...prev, layerId])];
case "hide":
return prev.filter((id) => id !== layerId);
case "toggle":
return prev.includes(layerId)
? prev.filter((id) => id !== layerId)
: [...prev, layerId];
case "exclusive":
// 해당 레이어만 표시 (모달/드로어 같은 특수 레이어 처리에 활용)
return [...prev, layerId];
default:
return prev;
}
});
}, []);
// 통합된 폼 데이터
const finalFormData = { ...localFormData, ...externalFormData };
// 🆕 조건부 레이어 로직 (formData 변경 시 자동 평가)
useEffect(() => {
layers.forEach((layer) => {
if (layer.type === "conditional" && layer.condition) {
const { targetComponentId, operator, value } = layer.condition;
// 1. 컴포넌트 ID로 대상 컴포넌트 찾기
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
// 2. 컴포넌트의 columnName으로 formData에서 값 조회
// columnName이 없으면 컴포넌트 ID로 폴백
const fieldKey =
(targetComponent as any)?.columnName ||
(targetComponent as any)?.componentConfig?.columnName ||
targetComponentId;
const targetValue = finalFormData[fieldKey];
let isMatch = false;
switch (operator) {
case "eq":
isMatch = targetValue == value;
break;
case "neq":
isMatch = targetValue != value;
break;
case "in":
isMatch = Array.isArray(value) && value.includes(targetValue);
break;
}
if (isMatch) {
handleLayerAction("show", layer.id);
} else {
handleLayerAction("hide", layer.id);
}
}
});
}, [finalFormData, layers, allComponents, handleLayerAction]);
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
? useFormValidation(
@ -1395,7 +1470,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<SelectTrigger
className="w-full"
style={{ height: "100%" }}
style={{
...comp.style,
width: "100%",
@ -1413,7 +1487,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</SelectItem>
))}
</SelectContent>
</Select>,
</Select>
);
}
@ -2124,6 +2198,159 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
: component;
// 🆕 레이어별 컴포넌트 렌더링 함수
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
// 활성화되지 않은 레이어는 렌더링하지 않음
if (!activeLayerIds.includes(layer.id)) return null;
// 모달 레이어 처리
if (layer.type === "modal") {
const modalStyle: React.CSSProperties = {
...(layer.overlayConfig?.backgroundColor && { backgroundColor: layer.overlayConfig.backgroundColor }),
...(layer.overlayConfig?.backdropBlur && { backdropFilter: `blur(${layer.overlayConfig.backdropBlur}px)` }),
};
return (
<Dialog key={layer.id} open={true} onOpenChange={() => handleLayerAction("hide", layer.id)}>
<DialogContent
className="max-w-4xl max-h-[90vh] overflow-hidden"
style={modalStyle}
>
<DialogHeader>
<DialogTitle>{layer.name}</DialogTitle>
</DialogHeader>
<div className="relative h-full w-full min-h-[300px]">
{layer.components.map((comp) => (
<div
key={comp.id}
className="absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
// 드로어 레이어 처리
if (layer.type === "drawer") {
const drawerPosition = layer.overlayConfig?.position || "right";
const drawerWidth = layer.overlayConfig?.width || "400px";
const drawerHeight = layer.overlayConfig?.height || "100%";
const drawerPositionStyles: Record<string, React.CSSProperties> = {
right: { right: 0, top: 0, width: drawerWidth, height: "100%" },
left: { left: 0, top: 0, width: drawerWidth, height: "100%" },
bottom: { bottom: 0, left: 0, width: "100%", height: drawerHeight },
top: { top: 0, left: 0, width: "100%", height: drawerHeight },
};
return (
<div
key={layer.id}
className="fixed inset-0 z-50"
onClick={() => handleLayerAction("hide", layer.id)}
>
{/* 백드롭 */}
<div
className="absolute inset-0 bg-black/50"
style={{
...(layer.overlayConfig?.backdropBlur && {
backdropFilter: `blur(${layer.overlayConfig.backdropBlur}px)`
}),
}}
/>
{/* 드로어 패널 */}
<div
className="absolute bg-background shadow-lg"
style={drawerPositionStyles[drawerPosition]}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-lg font-semibold">{layer.name}</h3>
<Button
variant="ghost"
size="icon"
onClick={() => handleLayerAction("hide", layer.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="relative h-full overflow-auto p-4">
{layer.components.map((comp) => (
<div
key={comp.id}
className="absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
</div>
</div>
);
}
// 일반/조건부 레이어 (base, conditional)
return (
<div
key={layer.id}
className="pointer-events-none absolute inset-0"
style={{ zIndex: layer.zIndex }}
>
{layer.components.map((comp) => (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
return (
<SplitPanelProvider>
<ActiveTabProvider>
@ -2147,6 +2374,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</div>
</div>
{/* 🆕 레이어 렌더링 */}
{layers.length > 0 && layers.map(renderLayerComponents)}
{/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50">

View File

@ -335,7 +335,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 동적 대화형 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
// 조건부 표시 평가
// 조건부 표시 평가 (기존 conditional 시스템)
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
// 조건에 따라 숨김 처리
@ -343,6 +343,35 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
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)) {
return (
@ -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

@ -0,0 +1,371 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Loader2, AlertCircle, Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management";
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
interface LayerConditionPanelProps {
layer: LayerDefinition;
components: ComponentData[]; // 화면의 모든 컴포넌트
onUpdateCondition: (condition: LayerCondition | undefined) => void;
onClose?: () => void;
}
// 조건 연산자 옵션
const OPERATORS = [
{ value: "eq", label: "같음 (=)" },
{ value: "neq", label: "같지 않음 (≠)" },
{ value: "in", label: "포함 (in)" },
] as const;
type OperatorType = "eq" | "neq" | "in";
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
layer,
components,
onUpdateCondition,
onClose,
}) => {
// 조건 설정 상태
const [targetComponentId, setTargetComponentId] = useState<string>(
layer.condition?.targetComponentId || ""
);
const [operator, setOperator] = useState<OperatorType>(
(layer.condition?.operator as OperatorType) || "eq"
);
const [value, setValue] = useState<string>(
layer.condition?.value?.toString() || ""
);
const [multiValues, setMultiValues] = useState<string[]>(
Array.isArray(layer.condition?.value) ? layer.condition.value : []
);
// 코드 목록 로딩 상태
const [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
const triggerableComponents = useMemo(() => {
return components.filter((comp) => {
const componentType = (comp.componentType || "").toLowerCase();
const widgetType = ((comp as any).widgetType || "").toLowerCase();
const webType = ((comp as any).webType || "").toLowerCase();
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"];
const isTriggerType = triggerTypes.some((type) =>
componentType.includes(type) ||
widgetType.includes(type) ||
webType.includes(type) ||
inputType.includes(type)
);
return isTriggerType;
});
}, [components]);
// 선택된 컴포넌트 정보
const selectedComponent = useMemo(() => {
return components.find((c) => c.id === targetComponentId);
}, [components, targetComponentId]);
// 선택된 컴포넌트의 코드 카테고리
const codeCategory = useMemo(() => {
if (!selectedComponent) return null;
// codeCategory 확인 (다양한 위치에 있을 수 있음)
const category =
(selectedComponent as any).codeCategory ||
(selectedComponent as any).componentConfig?.codeCategory ||
(selectedComponent as any).webTypeConfig?.codeCategory;
return category || null;
}, [selectedComponent]);
// 컴포넌트 선택 시 코드 목록 로드
useEffect(() => {
if (!codeCategory) {
setCodeOptions([]);
return;
}
const loadCodes = async () => {
setIsLoadingCodes(true);
setCodeLoadError(null);
try {
const codes = await getCodesByCategory(codeCategory);
setCodeOptions(codes);
} catch (error: any) {
console.error("코드 목록 로드 실패:", error);
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
setCodeOptions([]);
} finally {
setIsLoadingCodes(false);
}
};
loadCodes();
}, [codeCategory]);
// 조건 저장
const handleSave = useCallback(() => {
if (!targetComponentId) {
return;
}
const condition: LayerCondition = {
targetComponentId,
operator,
value: operator === "in" ? multiValues : value,
};
onUpdateCondition(condition);
onClose?.();
}, [targetComponentId, operator, value, multiValues, onUpdateCondition, onClose]);
// 조건 삭제
const handleClear = useCallback(() => {
onUpdateCondition(undefined);
setTargetComponentId("");
setOperator("eq");
setValue("");
setMultiValues([]);
onClose?.();
}, [onUpdateCondition, onClose]);
// in 연산자용 다중 값 토글
const toggleMultiValue = useCallback((val: string) => {
setMultiValues((prev) =>
prev.includes(val)
? prev.filter((v) => v !== val)
: [...prev, val]
);
}, []);
// 컴포넌트 라벨 가져오기
const getComponentLabel = (comp: ComponentData) => {
return comp.label || (comp as any).columnName || comp.id;
};
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
{layer.condition && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
</div>
{/* 트리거 컴포넌트 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select value={targetComponentId} onValueChange={setTargetComponentId}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컴포넌트 선택..." />
</SelectTrigger>
<SelectContent>
{triggerableComponents.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
.
<br />
(, , )
</div>
) : (
triggerableComponents.map((comp) => (
<SelectItem key={comp.id} value={comp.id} className="text-xs">
<div className="flex items-center gap-2">
<span>{getComponentLabel(comp)}</span>
<Badge variant="outline" className="text-[10px]">
{comp.componentType || (comp as any).widgetType}
</Badge>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
{/* 코드 카테고리 표시 */}
{codeCategory && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>:</span>
<Badge variant="secondary" className="text-[10px]">
{codeCategory}
</Badge>
</div>
)}
</div>
{/* 연산자 선택 */}
{targetComponentId && (
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={operator}
onValueChange={(val) => setOperator(val as OperatorType)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value} className="text-xs">
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 조건 값 선택 */}
{targetComponentId && (
<div className="space-y-2">
<Label className="text-xs">
{operator === "in" ? "값 선택 (복수)" : "값"}
</Label>
{isLoadingCodes ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : codeLoadError ? (
<div className="flex items-center gap-2 text-xs text-destructive p-2">
<AlertCircle className="h-3 w-3" />
{codeLoadError}
</div>
) : codeOptions.length > 0 ? (
// 코드 카테고리가 있는 경우 - 선택 UI
operator === "in" ? (
// 다중 선택 (in 연산자)
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
{codeOptions.map((code) => (
<div
key={code.codeValue}
className={cn(
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
multiValues.includes(code.codeValue) && "bg-primary/10"
)}
onClick={() => toggleMultiValue(code.codeValue)}
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center",
multiValues.includes(code.codeValue)
? "bg-primary border-primary"
: "border-input"
)}>
{multiValues.includes(code.codeValue) && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
<span>{code.codeName}</span>
<span className="text-muted-foreground">({code.codeValue})</span>
</div>
))}
</div>
) : (
// 단일 선택 (eq, neq 연산자)
<Select value={value} onValueChange={setValue}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="값 선택..." />
</SelectTrigger>
<SelectContent>
{codeOptions.map((code) => (
<SelectItem
key={code.codeValue}
value={code.codeValue}
className="text-xs"
>
{code.codeName} ({code.codeValue})
</SelectItem>
))}
</SelectContent>
</Select>
)
) : (
// 코드 카테고리가 없는 경우 - 직접 입력
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="조건 값 입력..."
className="h-8 text-xs"
/>
)}
{/* 선택된 값 표시 (in 연산자) */}
{operator === "in" && multiValues.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{multiValues.map((val) => {
const code = codeOptions.find((c) => c.codeValue === val);
return (
<Badge
key={val}
variant="secondary"
className="text-[10px] gap-1"
>
{code?.codeName || val}
<X
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
onClick={() => toggleMultiValue(val)}
/>
</Badge>
);
})}
</div>
)}
</div>
)}
{/* 현재 조건 요약 */}
{targetComponentId && (value || multiValues.length > 0) && (
<div className="p-2 bg-muted rounded-md text-xs">
<span className="font-medium">: </span>
<span className="text-muted-foreground">
"{getComponentLabel(selectedComponent!)}" {" "}
{operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
{" "}
</span>
</div>
)}
{/* 버튼 */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-xs"
onClick={handleClear}
>
</Button>
<Button
size="sm"
className="flex-1 h-8 text-xs"
onClick={handleSave}
disabled={!targetComponentId || (!value && multiValues.length === 0)}
>
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,405 @@
import React, { useState, useMemo, useCallback } from "react";
import { useLayer } from "@/contexts/LayerContext";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Eye,
EyeOff,
Lock,
Unlock,
Plus,
Trash2,
GripVertical,
Layers,
SplitSquareVertical,
PanelRight,
ChevronDown,
ChevronRight,
Settings2,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management";
import { LayerConditionPanel } from "./LayerConditionPanel";
// 레이어 타입별 아이콘
const getLayerTypeIcon = (type: LayerType) => {
switch (type) {
case "base":
return <Layers className="h-3 w-3" />;
case "conditional":
return <SplitSquareVertical className="h-3 w-3" />;
case "modal":
return <Settings2 className="h-3 w-3" />;
case "drawer":
return <PanelRight className="h-3 w-3" />;
default:
return <Layers className="h-3 w-3" />;
}
};
// 레이어 타입별 라벨
function getLayerTypeLabel(type: LayerType): string {
switch (type) {
case "base":
return "기본";
case "conditional":
return "조건부";
case "modal":
return "모달";
case "drawer":
return "드로어";
default:
return type;
}
}
// 레이어 타입별 색상
function getLayerTypeColor(type: LayerType): string {
switch (type) {
case "base":
return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300";
case "conditional":
return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300";
case "modal":
return "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300";
case "drawer":
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
default:
return "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300";
}
}
interface LayerItemProps {
layer: LayerDefinition;
isActive: boolean;
componentCount: number; // 실제 컴포넌트 수 (layout.components 기반)
allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트
onSelect: () => void;
onToggleVisibility: () => void;
onToggleLock: () => void;
onRemove: () => void;
onUpdateName: (name: string) => void;
onUpdateCondition: (condition: LayerCondition | undefined) => void;
}
const LayerItem: React.FC<LayerItemProps> = ({
layer,
isActive,
componentCount,
allComponents,
onSelect,
onToggleVisibility,
onToggleLock,
onRemove,
onUpdateName,
onUpdateCondition,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isConditionOpen, setIsConditionOpen] = useState(false);
// 조건부 레이어인지 확인
const isConditionalLayer = layer.type === "conditional";
// 조건 설정 여부
const hasCondition = !!layer.condition;
return (
<div className="space-y-0">
{/* 레이어 메인 영역 */}
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "hover:bg-muted border-transparent",
!layer.isVisible && "opacity-50",
isConditionOpen && "rounded-b-none border-b-0",
)}
onClick={onSelect}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
{/* 레이어 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{/* 레이어 타입 아이콘 */}
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
{getLayerTypeIcon(layer.type)}
</span>
{/* 레이어 이름 */}
{isEditing ? (
<input
type="text"
value={layer.name}
onChange={(e) => onUpdateName(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(false);
}}
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 truncate font-medium"
onDoubleClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
>
{layer.name}
</span>
)}
</div>
{/* 레이어 메타 정보 */}
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
{getLayerTypeLabel(layer.type)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{componentCount}
</span>
{/* 조건 설정됨 표시 */}
{hasCondition && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 gap-0.5">
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center gap-0.5 flex-shrink-0">
{/* 조건부 레이어일 때 조건 설정 버튼 */}
{isConditionalLayer && (
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6",
hasCondition && "text-amber-600"
)}
title="조건 설정"
onClick={(e) => {
e.stopPropagation();
setIsConditionOpen(!isConditionOpen);
}}
>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
onClick={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
>
{layer.isVisible ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
onClick={(e) => {
e.stopPropagation();
onToggleLock();
}}
>
{layer.isLocked ? (
<Lock className="text-destructive h-3.5 w-3.5" />
) : (
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
{layer.type !== "base" && (
<Button
variant="ghost"
size="icon"
className="hover:text-destructive h-6 w-6"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 조건 설정 패널 (조건부 레이어만) */}
{isConditionalLayer && isConditionOpen && (
<div className={cn(
"border border-t-0 rounded-b-md bg-muted/30",
isActive ? "border-primary" : "border-border"
)}>
<LayerConditionPanel
layer={layer}
components={allComponents}
onUpdateCondition={onUpdateCondition}
onClose={() => setIsConditionOpen(false)}
/>
</div>
)}
</div>
);
};
interface LayerManagerPanelProps {
components?: ComponentData[]; // layout.components를 전달받음
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components = [] }) => {
const {
layers,
activeLayerId,
setActiveLayerId,
addLayer,
removeLayer,
toggleLayerVisibility,
toggleLayerLock,
updateLayer,
} = useLayer();
// 레이어 조건 업데이트 핸들러
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
updateLayer(layerId, { condition });
}, [updateLayer]);
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
const componentCountByLayer = useMemo(() => {
const counts: Record<string, number> = {};
// 모든 레이어를 0으로 초기화
layers.forEach(layer => {
counts[layer.id] = 0;
});
// layout.components에서 layerId별로 카운트
components.forEach(comp => {
const layerId = comp.layerId || "default-layer";
if (counts[layerId] !== undefined) {
counts[layerId]++;
} else {
// layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트
if (counts["default-layer"] !== undefined) {
counts["default-layer"]++;
}
}
});
return counts;
}, [components, layers]);
return (
<div className="bg-background flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold"></h3>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{layers.length}
</Badge>
</div>
{/* 레이어 추가 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1">
<Plus className="h-3.5 w-3.5" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => addLayer("conditional", "조건부 레이어")}>
<SplitSquareVertical className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => addLayer("modal", "모달 레이어")}>
<Settings2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addLayer("drawer", "드로어 레이어")}>
<PanelRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 레이어 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{layers.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
.
<br />
<span className="text-xs"> + .</span>
</div>
) : (
layers
.slice()
.reverse() // 상위 레이어가 위에 표시
.map((layer) => (
<LayerItem
key={layer.id}
layer={layer}
isActive={activeLayerId === layer.id}
componentCount={componentCountByLayer[layer.id] || 0}
allComponents={components}
onSelect={() => setActiveLayerId(layer.id)}
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
onToggleLock={() => toggleLayerLock(layer.id)}
onRemove={() => removeLayer(layer.id)}
onUpdateName={(name) => updateLayer(layer.id, { name })}
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
/>
))
)}
</div>
</ScrollArea>
{/* 도움말 */}
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
<p>더블클릭: 이름 | 드래그: 순서 </p>
</div>
</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

@ -39,6 +39,8 @@ interface RealtimePreviewProps {
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
// 버튼 액션을 위한 props
@ -140,6 +142,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
onResize, // 🆕 리사이즈 콜백
}) => {
// 🆕 화면 다국어 컨텍스트
@ -594,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
@ -640,12 +639,14 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId}
onSelectPanelComponent={onSelectPanelComponent}
selectedPanelComponentId={selectedPanelComponentId}
/>
</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)}
@ -684,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

@ -18,6 +18,7 @@ import {
Loader2,
RefreshCw,
Building2,
AlertTriangle,
} from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import {
@ -175,7 +176,7 @@ export function ScreenGroupTreeView({
const [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | null>(null);
// 회사 선택 (최고 관리자용)
const { user, switchCompany } = useAuth();
const { user } = useAuth();
const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("");
const [isSyncCompanySelectOpen, setIsSyncCompanySelectOpen] = useState(false);
@ -301,23 +302,18 @@ export function ScreenGroupTreeView({
}
};
// 회사 선택 시 회사 전환 + 상태 조회
// 회사 선택 시 상태만 변경 (페이지 새로고침 없이)
const handleCompanySelect = async (companyCode: string) => {
setSelectedCompanyCode(companyCode);
setIsSyncCompanySelectOpen(false);
setSyncStatus(null);
if (companyCode) {
// 🔧 회사 전환 (JWT 토큰 변경) - 모든 API가 선택한 회사로 동작하도록
const switchResult = await switchCompany(companyCode);
if (!switchResult.success) {
toast.error(switchResult.message || "회사 전환 실패");
return;
// 동기화 상태 조회 (선택한 회사 코드로)
const response = await getMenuScreenSyncStatus(companyCode);
if (response.success && response.data) {
setSyncStatus(response.data);
}
toast.success(`${companyCode} 회사로 전환되었습니다. 페이지를 새로고침합니다.`);
// 🔧 페이지 새로고침으로 새 JWT 확실하게 적용
window.location.reload();
}
};
@ -447,17 +443,24 @@ export function ScreenGroupTreeView({
};
// 그룹과 모든 하위 그룹의 화면을 재귀적으로 수집
const getAllScreensInGroupRecursively = (groupId: number): ScreenDefinition[] => {
// 같은 회사의 그룹만 필터링하여 다른 회사 화면이 잘못 수집되는 것을 방지
const getAllScreensInGroupRecursively = (groupId: number, targetCompanyCode?: string): ScreenDefinition[] => {
const result: ScreenDefinition[] = [];
// 부모 그룹의 company_code 확인
const parentGroup = groups.find(g => g.id === groupId);
const companyCode = targetCompanyCode || parentGroup?.company_code;
// 현재 그룹의 화면들
const currentGroupScreens = getScreensInGroup(groupId);
result.push(...currentGroupScreens);
// 하위 그룹들 찾기
const childGroups = groups.filter((g) => (g as any).parent_group_id === groupId);
// 같은 회사 + 같은 부모를 가진 하위 그룹들 찾기
const childGroups = groups.filter((g) =>
(g as any).parent_group_id === groupId &&
(!companyCode || g.company_code === companyCode)
);
for (const childGroup of childGroups) {
const childScreens = getAllScreensInGroupRecursively(childGroup.id);
const childScreens = getAllScreensInGroupRecursively(childGroup.id, companyCode);
result.push(...childScreens);
}
@ -465,13 +468,22 @@ export function ScreenGroupTreeView({
};
// 모든 하위 그룹 ID를 재귀적으로 수집 (삭제 순서: 자식 → 부모)
const getAllChildGroupIds = (groupId: number): number[] => {
// 같은 회사의 그룹만 필터링하여 다른 회사 그룹이 잘못 삭제되는 것을 방지
const getAllChildGroupIds = (groupId: number, targetCompanyCode?: string): number[] => {
const result: number[] = [];
const childGroups = groups.filter((g) => (g as any).parent_group_id === groupId);
// 부모 그룹의 company_code 확인
const parentGroup = groups.find(g => g.id === groupId);
const companyCode = targetCompanyCode || parentGroup?.company_code;
// 같은 회사 + 같은 부모를 가진 그룹만 필터링
const childGroups = groups.filter((g) =>
(g as any).parent_group_id === groupId &&
(!companyCode || g.company_code === companyCode)
);
for (const childGroup of childGroups) {
// 자식의 자식들을 먼저 수집 (깊은 곳부터)
const grandChildIds = getAllChildGroupIds(childGroup.id);
const grandChildIds = getAllChildGroupIds(childGroup.id, companyCode);
result.push(...grandChildIds);
result.push(childGroup.id);
}
@ -483,10 +495,35 @@ export function ScreenGroupTreeView({
const confirmDeleteGroup = async () => {
if (!deletingGroup) return;
// 🔍 디버깅: 삭제 대상 그룹 정보
console.log("========== 그룹 삭제 디버깅 ==========");
console.log("삭제 대상 그룹:", {
id: deletingGroup.id,
name: deletingGroup.group_name,
company_code: deletingGroup.company_code,
parent_group_id: (deletingGroup as any).parent_group_id
});
// 🔍 디버깅: 전체 groups 배열에서 같은 회사 그룹 출력
const sameCompanyGroups = groups.filter(g => g.company_code === deletingGroup.company_code);
console.log("같은 회사 그룹들:", sameCompanyGroups.map(g => ({
id: g.id,
name: g.group_name,
parent_group_id: (g as any).parent_group_id
})));
// 삭제 전 통계 수집 (화면 수는 삭제 전에 계산)
const totalScreensToDelete = getAllScreensInGroupRecursively(deletingGroup.id).length;
const childGroupIds = getAllChildGroupIds(deletingGroup.id);
// 🔍 디버깅: 수집된 하위 그룹 ID들
console.log("수집된 하위 그룹 ID들:", childGroupIds);
console.log("하위 그룹 상세:", childGroupIds.map(id => {
const g = groups.find(grp => grp.id === id);
return g ? { id: g.id, name: g.group_name, parent_group_id: (g as any).parent_group_id } : { id, name: "NOT_FOUND" };
}));
console.log("==========================================");
// 총 작업 수 계산 (화면 + 하위 그룹 + 현재 그룹)
const totalSteps = totalScreensToDelete + childGroupIds.length + 1;
let currentStep = 0;
@ -511,7 +548,7 @@ export function ScreenGroupTreeView({
total: totalSteps,
message: `화면 삭제 중: ${screen.screenName}`
});
await screenApi.deleteScreen(screen.screenId, "그룹 삭제와 함께 삭제");
await screenApi.deleteScreen(screen.screenId, "그룹 삭제와 함께 삭제", true); // force: true로 의존성 무시
}
console.log(`✅ 그룹 및 하위 그룹 내 화면 ${allScreens.length}개 삭제 완료`);
}
@ -1427,16 +1464,26 @@ export function ScreenGroupTreeView({
{/* 그룹 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px] border-destructive/50">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{deletingGroup?.group_name}" ?
<br />
<AlertDialogTitle className="text-base sm:text-lg flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-xs sm:text-sm">
<div className="mt-2 rounded-md bg-destructive/10 border border-destructive/30 p-3">
<p className="font-semibold text-destructive">
&quot;{deletingGroup?.group_name}&quot; ?
</p>
<p className="mt-2 text-destructive/80">
{deleteScreensWithGroup
? <span className="text-destructive font-medium"> .</span>
? "⚠️ 그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
: "그룹에 속한 화면들은 미분류로 이동됩니다."
}
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
@ -1534,11 +1581,21 @@ export function ScreenGroupTreeView({
)}
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
"{deletingScreen?.screenName}" ?
<br />
.
<AlertDialogTitle className="text-base sm:text-lg flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-xs sm:text-sm">
<div className="mt-2 rounded-md bg-destructive/10 border border-destructive/30 p-3">
<p className="font-semibold text-destructive">
&quot;{deletingScreen?.screenName}&quot; ?
</p>
<p className="mt-2 text-destructive/80">
, . .
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">

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

@ -148,6 +148,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);
@ -172,6 +185,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(
async (tableName: string): Promise<ColumnTypeInfo[]> => {
@ -267,7 +284,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
const flows = flowsRes.success ? flowsRes.data || [] : [];
const relations = relationsRes.success ? relationsRes.data || [] : [];
// 데이터 흐름에서 연결된 화면들 추가
// 데이터 흐름에서 연결된 화면들 추가 (개별 화면 모드에서만 - 그룹 모드에서는 그룹 내 화면만 표시)
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);
@ -285,6 +303,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
}
}
});
}
// 화면 레이아웃 요약 정보 로드
const screenIds = screenList.map((s) => s.screenId);
@ -306,6 +325,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);
@ -435,9 +461,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]) => {
@ -445,11 +489,14 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
const subTableNames: string[] = [];
screenSubData.subTables.forEach((subTable) => {
// 메인 테이블에 없는 것만 서브 테이블로 추가
if (!mainTableSet.has(subTable.tableName)) {
// mainTableSet에 있으면 서브 테이블에서 제외 (우선순위: 메인 > 서브)
if (mainTableSet.has(subTable.tableName)) {
return;
}
// 조인으로만 연결된 테이블 → 서브 테이블
subTableSet.add(subTable.tableName);
subTableNames.push(subTable.tableName);
}
});
if (subTableNames.length > 0) {
@ -540,10 +587,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({
@ -553,7 +609,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

@ -367,7 +367,7 @@ export function ScreenSettingModal({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-lg">
@ -528,11 +528,10 @@ export function ScreenSettingModal({
</DialogContent>
</Dialog>
{/* ScreenDesigner 전체 화면 모달 */}
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
<DialogTitle className="sr-only"> </DialogTitle>
<div className="flex flex-col h-full">
{/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
{/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
{showDesignerModal && (
<div className="bg-background fixed inset-0 z-[1000] flex flex-col">
<ScreenDesigner
selectedScreen={{
screenId: currentScreenId,
@ -547,15 +546,12 @@ export function ScreenSettingModal({
}}
onBackToList={async () => {
setShowDesignerModal(false);
// 디자이너에서 저장 후 모달 닫으면 데이터 새로고침
await loadData();
// 데이터 로드 완료 후 iframe 갱신
setIframeKey(prev => prev + 1);
}}
/>
</div>
</DialogContent>
</Dialog>
)}
{/* TableSettingModal */}
{tableSettingTarget && (
@ -736,7 +732,12 @@ function TableColumnAccordion({
if (allTables.length === 0) {
const tablesResult = await tableManagementApi.getTableList();
if (tablesResult.success && tablesResult.data) {
setAllTables(tablesResult.data);
// 중복 테이블 제거 (tableName 기준)
const uniqueTables = tablesResult.data.filter(
(table, index, self) =>
index === self.findIndex((t) => t.tableName === table.tableName)
);
setAllTables(uniqueTables);
}
}
} catch (error) {
@ -1351,9 +1352,9 @@ function JoinSettingEditor({
<CommandList>
<CommandEmpty className="text-xs py-2"> .</CommandEmpty>
<CommandGroup>
{allTables.map(t => (
{allTables.map((t, idx) => (
<CommandItem
key={t.tableName}
key={`${t.tableName}-${idx}`}
value={t.displayName || t.tableName}
onSelect={() => {
setEditingJoin({ ...editingJoin, referenceTable: t.tableName, referenceColumn: "", displayColumn: "" });

View File

@ -174,30 +174,10 @@ export default function TableTypeSelector({
}
};
// 입력 타입 변경
const handleInputTypeChange = async (columnName: string, inputType: "direct" | "auto") => {
try {
// 현재 컬럼 정보 가져오기
const currentColumn = columns.find((col) => col.columnName === columnName);
if (!currentColumn) return;
// 웹 타입과 함께 입력 타입 업데이트
await tableTypeApi.setColumnWebType(
selectedTable,
columnName,
currentColumn.webType || "text",
undefined, // detailSettings
inputType,
);
// 로컬 상태 업데이트
// 입력 타입 변경 (로컬 상태만 - DB에 저장하지 않음)
const handleInputTypeChange = (columnName: string, inputType: "direct" | "auto") => {
// 로컬 상태만 업데이트 (DB에는 저장하지 않음 - inputType은 화면 렌더링용)
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, inputType } : col)));
// console.log(`컬럼 ${columnName}의 입력 타입을 ${inputType}로 변경했습니다.`);
} catch (error) {
// console.error("입력 타입 변경 실패:", error);
alert("입력 타입 설정에 실패했습니다. 다시 시도해주세요.");
}
};
const filteredTables = tables.filter((table) => table.displayName.toLowerCase().includes(searchTerm.toLowerCase()));

View File

@ -51,11 +51,7 @@ 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 제거)
@ -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>>({});
@ -464,7 +462,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
loadModalActionMappingData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]);
}, [
config.action?.type,
config.action?.autoDetectDataSource,
config.action?.targetScreenId,
currentTableName,
allComponents,
]);
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
useEffect(() => {
@ -831,8 +835,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="operation_control"> </SelectItem>
{/* 🔒 - , UI
{/* 이벤트 버스 */}
<SelectItem value="event"> </SelectItem>
{/* 복사 */}
<SelectItem value="copy"> ( )</SelectItem>
{/* 🔒 - , UI
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="openModalWithData">(deprecated) + </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
@ -980,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>
@ -991,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>
@ -1007,14 +1016,18 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
{/* 테이블이 같으면 자동 매핑 안내 */}
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && (
{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 && (
{modalActionSourceTable &&
modalActionTargetTable &&
modalActionSourceTable !== modalActionTargetTable && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
@ -1024,7 +1037,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
size="sm"
className="h-6 text-xs"
onClick={() => {
const newMappings = [...(component.componentConfig?.action?.fieldMappings || []), { sourceField: "", targetField: "" }];
const newMappings = [
...(component.componentConfig?.action?.fieldMappings || []),
{ sourceField: "", targetField: "" },
];
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
@ -1035,7 +1051,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
. .
</p>
)}
@ -1045,12 +1061,15 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 소스 필드 선택 */}
<Popover
open={modalFieldMappingSourceOpen[index] || false}
onOpenChange={(open) => setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))}
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
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label ||
mapping.sourceField
: "소스 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
@ -1060,22 +1079,31 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingSourceSearch[index] || ""}
onValueChange={(val) => setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))}
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())
.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 || [])];
const newMappings = [
...(component.componentConfig?.action?.fieldMappings || []),
];
newMappings[index] = { ...newMappings[index], sourceField: col.name };
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
@ -1083,11 +1111,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}}
>
<Check
className={cn("mr-2 h-4 w-4", mapping.sourceField === col.name ? "opacity-100" : "opacity-0")}
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>
<span className="text-muted-foreground text-[10px]">{col.name}</span>
</div>
</CommandItem>
))}
@ -1097,17 +1128,20 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground"></span>
<span className="text-muted-foreground text-xs"></span>
{/* 대상 필드 선택 */}
<Popover
open={modalFieldMappingTargetOpen[index] || false}
onOpenChange={(open) => setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))}
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
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label ||
mapping.targetField
: "대상 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
@ -1117,22 +1151,31 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingTargetSearch[index] || ""}
onValueChange={(val) => setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))}
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())
.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 || [])];
const newMappings = [
...(component.componentConfig?.action?.fieldMappings || []),
];
newMappings[index] = { ...newMappings[index], targetField: col.name };
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
@ -1140,11 +1183,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}}
>
<Check
className={cn("mr-2 h-4 w-4", mapping.targetField === col.name ? "opacity-100" : "opacity-0")}
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>
<span className="text-muted-foreground text-[10px]">{col.name}</span>
</div>
</CommandItem>
))}
@ -1159,9 +1205,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10"
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);
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter(
(_: any, i: number) => i !== index,
);
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
@ -1180,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>
{/* 🆕 블록 기반 제목 빌더 */}
@ -3536,6 +3585,104 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/>
)}
{/* 🆕 이벤트 발송 액션 설정 */}
{localInputs.actionType === "event" && (
<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 .
.
</p>
<div>
<Label htmlFor="event-name"> </Label>
<Select
value={component.componentConfig?.action?.eventConfig?.eventName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.eventConfig.eventName", value);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="이벤트 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="SCHEDULE_GENERATE_REQUEST"> </SelectItem>
<SelectItem value="TABLE_REFRESH"> </SelectItem>
<SelectItem value="DATA_CHANGED"> </SelectItem>
</SelectContent>
</Select>
</div>
{component.componentConfig?.action?.eventConfig?.eventName === "SCHEDULE_GENERATE_REQUEST" && (
<div className="border-primary/20 space-y-3 border-l-2 pl-4">
<div>
<Label> </Label>
<Select
value={component.componentConfig?.action?.eventConfig?.eventPayload?.scheduleType || "PRODUCTION"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.eventConfig.eventPayload.scheduleType", value);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="스케줄 유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="PRODUCTION"> </SelectItem>
<SelectItem value="DELIVERY"> </SelectItem>
<SelectItem value="MAINTENANCE"> </SelectItem>
<SelectItem value="CUSTOM"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label> ()</Label>
<Input
type="number"
className="h-8 text-xs"
placeholder="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,
);
}}
/>
</div>
<div>
<Label> </Label>
<Input
type="number"
className="h-8 text-xs"
placeholder="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,
);
}}
/>
</div>
<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> .
.
</p>
</div>
</div>
)}
</div>
)}
{/* 🆕 행 선택 시에만 활성화 설정 */}
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>

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

@ -0,0 +1,144 @@
"use client";
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
interface ShortcutItem {
keys: string[];
description: string;
}
interface ShortcutGroup {
title: string;
shortcuts: ShortcutItem[];
}
const shortcutGroups: ShortcutGroup[] = [
{
title: "기본 조작",
shortcuts: [
{ keys: ["Ctrl", "S"], description: "레이아웃 저장" },
{ keys: ["Ctrl", "Z"], description: "실행취소" },
{ keys: ["Ctrl", "Y"], description: "다시실행" },
{ keys: ["Ctrl", "A"], description: "전체 선택" },
{ keys: ["Delete"], description: "선택 삭제" },
{ keys: ["Esc"], description: "선택 해제" },
],
},
{
title: "복사/붙여넣기",
shortcuts: [
{ keys: ["Ctrl", "C"], description: "컴포넌트 복사" },
{ keys: ["Ctrl", "V"], description: "컴포넌트 붙여넣기" },
],
},
{
title: "그룹 관리",
shortcuts: [
{ keys: ["Ctrl", "G"], description: "그룹 생성" },
{ keys: ["Ctrl", "Shift", "G"], description: "그룹 해제" },
],
},
{
title: "이동 (Nudge)",
shortcuts: [
{ keys: ["Arrow"], description: "1px 이동" },
{ keys: ["Shift", "Arrow"], description: "10px 이동" },
],
},
{
title: "정렬 (다중 선택 시)",
shortcuts: [
{ keys: ["Alt", "L"], description: "좌측 정렬" },
{ keys: ["Alt", "R"], description: "우측 정렬" },
{ keys: ["Alt", "C"], description: "가로 중앙 정렬" },
{ keys: ["Alt", "T"], description: "상단 정렬" },
{ keys: ["Alt", "B"], description: "하단 정렬" },
{ keys: ["Alt", "M"], description: "세로 중앙 정렬" },
],
},
{
title: "배분/크기 (다중 선택 시)",
shortcuts: [
{ keys: ["Alt", "H"], description: "가로 균등 배분" },
{ keys: ["Alt", "V"], description: "세로 균등 배분" },
{ keys: ["Alt", "W"], description: "너비 맞추기" },
{ keys: ["Alt", "E"], description: "높이 맞추기" },
],
},
{
title: "보기/탐색",
shortcuts: [
{ keys: ["Space", "Drag"], description: "캔버스 팬(이동)" },
{ keys: ["Wheel"], description: "줌 인/아웃" },
{ keys: ["P"], description: "패널 열기/닫기" },
{ keys: ["Alt", "Shift", "L"], description: "라벨 일괄 표시/숨기기" },
{ keys: ["?"], description: "단축키 도움말" },
],
},
];
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
}
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
isOpen,
onClose,
}) => {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. Mac에서는 Ctrl Cmd를 .
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
{shortcutGroups.map((group) => (
<div key={group.title}>
<h3 className="text-sm font-semibold text-foreground mb-2">
{group.title}
</h3>
<div className="space-y-1">
{group.shortcuts.map((shortcut, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-muted/50 transition-colors"
>
<span className="text-sm text-muted-foreground">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, kidx) => (
<React.Fragment key={kidx}>
{kidx > 0 && (
<span className="text-xs text-muted-foreground">+</span>
)}
<kbd className="inline-flex h-6 min-w-[24px] items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs text-muted-foreground shadow-sm">
{key}
</kbd>
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
};

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

@ -431,7 +431,8 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
}
return (
<div key={joinTable.tableName} className="space-y-1">
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지)
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
{/* 조인 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"

View File

@ -208,17 +208,14 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
if (componentId?.startsWith("v2-")) {
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
"v2-input": require("@/components/v2/config-panels/V2InputConfigPanel").V2InputConfigPanel,
"v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel")
.V2SelectConfigPanel,
"v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel").V2SelectConfigPanel,
"v2-date": require("@/components/v2/config-panels/V2DateConfigPanel").V2DateConfigPanel,
"v2-list": require("@/components/v2/config-panels/V2ListConfigPanel").V2ListConfigPanel,
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel")
.V2LayoutConfigPanel,
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel")
.V2HierarchyConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
};
const V2ConfigPanel = v2ConfigPanels[componentId];
@ -263,6 +260,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
definitionName: definition.name,
hasConfigPanel: !!definition.configPanel,
currentConfig,
defaultSort: currentConfig?.defaultSort, // 🔍 defaultSort 확인
});
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
@ -822,8 +820,16 @@ 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 +863,20 @@ 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 +886,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 +901,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 +914,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}
@ -943,8 +961,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
}
// 🆕 3.5. V2 컴포넌트 - 반드시 다른 체크보다 먼저 처리
const v2ComponentType =
(selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
const v2ComponentType = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
if (v2ComponentType.startsWith("v2-")) {
const configPanel = renderComponentConfigPanel();
if (configPanel) {
@ -1055,8 +1072,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

@ -22,6 +22,18 @@ import {
Settings2,
PanelLeft,
PanelLeftClose,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
AlignStartHorizontal,
AlignCenterHorizontal,
AlignEndHorizontal,
AlignHorizontalSpaceAround,
AlignVerticalSpaceAround,
RulerIcon,
Tag,
Keyboard,
Equal,
} from "lucide-react";
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
import {
@ -50,6 +62,10 @@ interface GridSettings {
gridOpacity?: number;
}
type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
type DistributeDirection = "horizontal" | "vertical";
type MatchSizeMode = "width" | "height" | "both";
interface SlimToolbarProps {
screenName?: string;
tableName?: string;
@ -67,6 +83,13 @@ interface SlimToolbarProps {
// 패널 토글 기능
isPanelOpen?: boolean;
onTogglePanel?: () => void;
// 정렬/배분/크기 기능
selectedCount?: number;
onAlign?: (mode: AlignMode) => void;
onDistribute?: (direction: DistributeDirection) => void;
onMatchSize?: (mode: MatchSizeMode) => void;
onToggleLabels?: () => void;
onShowShortcuts?: () => void;
}
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
@ -85,6 +108,12 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onOpenMultilangSettings,
isPanelOpen = false,
onTogglePanel,
selectedCount = 0,
onAlign,
onDistribute,
onMatchSize,
onToggleLabels,
onShowShortcuts,
}) => {
// 사용자 정의 해상도 상태
const [customWidth, setCustomWidth] = useState("");
@ -325,8 +354,100 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
)}
</div>
{/* 중앙: 정렬/배분 도구 (다중 선택 시 표시) */}
{selectedCount >= 2 && (onAlign || onDistribute || onMatchSize) && (
<div className="flex items-center space-x-1 rounded-md bg-blue-50 px-2 py-1">
{/* 정렬 */}
{onAlign && (
<>
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("left")} title="좌측 정렬 (Alt+L)">
<AlignStartVertical className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerX")} title="가로 중앙 (Alt+C)">
<AlignCenterVertical className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("right")} title="우측 정렬 (Alt+R)">
<AlignEndVertical className="h-3.5 w-3.5" />
</Button>
<div className="mx-0.5 h-4 w-px bg-blue-200" />
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("top")} title="상단 정렬 (Alt+T)">
<AlignStartHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerY")} title="세로 중앙 (Alt+M)">
<AlignCenterHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("bottom")} title="하단 정렬 (Alt+B)">
<AlignEndHorizontal className="h-3.5 w-3.5" />
</Button>
</>
)}
{/* 배분 (3개 이상 선택 시) */}
{onDistribute && selectedCount >= 3 && (
<>
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("horizontal")} title="가로 균등 배분 (Alt+H)">
<AlignHorizontalSpaceAround className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("vertical")} title="세로 균등 배분 (Alt+V)">
<AlignVerticalSpaceAround className="h-3.5 w-3.5" />
</Button>
</>
)}
{/* 크기 맞추기 */}
{onMatchSize && (
<>
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("width")} title="너비 맞추기 (Alt+W)">
<RulerIcon className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("height")} title="높이 맞추기 (Alt+E)">
<RulerIcon className="h-3.5 w-3.5 rotate-90" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("both")} title="크기 모두 맞추기">
<Equal className="h-3.5 w-3.5" />
</Button>
</>
)}
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="text-xs text-blue-600">{selectedCount} </span>
</div>
)}
{/* 우측: 버튼들 */}
<div className="flex items-center space-x-2">
{/* 라벨 토글 버튼 */}
{onToggleLabels && (
<Button
variant="outline"
size="sm"
onClick={onToggleLabels}
className="flex items-center space-x-1"
title="라벨 일괄 표시/숨기기 (Alt+Shift+L)"
>
<Tag className="h-4 w-4" />
<span></span>
</Button>
)}
{/* 단축키 도움말 */}
{onShowShortcuts && (
<Button
variant="ghost"
size="icon"
onClick={onShowShortcuts}
className="h-9 w-9"
title="단축키 도움말 (?)"
>
<Keyboard className="h-4 w-4" />
</Button>
)}
{onPreview && (
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
<Eye className="h-4 w-4" />

View File

@ -66,6 +66,33 @@ export function TabsWidget({
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,18 +109,23 @@ 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 }));
}
}
}
@ -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}`;
};
@ -172,8 +200,8 @@ export function TabsWidget({
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>
);
}
@ -181,7 +209,7 @@ export function TabsWidget({
// 에러 발생
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>
);
@ -196,7 +224,7 @@ export function TabsWidget({
// 아직 로드되지 않은 경우
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>
);
}
@ -219,17 +247,12 @@ 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
@ -268,11 +291,11 @@ 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 (
@ -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,
@ -321,6 +344,7 @@ export function TabsWidget({
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
isDesignMode={isDesignMode}
isInteractive={!isDesignMode}
/>
</div>
);
@ -352,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 && (
@ -362,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,15 +9,19 @@ import { WidgetComponent } from "@/types/screen";
import { toast } from "sonner";
import { apiClient, getFullImageUrl } from "@/lib/api/client";
export const ImageWidget: React.FC<WebTypeComponentProps> = ({
export const ImageWidget: React.FC<
WebTypeComponentProps & { size?: { width?: number; height?: number }; style?: React.CSSProperties }
> = ({
component,
value,
onChange,
readonly = false,
isDesignMode = false // 디자인 모드 여부
isDesignMode = false, // 디자인 모드 여부
size, // props로 전달된 size
style: propStyle, // props로 전달된 style
}) => {
const widget = component as WidgetComponent;
const { required, style } = widget;
const { required, style: widgetStyle } = widget;
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
@ -25,8 +29,16 @@ export const ImageWidget: React.FC<WebTypeComponentProps> = ({
const rawImageUrl = value || widget.value || "";
const imageUrl = rawImageUrl ? getFullImageUrl(rawImageUrl) : "";
// style에서 width, height 제거 (부모 컨테이너 크기 사용)
const filteredStyle = style ? { ...style, width: undefined, height: undefined } : {};
// 🔧 컴포넌트 크기를 명시적으로 적용 (props.size 우선, 없으면 style에서 가져옴)
const effectiveSize = size || (widget as any).size || {};
const effectiveStyle = propStyle || widgetStyle || {};
const containerStyle: React.CSSProperties = {
width: effectiveSize.width ? `${effectiveSize.width}px` : effectiveStyle?.width || "100%",
height: effectiveSize.height ? `${effectiveSize.height}px` : effectiveStyle?.height || "100%",
};
// style에서 width, height 제거 (내부 요소용)
const filteredStyle = effectiveStyle ? { ...effectiveStyle, width: undefined, height: undefined } : {};
// 파일 선택 처리
const handleFileSelect = () => {
@ -120,11 +132,11 @@ export const ImageWidget: React.FC<WebTypeComponentProps> = ({
};
return (
<div className="h-full w-full">
<div className="flex h-full w-full flex-col" style={containerStyle}>
{imageUrl ? (
// 이미지 표시 모드
<div
className="group relative h-full 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
@ -132,19 +144,15 @@ export const ImageWidget: React.FC<WebTypeComponentProps> = ({
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";
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>
@ -154,7 +162,7 @@ export const ImageWidget: React.FC<WebTypeComponentProps> = ({
) : (
// 업로드 영역
<div
className={`group relative flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
className={`group relative flex w-full flex-1 flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
isDesignMode
? "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"
@ -189,9 +197,7 @@ export const ImageWidget: React.FC<WebTypeComponentProps> = ({
/>
{/* 필수 필드 경고 */}
{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}

Some files were not shown because too many files have changed in this diff Show More