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:
commit
b85f8559e4
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"agent-orchestrator": {
|
||||
"command": "node",
|
||||
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
43
.cursorrules
43
.cursorrules
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 최우선 보안 규칙: 멀티테넌시
|
||||
|
||||
**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:**
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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); // 권한 그룹 관리
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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개 파일
|
||||
|
|
|
|||
|
|
@ -3,392 +3,545 @@
|
|||
*/
|
||||
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import {
|
||||
authenticateToken,
|
||||
AuthenticatedRequest,
|
||||
} from "../middleware/authMiddleware";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 규칙 목록 조회 (전체)
|
||||
router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
router.get(
|
||||
"/",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
try {
|
||||
const rules = await numberingRuleService.getRuleList(companyCode);
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const rules = await numberingRuleService.getRuleList(companyCode);
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 메뉴별 사용 가능한 규칙 조회
|
||||
router.get("/available/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
|
||||
router.get(
|
||||
"/available/:menuObjid?",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid
|
||||
? parseInt(req.params.menuObjid)
|
||||
: undefined;
|
||||
|
||||
logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode });
|
||||
logger.info("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode });
|
||||
|
||||
try {
|
||||
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
|
||||
|
||||
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
rulesCount: rules.length
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", {
|
||||
error: error.message,
|
||||
errorCode: error.code,
|
||||
errorStack: error.stack,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const rules = await numberingRuleService.getAvailableRulesForMenu(
|
||||
companyCode,
|
||||
menuObjid
|
||||
);
|
||||
|
||||
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
rulesCount: rules.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", {
|
||||
error: error.message,
|
||||
errorCode: error.code,
|
||||
errorStack: error.stack,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 화면용 채번 규칙 조회 (테이블 기반 필터링 - 간소화)
|
||||
router.get("/available-for-screen", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName } = req.query;
|
||||
router.get(
|
||||
"/available-for-screen",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName } = req.query;
|
||||
|
||||
try {
|
||||
// tableName 필수 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "tableName is required",
|
||||
});
|
||||
}
|
||||
|
||||
const rules = await numberingRuleService.getAvailableRulesForScreen(
|
||||
companyCode,
|
||||
tableName
|
||||
);
|
||||
|
||||
logger.info("화면용 채번 규칙 조회 성공", {
|
||||
companyCode,
|
||||
tableName,
|
||||
count: rules.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("화면용 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
tableName,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 규칙 조회
|
||||
router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getRuleById(ruleId, companyCode);
|
||||
if (!rule) {
|
||||
return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" });
|
||||
}
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 규칙 생성
|
||||
router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
||||
logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", {
|
||||
companyCode,
|
||||
userId,
|
||||
ruleId: ruleConfig.ruleId,
|
||||
ruleName: ruleConfig.ruleName,
|
||||
scopeType: ruleConfig.scopeType,
|
||||
menuObjid: ruleConfig.menuObjid,
|
||||
tableName: ruleConfig.tableName,
|
||||
partsCount: ruleConfig.parts?.length,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
|
||||
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) {
|
||||
return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" });
|
||||
}
|
||||
|
||||
// 🆕 scopeType이 'table'인 경우 tableName 필수 체크
|
||||
if (ruleConfig.scopeType === "table") {
|
||||
if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") {
|
||||
try {
|
||||
// tableName 필수 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다",
|
||||
error: "tableName is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
|
||||
|
||||
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
|
||||
ruleId: newRule.ruleId,
|
||||
menuObjid: newRule.menuObjid,
|
||||
});
|
||||
const rules = await numberingRuleService.getAvailableRulesForScreen(
|
||||
companyCode,
|
||||
tableName
|
||||
);
|
||||
|
||||
return res.status(201).json({ success: true, data: newRule });
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
|
||||
logger.info("화면용 채번 규칙 조회 성공", {
|
||||
companyCode,
|
||||
tableName,
|
||||
count: rules.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("화면용 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
tableName,
|
||||
});
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 특정 규칙 조회
|
||||
router.get(
|
||||
"/:ruleId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getRuleById(ruleId, companyCode);
|
||||
if (!rule) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "규칙을 찾을 수 없습니다" });
|
||||
}
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("규칙 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 규칙 생성
|
||||
router.post(
|
||||
"/",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
||||
logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", {
|
||||
companyCode,
|
||||
userId,
|
||||
ruleId: ruleConfig.ruleId,
|
||||
ruleName: ruleConfig.ruleName,
|
||||
scopeType: ruleConfig.scopeType,
|
||||
menuObjid: ruleConfig.menuObjid,
|
||||
tableName: ruleConfig.tableName,
|
||||
partsCount: ruleConfig.parts?.length,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({
|
||||
success: false,
|
||||
error: "최소 1개 이상의 규칙 파트가 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 scopeType이 'table'인 경우 tableName 필수 체크
|
||||
if (ruleConfig.scopeType === "table") {
|
||||
if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newRule = await numberingRuleService.createRule(
|
||||
ruleConfig,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
|
||||
ruleId: newRule.ruleId,
|
||||
menuObjid: newRule.menuObjid,
|
||||
});
|
||||
|
||||
return res.status(201).json({ success: true, data: newRule });
|
||||
} catch (error: any) {
|
||||
if (error.code === "23505") {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
|
||||
}
|
||||
logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 규칙 수정
|
||||
router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const updates = req.body;
|
||||
router.put(
|
||||
"/:ruleId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
||||
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
||||
|
||||
try {
|
||||
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
|
||||
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
||||
return res.json({ success: true, data: updatedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
try {
|
||||
const updatedRule = await numberingRuleService.updateRule(
|
||||
ruleId,
|
||||
updates,
|
||||
companyCode
|
||||
);
|
||||
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
||||
return res.json({ success: true, data: updatedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
}
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 규칙 삭제
|
||||
router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
router.delete(
|
||||
"/:ruleId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRule(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
try {
|
||||
await numberingRuleService.deleteRule(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
}
|
||||
logger.error("규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
logger.error("규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 코드 미리보기 (순번 증가 없음)
|
||||
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
router.post(
|
||||
"/:ruleId/preview",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(
|
||||
ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 코드 할당 (저장 시점에 실제 순번 증가)
|
||||
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
|
||||
router.post(
|
||||
"/:ruleId/allocate",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData, userInputCode } = req.body; // 폼 데이터 + 사용자가 편집한 코드
|
||||
|
||||
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData });
|
||||
logger.info("코드 할당 요청", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
hasFormData: !!formData,
|
||||
userInputCode,
|
||||
});
|
||||
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
|
||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(
|
||||
ruleId,
|
||||
companyCode,
|
||||
formData,
|
||||
userInputCode
|
||||
);
|
||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { generatedCode: allocatedCode },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("코드 할당 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 코드 생성 (기존 호환성 유지, deprecated)
|
||||
router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
router.post(
|
||||
"/:ruleId/generate",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode);
|
||||
return res.json({ success: true, data: { generatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.generateCode(
|
||||
ruleId,
|
||||
companyCode
|
||||
);
|
||||
return res.json({ success: true, data: { generatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// 시퀀스 초기화
|
||||
router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
router.post(
|
||||
"/:ruleId/reset",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.resetSequence(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "시퀀스가 초기화되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("시퀀스 초기화 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
await numberingRuleService.resetSequence(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "시퀀스가 초기화되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("시퀀스 초기화 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// ==================== 테스트 테이블용 API ====================
|
||||
|
||||
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회
|
||||
router.get("/test/list/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
|
||||
router.get(
|
||||
"/test/list/:menuObjid?",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const menuObjid = req.params.menuObjid
|
||||
? parseInt(req.params.menuObjid)
|
||||
: undefined;
|
||||
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 요청", { companyCode, menuObjid });
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 요청", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
});
|
||||
|
||||
try {
|
||||
const rules = await numberingRuleService.getRulesFromTest(companyCode, menuObjid);
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 성공", { companyCode, menuObjid, count: rules.length });
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const rules = await numberingRuleService.getRulesFromTest(
|
||||
companyCode,
|
||||
menuObjid
|
||||
);
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 성공", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
count: rules.length,
|
||||
});
|
||||
return res.json({ success: true, data: rules });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 목록 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// [테스트] 테이블+컬럼 기반 채번 규칙 조회
|
||||
router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
router.get(
|
||||
"/test/by-column/:tableName/:columnName",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, columnName);
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// [테스트] 테스트 테이블에 채번 규칙 저장
|
||||
// 채번 규칙은 독립적으로 생성 가능 (나중에 테이블 타입 관리에서 컬럼에 연결)
|
||||
router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
router.post(
|
||||
"/test/save",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
||||
logger.info("[테스트] 채번 규칙 저장 요청", {
|
||||
ruleId: ruleConfig.ruleId,
|
||||
ruleName: ruleConfig.ruleName,
|
||||
tableName: ruleConfig.tableName || "(미지정)",
|
||||
columnName: ruleConfig.columnName || "(미지정)",
|
||||
});
|
||||
logger.info("[테스트] 채번 규칙 저장 요청", {
|
||||
ruleId: ruleConfig.ruleId,
|
||||
ruleName: ruleConfig.ruleName,
|
||||
tableName: ruleConfig.tableName || "(미지정)",
|
||||
columnName: ruleConfig.columnName || "(미지정)",
|
||||
});
|
||||
|
||||
try {
|
||||
// ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결)
|
||||
if (!ruleConfig.ruleName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "ruleName is required"
|
||||
});
|
||||
try {
|
||||
// ruleName만 필수, tableName/columnName은 선택 (나중에 테이블 타입 관리에서 연결)
|
||||
if (!ruleConfig.ruleName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "ruleName is required",
|
||||
});
|
||||
}
|
||||
|
||||
const savedRule = await numberingRuleService.saveRuleToTest(
|
||||
ruleConfig,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
|
||||
const savedRule = await numberingRuleService.saveRuleToTest(ruleConfig, companyCode, userId);
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// [테스트] 테스트 테이블에서 채번 규칙 삭제
|
||||
router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
router.delete(
|
||||
"/test/:ruleId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// [테스트] 코드 미리보기 (테스트 테이블 사용)
|
||||
router.post("/test/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body;
|
||||
router.post(
|
||||
"/test/:ruleId/preview",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body;
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(
|
||||
ruleId,
|
||||
companyCode,
|
||||
formData
|
||||
);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// ==================== 회사별 채번규칙 복제 API ====================
|
||||
|
||||
// 회사별 채번규칙 복제
|
||||
router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
router.post(
|
||||
"/copy-for-company",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
|
||||
// 최고 관리자만 사용 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: "최고 관리자만 사용할 수 있습니다"
|
||||
});
|
||||
}
|
||||
// 최고 관리자만 사용 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: "최고 관리자만 사용할 수 있습니다",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다"
|
||||
});
|
||||
}
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("회사별 채번규칙 복제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
try {
|
||||
const result = await numberingRuleService.copyRulesForCompany(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("회사별 채번규칙 복제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -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 || "스케줄 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 시)
|
||||
// 삭제되는 그룹이 최상위인지 확인
|
||||
const isRootGroup = await client.query(
|
||||
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
|
||||
[id]
|
||||
);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. screen_groups 삭제
|
||||
let query = `DELETE FROM screen_groups WHERE id = $1`;
|
||||
const params: any[] = [id];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
query += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
ttc.table_name,
|
||||
ttc.column_name,
|
||||
ttc.column_label,
|
||||
ttc.reference_table,
|
||||
ttc.reference_column,
|
||||
ttc.display_column,
|
||||
ttc.company_code,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ttc.table_name, ttc.column_name
|
||||
ORDER BY CASE WHEN ttc.company_code = $2 THEN 1 ELSE 2 END
|
||||
) as rn
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.reference_table = $1
|
||||
AND ttc.input_type = 'entity'
|
||||
AND ttc.company_code IN ($2, '*')
|
||||
)
|
||||
SELECT DISTINCT
|
||||
ttc.table_name,
|
||||
ttc.column_name,
|
||||
ttc.column_label,
|
||||
ttc.reference_table,
|
||||
ttc.reference_column,
|
||||
ttc.display_column,
|
||||
ttc.table_name as table_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.reference_table = $1
|
||||
AND ttc.input_type = 'entity'
|
||||
AND ttc.company_code = '*'
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
table_name,
|
||||
column_name,
|
||||
column_label,
|
||||
reference_table,
|
||||
reference_column,
|
||||
display_column,
|
||||
table_name as table_label
|
||||
FROM ranked
|
||||
WHERE rn = 1
|
||||
ORDER BY table_name, column_name
|
||||
`;
|
||||
|
||||
const result = await query(sqlQuery, [tableName]);
|
||||
const result = await query(sqlQuery, [tableName, userCompanyCode]);
|
||||
|
||||
const referencedByTables = result.map((row: any) => ({
|
||||
tableName: row.table_name,
|
||||
|
|
@ -2379,7 +2422,7 @@ export async function getReferencedByTables(
|
|||
}));
|
||||
|
||||
logger.info(
|
||||
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
|
||||
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})`
|
||||
);
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 = '*'
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
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();
|
||||
components.forEach((comp: any, idx: number) => {
|
||||
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(comp.id, newComponentId);
|
||||
});
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
|
||||
layoutData,
|
||||
componentIdMap,
|
||||
screenIdMap,
|
||||
flowIdMap,
|
||||
numberingRuleIdMap,
|
||||
menuIdMap
|
||||
);
|
||||
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
|
||||
}
|
||||
|
||||
// component_id 매핑 생성 (원본 → 새 ID)
|
||||
const componentIdMap = new Map<string, string>();
|
||||
const timestamp = Date.now();
|
||||
layoutsResult.rows.forEach((layout, idx) => {
|
||||
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(layout.component_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,
|
||||
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}개`);
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
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,222 +2564,47 @@ 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 };
|
||||
}
|
||||
|
||||
// === 최적화: 배치 조회 ===
|
||||
// 1. 모든 원본 채번 규칙 한 번에 조회
|
||||
const allRulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`,
|
||||
[menuObjids]
|
||||
// 새 스키마에서는 채번규칙이 메뉴와 직접 연결되지 않음
|
||||
// 프론트엔드에서 /numbering-rules/copy-for-company API를 별도 호출
|
||||
// 여기서는 기존 규칙 ID를 그대로 매핑 (화면 레이아웃의 numberingRuleId 참조용)
|
||||
|
||||
// 원본 회사의 채번규칙 조회 (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;
|
||||
});
|
||||
|
||||
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}개 건너뜀)`);
|
||||
}
|
||||
|
||||
// 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 };
|
||||
logger.info(` 📋 채번규칙 매핑 완료: ${ruleIdMap.size}개`);
|
||||
|
||||
// 실제 복제는 프론트엔드에서 별도 API 호출로 처리됨
|
||||
return { copiedCount: 0, ruleIdMap };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 카테고리 매핑 + 값 복사 (최적화: 배치 조회)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => ({
|
||||
id: comp.id,
|
||||
type: comp.overrides?.type || "component",
|
||||
position: comp.position || { x: 0, y: 0, z: 1 },
|
||||
size: comp.size || { width: 200, height: 100 },
|
||||
componentUrl: comp.url,
|
||||
componentType: comp.overrides?.type,
|
||||
componentConfig: comp.overrides || {},
|
||||
displayOrder: comp.displayOrder || 0,
|
||||
...comp.overrides,
|
||||
}));
|
||||
const components = (layoutData.components || []).map((comp: any) => {
|
||||
const componentType = getTypeFromUrl(comp.url);
|
||||
return {
|
||||
id: comp.id,
|
||||
type: componentType,
|
||||
position: comp.position || { x: 0, y: 0, z: 1 },
|
||||
size: comp.size || { width: 200, height: 100 },
|
||||
componentUrl: comp.url,
|
||||
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) {
|
||||
// 파싱 실패 시 그대로 사용
|
||||
}
|
||||
}
|
||||
|
||||
// flowId 매핑 적용 (회사가 다른 경우)
|
||||
if (flowIdMap.size > 0) {
|
||||
properties = this.updateFlowIdsInProperties(
|
||||
properties,
|
||||
flowIdMap,
|
||||
);
|
||||
}
|
||||
|
||||
// 채번 규칙 ID 매핑 적용 (회사가 다른 경우)
|
||||
if (ruleIdMap.size > 0) {
|
||||
properties = this.updateNumberingRuleIdsInProperties(
|
||||
properties,
|
||||
ruleIdMap,
|
||||
);
|
||||
}
|
||||
|
||||
// 탭 컴포넌트의 screenId는 개별 복제 시점에 업데이트하지 않음
|
||||
// 모든 화면 복제 완료 후 updateTabScreenReferences에서 screenIdMap 기반으로 일괄 업데이트
|
||||
|
||||
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(),
|
||||
],
|
||||
);
|
||||
// componentId 매핑 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const comp of components) {
|
||||
componentIdMap.set(comp.id, generateId());
|
||||
}
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutData(
|
||||
layoutData,
|
||||
{
|
||||
componentIdMap,
|
||||
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
||||
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
||||
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
|
||||
},
|
||||
);
|
||||
|
||||
// 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()`,
|
||||
[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}`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -289,29 +289,48 @@ export class TableManagementService {
|
|||
companyCode,
|
||||
});
|
||||
|
||||
const mappings = await query<any>(
|
||||
`SELECT
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code = $2`,
|
||||
[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'`
|
||||
);
|
||||
|
||||
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
mappings: mappings,
|
||||
});
|
||||
if (columnCheck.length > 0) {
|
||||
// menu_objid 컬럼이 있는 경우
|
||||
const mappings = await query<any>(
|
||||
`SELECT
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
});
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} else {
|
||||
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
|
||||
logger.info(
|
||||
"⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"
|
||||
);
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} 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,11 +2276,17 @@ 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}`;
|
||||
const countResult = await query<any>(countQuery, searchValues);
|
||||
|
|
@ -3090,9 +3194,13 @@ 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`
|
||||
: "";
|
||||
|
||||
// 페이징 계산
|
||||
const offset = (options.page - 1) * options.size;
|
||||
|
|
@ -3302,14 +3410,17 @@ 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"}`
|
||||
: undefined,
|
||||
? `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,15 +3495,49 @@ export class TableManagementService {
|
|||
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
|
||||
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
|
||||
);
|
||||
|
||||
// 🔧 파이프로 구분된 다중 선택값 처리
|
||||
if (safeValue.includes("|")) {
|
||||
const multiValues = safeValue
|
||||
.split("|")
|
||||
.filter((v: string) => v.trim() !== "");
|
||||
if (multiValues.length > 0) {
|
||||
const inClause = multiValues
|
||||
.map((v: string) => `'${v}'`)
|
||||
.join(", ");
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn}::text IN (${inClause})`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 다중선택 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})`
|
||||
);
|
||||
}
|
||||
} else if (operator === "equals") {
|
||||
// 🔧 equals 연산자: 정확히 일치
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 정확히 일치 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
|
||||
);
|
||||
} else {
|
||||
// 기본: 부분 일치 (ILIKE)
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
|
||||
);
|
||||
}
|
||||
} else if (key === "writer_dept_code") {
|
||||
// writer_dept_code: user_info.dept_code에서 검색
|
||||
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
|
||||
|
|
@ -3427,18 +3574,44 @@ export class TableManagementService {
|
|||
}
|
||||
} else {
|
||||
// 일반 컬럼인 경우: 메인 테이블에서 검색
|
||||
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
|
||||
logger.info(
|
||||
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
|
||||
);
|
||||
// 🔧 파이프로 구분된 다중 선택값 처리
|
||||
if (safeValue.includes("|")) {
|
||||
const multiValues = safeValue
|
||||
.split("|")
|
||||
.filter((v: string) => v.trim() !== "");
|
||||
if (multiValues.length > 0) {
|
||||
const inClause = multiValues
|
||||
.map((v: string) => `'${v}'`)
|
||||
.join(", ");
|
||||
whereConditions.push(`main.${key}::text IN (${inClause})`);
|
||||
logger.info(
|
||||
`🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})`
|
||||
);
|
||||
}
|
||||
} else if (operator === "equals") {
|
||||
// 🔧 equals 연산자: 정확히 일치
|
||||
whereConditions.push(`main.${key}::text = '${safeValue}'`);
|
||||
logger.info(
|
||||
`🔍 정확히 일치 검색: ${key} → main.${key} = '${safeValue}'`
|
||||
);
|
||||
} else {
|
||||
// 기본: 부분 일치 (ILIKE)
|
||||
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
|
||||
logger.info(
|
||||
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
: "";
|
||||
|
||||
// 페이징 계산
|
||||
const offset = (options.page - 1) * options.size;
|
||||
|
|
@ -3715,6 +3888,7 @@ export class TableManagementService {
|
|||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
}>
|
||||
> {
|
||||
return await entityJoinService.getReferenceTableColumns(tableName);
|
||||
|
|
@ -4163,31 +4337,46 @@ export class TableManagementService {
|
|||
if (mappingTableExists) {
|
||||
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
||||
|
||||
const mappings = await query<any>(
|
||||
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY logical_column_name, menu_objid,
|
||||
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
|
||||
[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'`
|
||||
);
|
||||
|
||||
logger.info("카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
mappings: mappings,
|
||||
});
|
||||
if (columnCheck.length > 0) {
|
||||
const mappings = await query<any>(
|
||||
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY logical_column_name, menu_objid,
|
||||
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
logger.info("카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
});
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} else {
|
||||
logger.info("⚠️ menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} catch (mappingError: any) {
|
||||
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
|
||||
error: mappingError.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("categoryMappings Map 생성 완료", {
|
||||
size: categoryMappings.size,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ services:
|
|||
- "9771:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||
- NODE_OPTIONS=--max-old-space-size=8192
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
volumes:
|
||||
- ../../frontend:/app
|
||||
- /app/node_modules
|
||||
|
|
|
|||
|
|
@ -0,0 +1,729 @@
|
|||
# Flow 기반 반응형 레이아웃 설계서
|
||||
|
||||
> 작성일: 2026-01-30
|
||||
> 목표: 진정한 반응형 구현 (PC/태블릿/모바일 전체 대응)
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 결론
|
||||
|
||||
### 1.1 현재 방식 vs 반응형 표준
|
||||
|
||||
| 항목 | 현재 시스템 | 웹 표준 (2025) |
|
||||
|------|-------------|----------------|
|
||||
| 배치 방식 | `position: absolute` | **Flexbox / CSS Grid** |
|
||||
| 좌표 | 픽셀 고정 (x, y) | **Flow 기반 (순서)** |
|
||||
| 화면 축소 시 | 그대로 (잘림) | **자동 재배치** |
|
||||
| 용도 | 툴팁, 오버레이 | **전체 레이아웃** |
|
||||
|
||||
> **결론**: `position: absolute`는 전체 레이아웃에 사용하면 안 됨 (웹 표준)
|
||||
|
||||
### 1.2 구현 방향
|
||||
|
||||
```
|
||||
절대 좌표 (x, y 픽셀)
|
||||
↓ 변환
|
||||
Flow 기반 배치 (Flexbox + Grid)
|
||||
↓ 결과
|
||||
화면 크기에 따라 자동 재배치
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 실제 화면 데이터 분석
|
||||
|
||||
### 2.1 분석 대상
|
||||
|
||||
```
|
||||
총 레이아웃: 1,250개
|
||||
총 컴포넌트: 5,236개
|
||||
분석 샘플: 6개 화면 (23, 20, 18, 16, 18, 5개 컴포넌트)
|
||||
```
|
||||
|
||||
### 2.2 화면 68 (수주 목록) - 가로 배치 패턴
|
||||
|
||||
```
|
||||
y=88: [분리] [저장] [수정] [삭제] ← 같은 행에 버튼 4개
|
||||
x=1277 x=1436 x=1594 x=1753
|
||||
|
||||
y=128: [────────── 테이블 ──────────]
|
||||
x=8, width=1904
|
||||
```
|
||||
|
||||
**변환 후**:
|
||||
```html
|
||||
<div class="flex flex-wrap justify-end gap-2"> <!-- Row 1 -->
|
||||
<button>분리</button>
|
||||
<button>저장</button>
|
||||
<button>수정</button>
|
||||
<button>삭제</button>
|
||||
</div>
|
||||
<div class="w-full"> <!-- Row 2 -->
|
||||
<Table />
|
||||
</div>
|
||||
```
|
||||
|
||||
**반응형 동작**:
|
||||
```
|
||||
1920px: [분리] [저장] [수정] [삭제] ← 가로 배치
|
||||
1280px: [분리] [저장] [수정] [삭제] ← 가로 배치 (공간 충분)
|
||||
768px: [분리] [저장] ← 줄바꿈 발생
|
||||
[수정] [삭제]
|
||||
375px: [분리] ← 세로 배치
|
||||
[저장]
|
||||
[수정]
|
||||
[삭제]
|
||||
```
|
||||
|
||||
### 2.3 화면 119 (장치 관리) - 2열 폼 패턴
|
||||
|
||||
```
|
||||
y=80: [장치 코드 ] [시리얼넘버 ]
|
||||
x=136, w=256 x=408, w=256
|
||||
|
||||
y=160: [제조사 ]
|
||||
x=136, w=528
|
||||
|
||||
y=240: [품번 ] [모델명 ]
|
||||
x=136, w=256 x=408, w=256
|
||||
|
||||
y=320: [구매일 ] [상태 ]
|
||||
y=400: [공급사 ] [구매 가격 ]
|
||||
y=480: [계약 번호 ] [공급사 전화 ]
|
||||
... (2열 반복)
|
||||
|
||||
y=840: [저장]
|
||||
x=544
|
||||
```
|
||||
|
||||
**변환 후**:
|
||||
```html
|
||||
<div class="grid grid-cols-2 gap-4"> <!-- 2열 그리드 -->
|
||||
<Input label="장치 코드" />
|
||||
<Input label="시리얼넘버" />
|
||||
</div>
|
||||
<div class="w-full"> <!-- 전체 너비 -->
|
||||
<Input label="제조사" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input label="품번" />
|
||||
<Select label="모델명" />
|
||||
</div>
|
||||
<!-- ... 반복 ... -->
|
||||
<div class="flex justify-center">
|
||||
<Button>저장</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**반응형 동작**:
|
||||
```
|
||||
1920px: [장치 코드] [시리얼넘버] ← 2열
|
||||
1280px: [장치 코드] [시리얼넘버] ← 2열
|
||||
768px: [장치 코드] ← 1열
|
||||
[시리얼넘버]
|
||||
375px: [장치 코드] ← 1열
|
||||
[시리얼넘버]
|
||||
```
|
||||
|
||||
### 2.4 화면 4103 (수주 등록) - 섹션 기반 패턴
|
||||
|
||||
```
|
||||
y=20: [섹션: 옵션 설정 ]
|
||||
y=35: [입력방식▼] [판매유형▼] [단가방식▼] [☑ 단가수정]
|
||||
|
||||
y=110: [섹션: 거래처 정보 ]
|
||||
y=190: [거래처 * ] [담당자 ] [납품처 ] [납품장소 ]
|
||||
|
||||
y=260: [섹션: 추가된 품목 ]
|
||||
y=360: [리피터 테이블 ]
|
||||
|
||||
y=570: [섹션: 무역 정보 ]
|
||||
y=690: [인코텀즈▼] [결제조건▼] [통화▼ ]
|
||||
y=740: [선적항 ] [도착항 ] [HS Code ]
|
||||
|
||||
y=890: [섹션: 추가 정보 ]
|
||||
y=935: [메모 ]
|
||||
|
||||
y=1080: [저장]
|
||||
```
|
||||
|
||||
**변환 후**:
|
||||
```html
|
||||
<Card title="옵션 설정">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<Select label="입력방식" />
|
||||
<Select label="판매유형" />
|
||||
<Select label="단가방식" />
|
||||
<Checkbox label="단가수정 허용" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="거래처 정보">
|
||||
<div class="grid grid-cols-4 gap-4"> <!-- 4열 그리드 -->
|
||||
<Select label="거래처 *" />
|
||||
<Input label="담당자" />
|
||||
<Input label="납품처" />
|
||||
<Input label="납품장소" class="col-span-2" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- ... 섹션 반복 ... -->
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button>저장</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**반응형 동작**:
|
||||
```
|
||||
1920px: [입력방식] [판매유형] [단가방식] [단가수정] ← 4열
|
||||
1280px: [입력방식] [판매유형] [단가방식] ← 3열
|
||||
[단가수정]
|
||||
768px: [입력방식] [판매유형] ← 2열
|
||||
[단가방식] [단가수정]
|
||||
375px: [입력방식] ← 1열
|
||||
[판매유형]
|
||||
[단가방식]
|
||||
[단가수정]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 변환 규칙
|
||||
|
||||
### 3.1 Row 그룹화 알고리즘
|
||||
|
||||
```typescript
|
||||
const ROW_THRESHOLD = 40; // px
|
||||
|
||||
function groupByRows(components: Component[]): Row[] {
|
||||
// 1. y 좌표로 정렬
|
||||
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
|
||||
|
||||
const rows: Row[] = [];
|
||||
let currentRow: Component[] = [];
|
||||
let currentY = -Infinity;
|
||||
|
||||
for (const comp of sorted) {
|
||||
if (comp.position.y - currentY > ROW_THRESHOLD) {
|
||||
// 새로운 Row 시작
|
||||
if (currentRow.length > 0) {
|
||||
rows.push({
|
||||
y: currentY,
|
||||
components: currentRow.sort((a, b) => a.position.x - b.position.x)
|
||||
});
|
||||
}
|
||||
currentRow = [comp];
|
||||
currentY = comp.position.y;
|
||||
} else {
|
||||
// 같은 Row에 추가
|
||||
currentRow.push(comp);
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 Row 추가
|
||||
if (currentRow.length > 0) {
|
||||
rows.push({
|
||||
y: currentY,
|
||||
components: currentRow.sort((a, b) => a.position.x - b.position.x)
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 화면 68 적용 예시
|
||||
|
||||
**입력**:
|
||||
```json
|
||||
[
|
||||
{ "id": "comp_1899", "position": { "x": 1277, "y": 88 }, "text": "분리" },
|
||||
{ "id": "comp_1898", "position": { "x": 1436, "y": 88 }, "text": "저장" },
|
||||
{ "id": "comp_1897", "position": { "x": 1594, "y": 88 }, "text": "수정" },
|
||||
{ "id": "comp_1896", "position": { "x": 1753, "y": 88 }, "text": "삭제" },
|
||||
{ "id": "comp_1895", "position": { "x": 8, "y": 128 }, "type": "table" }
|
||||
]
|
||||
```
|
||||
|
||||
**변환 결과**:
|
||||
```json
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"y": 88,
|
||||
"justify": "end",
|
||||
"components": ["comp_1899", "comp_1898", "comp_1897", "comp_1896"]
|
||||
},
|
||||
{
|
||||
"y": 128,
|
||||
"justify": "start",
|
||||
"components": ["comp_1895"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 정렬 방향 결정
|
||||
|
||||
```typescript
|
||||
function determineJustify(row: Row, screenWidth: number): string {
|
||||
const firstX = row.components[0].position.x;
|
||||
const lastComp = row.components[row.components.length - 1];
|
||||
const lastEnd = lastComp.position.x + lastComp.size.width;
|
||||
|
||||
// 왼쪽 여백 vs 오른쪽 여백 비교
|
||||
const leftMargin = firstX;
|
||||
const rightMargin = screenWidth - lastEnd;
|
||||
|
||||
if (leftMargin > rightMargin * 2) {
|
||||
return "end"; // 오른쪽 정렬
|
||||
} else if (rightMargin > leftMargin * 2) {
|
||||
return "start"; // 왼쪽 정렬
|
||||
} else {
|
||||
return "center"; // 중앙 정렬
|
||||
}
|
||||
}
|
||||
|
||||
// 화면 68 버튼 그룹:
|
||||
// leftMargin = 1277, rightMargin = 1920 - 1912 = 8
|
||||
// → "end" (오른쪽 정렬)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 렌더링 구현
|
||||
|
||||
### 4.1 새로운 FlowLayout 컴포넌트
|
||||
|
||||
```tsx
|
||||
// frontend/lib/registry/layouts/flow/FlowLayout.tsx
|
||||
|
||||
interface FlowLayoutProps {
|
||||
layout: LayoutData;
|
||||
renderer: DynamicComponentRenderer;
|
||||
}
|
||||
|
||||
export function FlowLayout({ layout, renderer }: FlowLayoutProps) {
|
||||
// 1. Row 그룹화
|
||||
const rows = useMemo(() => {
|
||||
return groupByRows(layout.components);
|
||||
}, [layout.components]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{rows.map((row, index) => (
|
||||
<FlowRow
|
||||
key={index}
|
||||
row={row}
|
||||
renderer={renderer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowRow({ row, renderer }: { row: Row; renderer: any }) {
|
||||
const justify = determineJustify(row, 1920);
|
||||
|
||||
const justifyClass = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
}[justify];
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 ${justifyClass}`}>
|
||||
{row.components.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
style={{
|
||||
minWidth: comp.size.width,
|
||||
// width는 고정하지 않음 (flex로 자동 조정)
|
||||
}}
|
||||
>
|
||||
{renderer.renderChild(comp)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 기존 코드 수정 위치
|
||||
|
||||
**현재 (RealtimePreviewDynamic.tsx 라인 524-536)**:
|
||||
```tsx
|
||||
const baseStyle = {
|
||||
left: `${adjustedPositionX}px`, // ❌ 절대 좌표
|
||||
top: `${position.y}px`, // ❌ 절대 좌표
|
||||
position: "absolute", // ❌ 절대 위치
|
||||
};
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
```tsx
|
||||
// FlowLayout 사용 시 position 관련 스타일 제거
|
||||
const baseStyle = isFlowMode ? {
|
||||
// position, left, top 없음
|
||||
minWidth: size.width,
|
||||
height: size.height,
|
||||
} : {
|
||||
left: `${adjustedPositionX}px`,
|
||||
top: `${position.y}px`,
|
||||
position: "absolute",
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 가상 시뮬레이션
|
||||
|
||||
### 5.1 시나리오 1: 화면 68 (버튼 4개 + 테이블)
|
||||
|
||||
**렌더링 결과 (1920px)**:
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ [분리] [저장] [수정] [삭제] │
|
||||
│ flex-wrap, justify-end │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 테이블 (w-full) │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
✅ 정상: 버튼 오른쪽 정렬, 테이블 전체 너비
|
||||
```
|
||||
|
||||
**렌더링 결과 (1280px)**:
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [분리] [저장] [수정] [삭제] │
|
||||
│ flex-wrap, justify-end │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ 테이블 (w-full) │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
✅ 정상: 버튼 크기 유지, 테이블 너비 조정
|
||||
```
|
||||
|
||||
**렌더링 결과 (768px)**:
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ [분리] [저장] │
|
||||
│ [수정] [삭제] │ ← 자동 줄바꿈!
|
||||
├──────────────────────────┤
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ 테이블 (w-full) │ │
|
||||
│ └──────────────────────┘ │
|
||||
└──────────────────────────┘
|
||||
✅ 정상: 버튼 줄바꿈, 테이블 너비 조정
|
||||
```
|
||||
|
||||
**렌더링 결과 (375px)**:
|
||||
```
|
||||
┌─────────────┐
|
||||
│ [분리] │
|
||||
│ [저장] │
|
||||
│ [수정] │
|
||||
│ [삭제] │ ← 세로 배치
|
||||
├─────────────┤
|
||||
│ ┌─────────┐ │
|
||||
│ │ 테이블 │ │ (가로 스크롤)
|
||||
│ └─────────┘ │
|
||||
└─────────────┘
|
||||
✅ 정상: 버튼 세로 배치, 테이블 가로 스크롤
|
||||
```
|
||||
|
||||
### 5.2 시나리오 2: 화면 119 (2열 폼)
|
||||
|
||||
**렌더링 결과 (1920px)**:
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ [장치 코드 ] [시리얼넘버 ] │
|
||||
│ grid-cols-2 │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ [제조사 ] │
|
||||
│ col-span-2 (전체 너비) │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ [품번 ] [모델명▼ ] │
|
||||
│ ... │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
✅ 정상: 2열 그리드
|
||||
```
|
||||
|
||||
**렌더링 결과 (768px)**:
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ [장치 코드 ] │
|
||||
│ [시리얼넘버 ] │ ← 1열로 변경
|
||||
├──────────────────────────┤
|
||||
│ [제조사 ] │
|
||||
├──────────────────────────┤
|
||||
│ [품번 ] │
|
||||
│ [모델명▼ ] │
|
||||
│ ... │
|
||||
└──────────────────────────┘
|
||||
✅ 정상: 1열 그리드
|
||||
```
|
||||
|
||||
### 5.3 시나리오 3: 분할 패널
|
||||
|
||||
**현재 SplitPanelLayout 동작**:
|
||||
```
|
||||
좌측 60% | 우측 40% ← 이미 퍼센트 기반
|
||||
```
|
||||
|
||||
**변경 후 (768px 이하)**:
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ 좌측 100% │
|
||||
├────────────────────┤
|
||||
│ 우측 100% │
|
||||
└────────────────────┘
|
||||
← 세로 배치로 전환
|
||||
```
|
||||
|
||||
**구현**:
|
||||
```tsx
|
||||
// SplitPanelLayoutComponent.tsx
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
|
||||
return (
|
||||
<div className={isMobile ? "flex-col" : "flex-row"}>
|
||||
<div className={isMobile ? "w-full" : "w-[60%]"}>
|
||||
{/* 좌측 패널 */}
|
||||
</div>
|
||||
<div className={isMobile ? "w-full" : "w-[40%]"}>
|
||||
{/* 우측 패널 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 엣지 케이스 검증
|
||||
|
||||
### 6.1 겹치는 컴포넌트
|
||||
|
||||
**현재 데이터 (화면 74)**:
|
||||
```json
|
||||
{ "id": "comp_2606", "position": { "x": 161, "y": 400 } }, // 분할 패널
|
||||
{ "id": "comp_fkk75q08", "position": { "x": 161, "y": 400 } } // 라디오 버튼
|
||||
```
|
||||
|
||||
**문제**: 같은 위치에 두 컴포넌트 → z-index로 겹쳐서 표시
|
||||
|
||||
**해결**:
|
||||
- z-index가 높은 컴포넌트 우선
|
||||
- 또는 parent-child 관계면 중첩 처리
|
||||
|
||||
```typescript
|
||||
function resolveOverlaps(row: Row): Row {
|
||||
// z-index로 정렬하여 높은 것만 표시
|
||||
// 또는 parentId 확인하여 중첩 처리
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 조건부 표시 컴포넌트
|
||||
|
||||
**현재 데이터 (화면 4103)**:
|
||||
```json
|
||||
{
|
||||
"id": "section-customer-info",
|
||||
"conditionalConfig": {
|
||||
"field": "input_method",
|
||||
"value": "customer_first",
|
||||
"action": "show"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**동작**: 조건에 따라 show/hide
|
||||
**Flow 레이아웃에서**: 숨겨지면 공간도 사라짐 (flex 자동 조정)
|
||||
|
||||
✅ 문제없음
|
||||
|
||||
### 6.3 테이블 + 버튼 조합
|
||||
|
||||
**패턴**:
|
||||
```
|
||||
[버튼 그룹] ← flex-wrap, justify-end
|
||||
[테이블] ← w-full
|
||||
```
|
||||
|
||||
**테이블 가로 스크롤**:
|
||||
- 테이블 내부는 가로 스크롤 지원
|
||||
- 외부 컨테이너는 w-full
|
||||
|
||||
✅ 문제없음
|
||||
|
||||
### 6.4 섹션 카드 내부 컴포넌트
|
||||
|
||||
**현재**: 섹션 카드와 내부 컴포넌트가 별도로 저장됨
|
||||
|
||||
**변환 시**:
|
||||
1. 섹션 카드의 y 범위 파악
|
||||
2. 해당 y 범위 내 컴포넌트들을 섹션 자식으로 그룹화
|
||||
3. 섹션 내부에서 다시 Row 그룹화
|
||||
|
||||
```typescript
|
||||
function groupWithinSection(
|
||||
section: Component,
|
||||
allComponents: Component[]
|
||||
): Component[] {
|
||||
const sectionTop = section.position.y;
|
||||
const sectionBottom = section.position.y + section.size.height;
|
||||
|
||||
return allComponents.filter(comp => {
|
||||
return comp.id !== section.id &&
|
||||
comp.position.y >= sectionTop &&
|
||||
comp.position.y < sectionBottom;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 호환성 검증
|
||||
|
||||
### 7.1 기존 기능 호환
|
||||
|
||||
| 기능 | 호환 여부 | 설명 |
|
||||
|------|----------|------|
|
||||
| 디자인 모드 | ⚠️ 수정 필요 | 드래그 앤 드롭 로직 수정 |
|
||||
| 미리보기 | ✅ 호환 | Flow 레이아웃으로 렌더링 |
|
||||
| 조건부 표시 | ✅ 호환 | flex로 자동 조정 |
|
||||
| 분할 패널 | ⚠️ 수정 필요 | 반응형 전환 로직 추가 |
|
||||
| 테이블 | ✅ 호환 | w-full 적용 |
|
||||
| 모달 | ✅ 호환 | 모달 내부도 Flow 적용 |
|
||||
|
||||
### 7.2 디자인 모드 수정
|
||||
|
||||
**현재**: 드래그하면 x, y 픽셀 저장
|
||||
**변경 후**: 드래그하면 x, y 픽셀 저장 (동일) → 렌더링 시 변환
|
||||
|
||||
```
|
||||
저장: 픽셀 좌표 (기존 유지)
|
||||
렌더링: Flow 기반으로 변환
|
||||
```
|
||||
|
||||
**장점**: DB 마이그레이션 불필요
|
||||
|
||||
---
|
||||
|
||||
## 8. 구현 계획
|
||||
|
||||
### Phase 1: 핵심 변환 로직 (1일)
|
||||
|
||||
1. `groupByRows()` 함수 구현
|
||||
2. `determineJustify()` 함수 구현
|
||||
3. `FlowLayout` 컴포넌트 생성
|
||||
|
||||
### Phase 2: 렌더링 적용 (1일)
|
||||
|
||||
1. `DynamicComponentRenderer`에 Flow 모드 추가
|
||||
2. `RealtimePreviewDynamic` 수정
|
||||
3. 기존 absolute 스타일 조건부 적용
|
||||
|
||||
### Phase 3: 특수 케이스 처리 (1일)
|
||||
|
||||
1. 섹션 카드 내부 그룹화
|
||||
2. 겹치는 컴포넌트 처리
|
||||
3. 분할 패널 반응형 전환
|
||||
|
||||
### Phase 4: 테스트 (1일)
|
||||
|
||||
1. 화면 68 (버튼 + 테이블) 테스트
|
||||
2. 화면 119 (2열 폼) 테스트
|
||||
3. 화면 4103 (복잡한 폼) 테스트
|
||||
4. PC 1920px → 1280px 테스트
|
||||
5. 태블릿 768px 테스트
|
||||
6. 모바일 375px 테스트
|
||||
|
||||
---
|
||||
|
||||
## 9. 예상 이슈
|
||||
|
||||
### 9.1 디자이너 의도 손실
|
||||
|
||||
**문제**: 디자이너가 의도적으로 배치한 위치가 변경될 수 있음
|
||||
|
||||
**해결**:
|
||||
- 기본 Flow 레이아웃 적용
|
||||
- 필요시 `flexOrder` 속성으로 순서 조정 가능
|
||||
- 또는 `fixedPosition: true` 옵션으로 절대 좌표 유지
|
||||
|
||||
### 9.2 복잡한 레이아웃
|
||||
|
||||
**문제**: 일부 화면은 자유 배치가 필요할 수 있음
|
||||
|
||||
**해결**:
|
||||
- 화면별 `layoutMode` 설정
|
||||
- `"flow"`: Flow 기반 (기본값)
|
||||
- `"absolute"`: 기존 절대 좌표
|
||||
|
||||
### 9.3 성능
|
||||
|
||||
**문제**: 매 렌더링마다 Row 그룹화 계산
|
||||
|
||||
**해결**:
|
||||
- `useMemo`로 캐싱
|
||||
- 컴포넌트 목록 변경 시에만 재계산
|
||||
|
||||
---
|
||||
|
||||
## 10. 최종 체크리스트
|
||||
|
||||
### 구현 전
|
||||
|
||||
- [ ] 현재 동작하는 화면 스크린샷 (비교용)
|
||||
- [ ] 테스트 화면 목록 확정 (68, 119, 4103)
|
||||
|
||||
### 구현 중
|
||||
|
||||
- [ ] `groupByRows()` 구현
|
||||
- [ ] `determineJustify()` 구현
|
||||
- [ ] `FlowLayout` 컴포넌트 생성
|
||||
- [ ] `DynamicComponentRenderer` 수정
|
||||
- [ ] `RealtimePreviewDynamic` 수정
|
||||
|
||||
### 테스트
|
||||
|
||||
- [ ] 1920px 테스트
|
||||
- [ ] 1280px 테스트
|
||||
- [ ] 768px 테스트
|
||||
- [ ] 375px 테스트
|
||||
- [ ] 디자인 모드 테스트
|
||||
- [ ] 분할 패널 테스트
|
||||
- [ ] 조건부 표시 테스트
|
||||
|
||||
---
|
||||
|
||||
## 11. 결론
|
||||
|
||||
### 11.1 구현 가능 여부
|
||||
|
||||
**✅ 가능**
|
||||
|
||||
- 기존 데이터 구조 유지 (DB 변경 없음)
|
||||
- 렌더링 레벨에서만 변환
|
||||
- 모든 화면 패턴 분석 완료
|
||||
- 엣지 케이스 해결책 확보
|
||||
|
||||
### 11.2 핵심 변경 사항
|
||||
|
||||
```
|
||||
Before: position: absolute + left/top 픽셀
|
||||
After: Flexbox + flex-wrap + justify-*
|
||||
```
|
||||
|
||||
### 11.3 예상 효과
|
||||
|
||||
| 화면 크기 | Before | After |
|
||||
|-----------|--------|-------|
|
||||
| 1920px | 정상 | 정상 |
|
||||
| 1280px | 버튼 잘림 | **자동 조정** |
|
||||
| 768px | 레이아웃 깨짐 | **자동 재배치** |
|
||||
| 375px | 사용 불가 | **자동 세로 배치** |
|
||||
|
|
@ -0,0 +1,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)
|
||||
|
|
@ -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 반응형을 구현할 수 있습니다.
|
||||
|
|
@ -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/) - 컴포넌트 라이브러리
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
# V2 마이그레이션 학습노트 (DDD1542 전용)
|
||||
|
||||
> **목적**: 마이그레이션 작업 전 완벽한 이해를 위한 개인 학습노트
|
||||
> **작성일**: 2026-02-03
|
||||
> **절대 규칙**: 모르면 물어보기, 추측 금지
|
||||
|
||||
---
|
||||
|
||||
## 1. 가장 중요한 핵심 (이전 신하가 실패한 이유)
|
||||
|
||||
### 1.1 "component" vs "v2-input" 차이
|
||||
|
||||
```
|
||||
[잘못된 상태] [올바른 상태]
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ component │ │ v2-input │
|
||||
│ 업체코드 │ │ 업체코드 │
|
||||
│ "자동 생성됩니다" │ │ "자동 생성됩니다" │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
↑ ↑
|
||||
테이블-컬럼 연결 없음 table_name + column_name 연결됨
|
||||
```
|
||||
|
||||
**핵심**: 컬럼을 왼쪽 패널에서 **드래그**해야 올바른 연결이 생성됨
|
||||
|
||||
### 1.2 올바른 컴포넌트 생성 방법
|
||||
|
||||
```
|
||||
[왼쪽 패널: 테이블 컬럼 목록]
|
||||
운송업체 (8개)
|
||||
├── 업체코드 [numbering] ─드래그→ 화면 캔버스 → v2-numbering-rule (또는 v2-input)
|
||||
├── 업체명 [text] ─드래그→ 화면 캔버스 → v2-input
|
||||
├── 유형 [category] ─드래그→ 화면 캔버스 → v2-select
|
||||
├── 연락처 [text] ─드래그→ 화면 캔버스 → v2-input
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 1.3 input_type → V2 컴포넌트 매핑
|
||||
|
||||
| table_type_columns.input_type | V2 컴포넌트 | 연동 테이블 |
|
||||
|-------------------------------|-------------|-------------|
|
||||
| text | v2-input | - |
|
||||
| number | v2-input (type=number) | - |
|
||||
| date | v2-date | - |
|
||||
| category | v2-select | category_values |
|
||||
| numbering | v2-numbering-rule 또는 v2-input | numbering_rules |
|
||||
| entity | v2-entity-search | 엔티티 조인 |
|
||||
|
||||
---
|
||||
|
||||
## 2. V1 vs V2 구조 차이
|
||||
|
||||
### 2.1 테이블 구조
|
||||
|
||||
```
|
||||
V1 (본서버: screen_layouts) V2 (개발서버: screen_layouts_v2)
|
||||
──────────────────────────────────────────────────────────────────
|
||||
- 컴포넌트별 1개 레코드 - 화면당 1개 레코드
|
||||
- properties JSONB - layout_data JSONB
|
||||
- component_type VARCHAR - url (컴포넌트 경로)
|
||||
- menu_objid 기반 채번/카테고리 - table_name + column_name 기반
|
||||
```
|
||||
|
||||
### 2.2 V2 layout_data 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp_xxx",
|
||||
"url": "@/lib/registry/components/v2-table-list",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 100, "height": 50 },
|
||||
"displayOrder": 0,
|
||||
"overrides": {
|
||||
"tableName": "inspection_standard",
|
||||
"columns": ["id", "name", "status"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-02-03T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 컴포넌트 URL 매핑
|
||||
|
||||
```typescript
|
||||
const V1_TO_V2_URL_MAPPING = {
|
||||
'table-list': '@/lib/registry/components/v2-table-list',
|
||||
'button-primary': '@/lib/registry/components/v2-button-primary',
|
||||
'text-input': '@/lib/registry/components/v2-input',
|
||||
'select-basic': '@/lib/registry/components/v2-select',
|
||||
'date-input': '@/lib/registry/components/v2-date',
|
||||
'entity-search-input': '@/lib/registry/components/v2-entity-search',
|
||||
'category-manager': '@/lib/registry/components/v2-category-manager',
|
||||
'numbering-rule': '@/lib/registry/components/v2-numbering-rule',
|
||||
'tabs-widget': '@/lib/registry/components/v2-tabs-widget',
|
||||
'split-panel-layout': '@/lib/registry/components/v2-split-panel-layout',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 타입 관리 (V2)
|
||||
|
||||
### 3.1 핵심 테이블 관계
|
||||
|
||||
```
|
||||
table_type_columns (컬럼 타입 정의)
|
||||
├── input_type = 'category' → category_values (table_name + column_name)
|
||||
├── input_type = 'numbering' → numbering_rules (detail_settings.numberingRuleId)
|
||||
├── input_type = 'entity' → 엔티티 조인
|
||||
└── input_type = 'text', 'number', 'date', etc.
|
||||
```
|
||||
|
||||
### 3.2 category_values 조회 쿼리
|
||||
|
||||
```sql
|
||||
-- 특정 테이블.컬럼의 카테고리 값 조회
|
||||
SELECT value_id, value_code, value_label, parent_value_id, depth
|
||||
FROM category_values
|
||||
WHERE table_name = '테이블명'
|
||||
AND column_name = '컬럼명'
|
||||
AND company_code = 'COMPANY_7'
|
||||
ORDER BY value_order;
|
||||
```
|
||||
|
||||
### 3.3 numbering_rules 연결 방식
|
||||
|
||||
```json
|
||||
// table_type_columns.detail_settings
|
||||
{
|
||||
"numberingRuleId": "rule-xxx"
|
||||
}
|
||||
|
||||
// numbering_rules에서 해당 rule 조회
|
||||
SELECT * FROM numbering_rules WHERE rule_id = 'rule-xxx';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. V2 컴포넌트 목록 (23개)
|
||||
|
||||
### 4.1 입력 컴포넌트
|
||||
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| v2-input | 입력 | 텍스트, 숫자, 비밀번호, 이메일 |
|
||||
| v2-select | 선택 | 드롭다운, 라디오, 체크박스 |
|
||||
| v2-date | 날짜 | 날짜, 시간, 날짜범위 |
|
||||
|
||||
### 4.2 표시 컴포넌트
|
||||
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| v2-text-display | 텍스트 표시 | 라벨, 제목 |
|
||||
| v2-card-display | 카드 디스플레이 | 카드 형태 데이터 |
|
||||
| v2-aggregation-widget | 집계 위젯 | 합계, 평균, 개수 |
|
||||
|
||||
### 4.3 테이블/데이터 컴포넌트
|
||||
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| v2-table-list | 테이블 리스트 | 데이터 그리드 |
|
||||
| v2-table-search-widget | 검색 필터 | 테이블 검색 |
|
||||
| v2-pivot-grid | 피벗 그리드 | 다차원 분석 |
|
||||
| v2-table-grouped | 그룹화 테이블 | 그룹별 접기/펼치기 |
|
||||
|
||||
### 4.4 레이아웃 컴포넌트
|
||||
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| v2-split-panel-layout | 분할 패널 | 마스터-디테일 |
|
||||
| v2-tabs-widget | 탭 위젯 | 탭 전환 |
|
||||
| v2-section-card | 섹션 카드 | 제목+테두리 그룹 |
|
||||
| v2-section-paper | 섹션 페이퍼 | 배경색 그룹 |
|
||||
| v2-divider-line | 구분선 | 영역 구분 |
|
||||
| v2-repeat-container | 리피터 컨테이너 | 데이터 반복 |
|
||||
| v2-unified-repeater | 통합 리피터 | 인라인/모달/버튼 |
|
||||
|
||||
### 4.5 액션/특수 컴포넌트
|
||||
|
||||
| ID | 이름 | 용도 |
|
||||
|----|------|------|
|
||||
| v2-button-primary | 기본 버튼 | 저장, 삭제 등 |
|
||||
| v2-numbering-rule | 채번 규칙 | 자동 코드 생성 |
|
||||
| v2-category-manager | 카테고리 관리자 | 카테고리 관리 |
|
||||
| v2-location-swap-selector | 위치 교환 | 위치 선택 |
|
||||
| v2-rack-structure | 랙 구조 | 창고 랙 시각화 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 화면 패턴 (5가지)
|
||||
|
||||
### 5.1 패턴 A: 기본 마스터 화면
|
||||
|
||||
```
|
||||
사용 조건: 단일 테이블 CRUD
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ v2-table-search-widget │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ v2-table-list │
|
||||
│ [신규] [삭제] v2-button-primary │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 패턴 B: 마스터-디테일 화면
|
||||
|
||||
```
|
||||
사용 조건: 마스터 선택 → 디테일 표시
|
||||
|
||||
┌──────────────────┬──────────────────────────────┐
|
||||
│ 마스터 리스트 │ 디테일 리스트 │
|
||||
│ v2-table-list │ v2-table-list │
|
||||
│ │ (relation: foreignKey) │
|
||||
└──────────────────┴──────────────────────────────┘
|
||||
v2-split-panel-layout
|
||||
```
|
||||
|
||||
**필수 설정:**
|
||||
```json
|
||||
{
|
||||
"leftPanel": { "tableName": "master_table" },
|
||||
"rightPanel": {
|
||||
"tableName": "detail_table",
|
||||
"relation": { "type": "detail", "foreignKey": "master_id" }
|
||||
},
|
||||
"splitRatio": 30
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 패턴 C: 마스터-디테일 + 탭
|
||||
|
||||
```
|
||||
┌──────────────────┬──────────────────────────────┐
|
||||
│ 마스터 리스트 │ v2-tabs-widget │
|
||||
│ v2-table-list │ ├─ 탭1: v2-table-list │
|
||||
│ │ ├─ 탭2: v2-table-list │
|
||||
│ │ └─ 탭3: 폼 컴포넌트들 │
|
||||
└──────────────────┴──────────────────────────────┘
|
||||
v2-split-panel-layout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 모달 처리 방식 변경
|
||||
|
||||
### 6.1 V1 (본서버)
|
||||
|
||||
```
|
||||
화면 A (screen_id: 142) - 검사장비관리
|
||||
└── 버튼 클릭 → 화면 B (screen_id: 143) - 검사장비 등록모달 (별도 screen_id)
|
||||
```
|
||||
|
||||
### 6.2 V2 (개발서버)
|
||||
|
||||
```
|
||||
화면 A (screen_id: 142) - 검사장비관리
|
||||
└── layout_data.components[] 내에 v2-dialog-form 또는 overlay 포함
|
||||
```
|
||||
|
||||
**핵심**: V2에서는 모달을 별도 화면이 아닌, 부모 화면의 컴포넌트로 통합
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 절차 (Step by Step)
|
||||
|
||||
### Step 1: 사전 분석
|
||||
|
||||
```sql
|
||||
-- 본서버 화면 목록 확인
|
||||
SELECT sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name,
|
||||
COUNT(sl.layout_id) as component_count
|
||||
FROM screen_definitions sd
|
||||
LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
WHERE sd.screen_code LIKE 'COMPANY_7_%'
|
||||
GROUP BY sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name;
|
||||
|
||||
-- 개발서버 V2 현황 확인
|
||||
SELECT sd.screen_id, sd.screen_code, sd.screen_name,
|
||||
sv2.layout_data IS NOT NULL as has_v2_layout
|
||||
FROM screen_definitions sd
|
||||
LEFT JOIN screen_layouts_v2 sv2 ON sd.screen_id = sv2.screen_id
|
||||
WHERE sd.company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
### Step 2: table_type_columns 확인
|
||||
|
||||
```sql
|
||||
-- 해당 테이블의 컬럼 타입 확인
|
||||
SELECT column_name, column_label, input_type, detail_settings
|
||||
FROM table_type_columns
|
||||
WHERE table_name = '대상테이블명'
|
||||
AND company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
### Step 3: V2 layout_data 생성
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"components": [
|
||||
{
|
||||
"id": "생성된ID",
|
||||
"url": "@/lib/registry/components/v2-컴포넌트타입",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 100, "height": 50 },
|
||||
"displayOrder": 0,
|
||||
"overrides": {
|
||||
"tableName": "테이블명",
|
||||
"fieldName": "컬럼명"
|
||||
}
|
||||
}
|
||||
],
|
||||
"migratedFrom": "V1",
|
||||
"migratedAt": "2026-02-03T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: screen_layouts_v2 INSERT
|
||||
|
||||
```sql
|
||||
INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data)
|
||||
VALUES ($1, $2, $3::jsonb)
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3::jsonb, updated_at = NOW();
|
||||
```
|
||||
|
||||
### Step 5: 검증
|
||||
|
||||
- [ ] 화면 렌더링 확인 (component가 아닌 v2-xxx로 표시되는지)
|
||||
- [ ] 컴포넌트별 테이블-컬럼 연결 확인
|
||||
- [ ] 카테고리 드롭다운 동작 확인
|
||||
- [ ] 채번 규칙 동작 확인
|
||||
- [ ] 저장/수정/삭제 테스트
|
||||
|
||||
---
|
||||
|
||||
## 8. 품질관리 메뉴 마이그레이션 현황
|
||||
|
||||
| 본서버 코드 | 화면명 | 테이블 | 상태 | 비고 |
|
||||
|-------------|--------|--------|------|------|
|
||||
| COMPANY_7_126 | 검사정보 관리 | inspection_standard | ✅ V2 존재 | 컴포넌트 검증 필요 |
|
||||
| COMPANY_7_127 | 품목옵션 설정 | - | ✅ V2 존재 | v2-category-manager |
|
||||
| COMPANY_7_138 | 카테고리 설정 | inspection_standard | ❌ 누락 | table_name 기반 |
|
||||
| COMPANY_7_139 | 코드 설정 | inspection_standard | ❌ 누락 | table_name 기반 |
|
||||
| COMPANY_7_142 | 검사장비 관리 | inspection_equipment_mng | ❌ 누락 | 모달 통합 |
|
||||
| COMPANY_7_143 | 검사장비 등록모달 | inspection_equipment_mng | ❌ 누락 | → 142 통합 |
|
||||
| COMPANY_7_144 | 불량기준 정보 | defect_standard_mng | ❌ 누락 | 모달 통합 |
|
||||
| COMPANY_7_145 | 불량기준 등록모달 | defect_standard_mng | ❌ 누락 | → 144 통합 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 코드 파일 경로
|
||||
|
||||
| 항목 | 경로 |
|
||||
|------|------|
|
||||
| V2 컴포넌트 폴더 | `frontend/lib/registry/components/v2-xxx/` |
|
||||
| 컴포넌트 등록 | `frontend/lib/registry/components/index.ts` |
|
||||
| 카테고리 서비스 | `backend-node/src/services/categoryTreeService.ts` |
|
||||
| 채번 서비스 | `backend-node/src/services/numberingRuleService.ts` |
|
||||
| 엔티티 조인 API | `frontend/lib/api/entityJoin.ts` |
|
||||
| 폼 호환성 훅 | `frontend/hooks/useFormCompatibility.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 10. 절대 하지 말 것
|
||||
|
||||
1. ❌ **테이블-컬럼 연결 없이 컴포넌트 배치** → "component"로 표시됨
|
||||
2. ❌ **menu_objid 기반 카테고리/채번 사용** → V2는 table_name + column_name 기반
|
||||
3. ❌ **모달을 별도 screen_id로 생성** → V2는 부모 화면에 통합
|
||||
4. ❌ **V1 컴포넌트 타입 사용** → 반드시 v2- 접두사 컴포넌트 사용
|
||||
5. ❌ **company_code 필터링 누락** → 멀티테넌시 필수
|
||||
|
||||
---
|
||||
|
||||
## 11. 모르면 확인할 곳
|
||||
|
||||
1. **컴포넌트 구조**: `docs/V2_컴포넌트_분석_가이드.md`
|
||||
2. **화면 개발 표준**: `docs/screen-implementation-guide/화면개발_표준_가이드.md`
|
||||
3. **마이그레이션 절차**: `docs/DDD1542/본서버_개발서버_마이그레이션_상세가이드.md`
|
||||
4. **탑실 디자인 명세**: `/Users/gbpark/Downloads/화면개발 8/`
|
||||
5. **실제 코드**: 위 경로의 소스 파일들
|
||||
|
||||
---
|
||||
|
||||
## 12. 왕의 훈계
|
||||
|
||||
> **"항상 애매한 거는 md파일 보거나 물어볼 것. 코드에는 전부 정답이 있음. 만약 모른다면 너 잘못. 실수해도 너 잘못."**
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 작성자 | 내용 |
|
||||
|------|--------|------|
|
||||
| 2026-02-03 | DDD1542 | 초안 작성 (문서 4개 정독 후) |
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
# 본서버 → 개발서버 마이그레이션 가이드 (공용)
|
||||
|
||||
> **이 문서는 다음 AI 에이전트가 마이그레이션 작업을 이어받을 때 참고하는 핵심 가이드입니다.**
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 마이그레이션 방향 (절대 잊지 말 것)
|
||||
|
||||
```
|
||||
본서버 (Production) → 개발서버 (Development)
|
||||
211.115.91.141:11134 39.117.244.52:11132
|
||||
screen_layouts (V1) screen_layouts_v2 (V2)
|
||||
```
|
||||
|
||||
**반대로 하면 안 됨!** 개발서버 완성 후 → 본서버로 배포 예정
|
||||
|
||||
### DB 접속 정보
|
||||
|
||||
```bash
|
||||
# 본서버 (Production)
|
||||
docker exec pms-backend-mac node -e '
|
||||
const { Pool } = require("pg");
|
||||
const pool = new Pool({
|
||||
connectionString: "postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm?sslmode=disable",
|
||||
ssl: false
|
||||
});
|
||||
// 쿼리 실행
|
||||
'
|
||||
|
||||
# 개발서버 (Development)
|
||||
docker exec pms-backend-mac node -e '
|
||||
const { Pool } = require("pg");
|
||||
const pool = new Pool({
|
||||
connectionString: "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm?sslmode=disable",
|
||||
ssl: false
|
||||
});
|
||||
// 쿼리 실행
|
||||
'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 절대 주의: 컴포넌트-컬럼 연결 (이전 실패 원인)
|
||||
|
||||
### "component" vs "v2-input" 구분
|
||||
|
||||
```
|
||||
❌ 잘못된 상태 ✅ 올바른 상태
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ component │ │ v2-input │
|
||||
│ 업체코드 │ │ 업체코드 │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
↑ ↑
|
||||
overrides.type 없음 overrides.type = "v2-input"
|
||||
```
|
||||
|
||||
**핵심 원인**: 컴포넌트를 그냥 배치하면 "component"로 표시됨. 반드시 왼쪽 패널에서 테이블 컬럼을 **드래그**해야 올바른 v2-xxx 컴포넌트가 생성됨.
|
||||
|
||||
### 🔥 핵심 발견: overrides.type 필수 (2026-02-04 발견)
|
||||
|
||||
**"component"로 표시되는 근본 원인:**
|
||||
|
||||
| 항목 | 드래그로 배치 | 마이그레이션 (잘못된) |
|
||||
|------|---------------|----------------------|
|
||||
| `overrides.type` | **"v2-input"** ✅ | **없음** ❌ |
|
||||
| `overrides.webType` | "text" 등 | 없음 |
|
||||
| `overrides.tableName` | "carrier_mng" 등 | 없음 |
|
||||
|
||||
**프론트엔드가 컴포넌트 타입을 인식하는 방법:**
|
||||
1. `overrides.type` 확인 → 있으면 해당 값 사용 (예: "v2-input")
|
||||
2. 없으면 → 기본값 "component"로 폴백
|
||||
|
||||
**결론**: 마이그레이션 시 `overrides.type` 필드를 반드시 설정해야 함!
|
||||
|
||||
### input_type → V2 컴포넌트 자동 매핑
|
||||
|
||||
| table_type_columns.input_type | 드래그 시 생성되는 V2 컴포넌트 |
|
||||
|-------------------------------|-------------------------------|
|
||||
| text | v2-input |
|
||||
| number | v2-input (type=number) |
|
||||
| date | v2-date |
|
||||
| category | v2-select (category_values 연동) |
|
||||
| numbering | v2-numbering-rule 또는 v2-input |
|
||||
| entity | v2-entity-search |
|
||||
|
||||
**절대 규칙**: 컴포넌트가 "component"로 표시되면 연결 실패 상태. 반드시 "v2-xxx"로 표시되어야 함.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 개념
|
||||
|
||||
### V1 vs V2 구조 차이
|
||||
|
||||
| 구분 | V1 (본서버) | V2 (개발서버) |
|
||||
|------|-------------|---------------|
|
||||
| 테이블 | screen_layouts | screen_layouts_v2 |
|
||||
| 레코드 | 컴포넌트별 1개 | 화면당 1개 |
|
||||
| 설정 저장 | properties JSONB | layout_data.components[].overrides |
|
||||
| 채번/카테고리 | menu_objid 기반 | table_name + column_name 기반 |
|
||||
| 컴포넌트 참조 | component_type 문자열 | url 경로 (@/lib/registry/...) |
|
||||
|
||||
### 데이터 타입 관리 (V2)
|
||||
|
||||
```
|
||||
table_type_columns (input_type)
|
||||
├── 'category' → category_values 테이블
|
||||
├── 'numbering' → numbering_rules 테이블 (detail_settings.numberingRuleId)
|
||||
├── 'entity' → 엔티티 검색
|
||||
└── 'text', 'number', 'date', etc.
|
||||
```
|
||||
|
||||
### 컴포넌트 URL 매핑
|
||||
|
||||
```typescript
|
||||
const V1_TO_V2_MAPPING = {
|
||||
'table-list': '@/lib/registry/components/v2-table-list',
|
||||
'button-primary': '@/lib/registry/components/v2-button-primary',
|
||||
'text-input': '@/lib/registry/components/v2-text-input',
|
||||
'select-basic': '@/lib/registry/components/v2-select',
|
||||
'date-input': '@/lib/registry/components/v2-date-input',
|
||||
'entity-search-input': '@/lib/registry/components/v2-entity-search',
|
||||
'category-manager': '@/lib/registry/components/v2-category-manager',
|
||||
'numbering-rule': '@/lib/registry/components/v2-numbering-rule',
|
||||
'tabs-widget': '@/lib/registry/components/v2-tabs-widget',
|
||||
'textarea-basic': '@/lib/registry/components/v2-textarea',
|
||||
};
|
||||
```
|
||||
|
||||
### 모달 처리 방식 변경
|
||||
|
||||
- **V1**: 별도 화면(screen_id)으로 모달 관리
|
||||
- **V2**: 부모 화면에 overlay/dialog 컴포넌트로 통합
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 대상 메뉴 현황
|
||||
|
||||
### 품질관리 (우선순위 1)
|
||||
|
||||
| 본서버 코드 | 화면명 | 상태 | 비고 |
|
||||
|-------------|--------|------|------|
|
||||
| COMPANY_7_126 | 검사정보 관리 | ✅ V2 존재 | 컴포넌트 검증 필요 |
|
||||
| COMPANY_7_127 | 품목옵션 설정 | ✅ V2 존재 | v2-category-manager 사용중 |
|
||||
| COMPANY_7_138 | 카테고리 설정 | ❌ 누락 | table_name 기반으로 변경 |
|
||||
| COMPANY_7_139 | 코드 설정 | ❌ 누락 | table_name 기반으로 변경 |
|
||||
| COMPANY_7_142 | 검사장비 관리 | ❌ 누락 | 모달 통합 필요 |
|
||||
| COMPANY_7_143 | 검사장비 등록모달 | ❌ 누락 | → 142에 통합 |
|
||||
| COMPANY_7_144 | 불량기준 정보 | ❌ 누락 | 모달 통합 필요 |
|
||||
| COMPANY_7_145 | 불량기준 등록모달 | ❌ 누락 | → 144에 통합 |
|
||||
|
||||
### 다음 마이그레이션 대상 (미정)
|
||||
|
||||
- [ ] 물류관리
|
||||
- [ ] 생산관리
|
||||
- [ ] 영업관리
|
||||
- [ ] 기타 메뉴들
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 작업 절차
|
||||
|
||||
### Step 1: 분석
|
||||
|
||||
```sql
|
||||
-- 본서버 특정 메뉴 화면 목록 조회
|
||||
SELECT
|
||||
sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name,
|
||||
COUNT(sl.layout_id) as component_count
|
||||
FROM screen_definitions sd
|
||||
LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
WHERE sd.screen_name LIKE '%[메뉴명]%'
|
||||
AND sd.company_code = 'COMPANY_7'
|
||||
GROUP BY sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name;
|
||||
|
||||
-- 개발서버 V2 현황 확인
|
||||
SELECT
|
||||
sd.screen_id, sd.screen_code, sd.screen_name,
|
||||
sv2.layout_id IS NOT NULL as has_v2
|
||||
FROM screen_definitions sd
|
||||
LEFT JOIN screen_layouts_v2 sv2 ON sd.screen_id = sv2.screen_id
|
||||
WHERE sd.company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
### Step 2: screen_definitions 동기화
|
||||
|
||||
본서버에만 있는 화면을 개발서버에 추가
|
||||
|
||||
### Step 3: V1 → V2 레이아웃 변환
|
||||
|
||||
```typescript
|
||||
// layout_data 구조
|
||||
{
|
||||
"version": "2.0",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp_xxx",
|
||||
"url": "@/lib/registry/components/v2-table-list",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 100, "height": 50 },
|
||||
"displayOrder": 0,
|
||||
"overrides": {
|
||||
"tableName": "테이블명",
|
||||
"columns": ["컬럼1", "컬럼2"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: 카테고리 데이터 확인/생성
|
||||
|
||||
```sql
|
||||
-- 테이블의 category 컬럼 확인
|
||||
SELECT column_name, column_label
|
||||
FROM table_type_columns
|
||||
WHERE table_name = '[테이블명]'
|
||||
AND input_type = 'category';
|
||||
|
||||
-- category_values 데이터 확인
|
||||
SELECT value_id, value_code, value_label
|
||||
FROM category_values
|
||||
WHERE table_name = '[테이블명]'
|
||||
AND column_name = '[컬럼명]'
|
||||
AND company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
### Step 5: 채번 규칙 확인/생성
|
||||
|
||||
```sql
|
||||
-- numbering 컬럼 확인
|
||||
SELECT column_name, column_label, detail_settings
|
||||
FROM table_type_columns
|
||||
WHERE table_name = '[테이블명]'
|
||||
AND input_type = 'numbering';
|
||||
|
||||
-- numbering_rules 데이터 확인
|
||||
SELECT rule_id, rule_name, table_name, column_name
|
||||
FROM numbering_rules
|
||||
WHERE company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
### Step 6: 검증
|
||||
|
||||
- [ ] 화면 렌더링 확인
|
||||
- [ ] 컴포넌트 동작 확인
|
||||
- [ ] 저장/수정/삭제 테스트
|
||||
- [ ] 카테고리 드롭다운 동작
|
||||
- [ ] 채번 규칙 동작
|
||||
|
||||
---
|
||||
|
||||
## 핵심 테이블 스키마
|
||||
|
||||
### screen_layouts_v2
|
||||
|
||||
```sql
|
||||
CREATE TABLE screen_layouts_v2 (
|
||||
layout_id SERIAL PRIMARY KEY,
|
||||
screen_id INTEGER NOT NULL,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
layout_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(screen_id, company_code)
|
||||
);
|
||||
```
|
||||
|
||||
### category_values
|
||||
|
||||
```sql
|
||||
-- 핵심 컬럼
|
||||
value_id, table_name, column_name, value_code, value_label,
|
||||
parent_value_id, depth, path, company_code
|
||||
```
|
||||
|
||||
### numbering_rules + numbering_rule_parts
|
||||
|
||||
```sql
|
||||
-- numbering_rules 핵심 컬럼
|
||||
rule_id, rule_name, table_name, column_name, separator,
|
||||
reset_period, current_sequence, company_code
|
||||
|
||||
-- numbering_rule_parts 핵심 컬럼
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code
|
||||
```
|
||||
|
||||
### table_type_columns
|
||||
|
||||
```sql
|
||||
-- 핵심 컬럼
|
||||
table_name, column_name, input_type, column_label,
|
||||
detail_settings, company_code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 문서
|
||||
|
||||
### 필수 읽기
|
||||
|
||||
1. **[본서버_개발서버_마이그레이션_상세가이드.md](./본서버_개발서버_마이그레이션_상세가이드.md)** - 상세 마이그레이션 절차
|
||||
2. **[화면개발_표준_가이드.md](../screen-implementation-guide/화면개발_표준_가이드.md)** - V2 화면 개발 표준
|
||||
3. **[SCREEN_DEVELOPMENT_STANDARD.md](../screen-implementation-guide/SCREEN_DEVELOPMENT_STANDARD.md)** - 영문 표준 가이드
|
||||
|
||||
### 코드 참조
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `backend-node/src/services/categoryTreeService.ts` | 카테고리 관리 서비스 |
|
||||
| `backend-node/src/services/numberingRuleService.ts` | 채번 규칙 서비스 |
|
||||
| `frontend/lib/registry/components/v2-category-manager/` | V2 카테고리 컴포넌트 |
|
||||
| `frontend/lib/registry/components/v2-numbering-rule/` | V2 채번 컴포넌트 |
|
||||
|
||||
### 관련 문서
|
||||
|
||||
- `docs/V2_컴포넌트_분석_가이드.md`
|
||||
- `docs/V2_컴포넌트_연동_가이드.md`
|
||||
- `docs/DDD1542/COMPONENT_LAYOUT_V2_ARCHITECTURE.md`
|
||||
- `docs/DDD1542/COMPONENT_MIGRATION_PLAN.md`
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
### 절대 하지 말 것
|
||||
|
||||
1. **개발서버 → 본서버 마이그레이션** (반대 방향)
|
||||
2. **본서버 데이터 직접 수정** (SELECT만 허용)
|
||||
3. **company_code 누락** (멀티테넌시 필수)
|
||||
4. **테이블-컬럼 연결 없이 컴포넌트 배치** ("component"로 표시되면 실패)
|
||||
5. **menu_objid 기반 카테고리/채번 사용** (V2는 table_name + column_name 기반)
|
||||
|
||||
### 반드시 할 것
|
||||
|
||||
1. 마이그레이션 전 **개발서버 백업**
|
||||
2. 컴포넌트 변환 시 **V2 컴포넌트만 사용** (v2- prefix)
|
||||
3. 모달 화면은 **부모 화면에 통합**
|
||||
4. 카테고리/채번은 **table_name + column_name 기반**
|
||||
5. 컴포넌트 배치 후 **"v2-xxx"로 표시되는지 반드시 확인**
|
||||
|
||||
### 실패 사례 (이전 작업자)
|
||||
|
||||
**물류정보관리 → 운송업체 관리 마이그레이션 실패**
|
||||
|
||||
- **원인**: 컴포넌트를 직접 배치하여 "component"로 생성됨
|
||||
- **증상**: 화면에 "component" 라벨 표시, 데이터 바인딩 실패
|
||||
- **해결**: 왼쪽 패널에서 테이블 컬럼을 드래그하여 "v2-input" 등으로 생성
|
||||
|
||||
---
|
||||
|
||||
## 🔧 일괄 수정 SQL (overrides.type 누락 문제)
|
||||
|
||||
### 문제 진단 쿼리
|
||||
|
||||
```sql
|
||||
-- overrides.type이 없는 컴포넌트 수 확인
|
||||
SELECT
|
||||
COUNT(DISTINCT sv2.screen_id) as affected_screens,
|
||||
COUNT(*) as affected_components
|
||||
FROM screen_layouts_v2 sv2,
|
||||
jsonb_array_elements(sv2.layout_data->'components') as comp
|
||||
WHERE (comp->>'url' LIKE '%/v2-input'
|
||||
OR comp->>'url' LIKE '%/v2-select'
|
||||
OR comp->>'url' LIKE '%/v2-date')
|
||||
AND NOT (comp->'overrides' ? 'type');
|
||||
```
|
||||
|
||||
### 일괄 수정 쿼리 (개발서버에서만!)
|
||||
|
||||
```sql
|
||||
UPDATE screen_layouts_v2
|
||||
SET layout_data = jsonb_set(
|
||||
layout_data,
|
||||
'{components}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN comp->>'url' LIKE '%/v2-input' AND NOT (comp->'overrides' ? 'type')
|
||||
THEN jsonb_set(comp, '{overrides,type}', '"v2-input"')
|
||||
WHEN comp->>'url' LIKE '%/v2-select' AND NOT (comp->'overrides' ? 'type')
|
||||
THEN jsonb_set(comp, '{overrides,type}', '"v2-select"')
|
||||
WHEN comp->>'url' LIKE '%/v2-date' AND NOT (comp->'overrides' ? 'type')
|
||||
THEN jsonb_set(comp, '{overrides,type}', '"v2-date"')
|
||||
WHEN comp->>'url' LIKE '%/v2-textarea' AND NOT (comp->'overrides' ? 'type')
|
||||
THEN jsonb_set(comp, '{overrides,type}', '"v2-textarea"')
|
||||
ELSE comp
|
||||
END
|
||||
)
|
||||
FROM jsonb_array_elements(layout_data->'components') comp
|
||||
)
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(layout_data->'components') c
|
||||
WHERE (c->>'url' LIKE '%/v2-input' OR c->>'url' LIKE '%/v2-select'
|
||||
OR c->>'url' LIKE '%/v2-date' OR c->>'url' LIKE '%/v2-textarea')
|
||||
AND NOT (c->'overrides' ? 'type')
|
||||
);
|
||||
```
|
||||
|
||||
### 2026-02-04 일괄 수정 실행 결과
|
||||
|
||||
| 항목 | 수량 |
|
||||
|------|------|
|
||||
| 수정된 화면 | 397개 |
|
||||
| 수정된 컴포넌트 | 2,455개 |
|
||||
| v2-input | 1,983개 |
|
||||
| v2-select | 336개 |
|
||||
| v2-date | 136개 |
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 진행 로그
|
||||
|
||||
| 날짜 | 메뉴 | 담당 | 상태 | 비고 |
|
||||
|------|------|------|------|------|
|
||||
| 2026-02-03 | 품질관리 | DDD1542 | 분석 완료 | 마이그레이션 대기 |
|
||||
| 2026-02-03 | 물류관리 (운송업체) | 이전 신하 | ❌ 실패 | component 연결 오류 |
|
||||
| 2026-02-03 | 문서 학습 | DDD1542 | ✅ 완료 | 핵심 4개 문서 정독, 학습노트 작성 |
|
||||
| **2026-02-04** | **overrides.type 원인 분석** | **AI** | **✅ 완료** | **핵심 원인 발견: overrides.type 누락** |
|
||||
| **2026-02-04** | **전체 입력폼 일괄 수정** | **AI** | **✅ 완료** | **397개 화면, 2,455개 컴포넌트 수정** |
|
||||
| | 물류관리 | - | 미시작 | |
|
||||
| | 생산관리 | - | 미시작 | |
|
||||
| | 영업관리 | - | 미시작 | |
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업 요청 예시
|
||||
|
||||
다음 AI에게 요청할 때 이렇게 말하면 됩니다:
|
||||
|
||||
```
|
||||
"본서버_개발서버_마이그레이션_가이드.md 읽고 품질관리 메뉴 마이그레이션 진행해줘"
|
||||
|
||||
"본서버_개발서버_마이그레이션_가이드.md 참고해서 물류관리 메뉴 분석해줘"
|
||||
|
||||
"본서버_개발서버_마이그레이션_상세가이드.md 보고 COMPANY_7_142 화면 V2로 변환해줘"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 작성자 | 내용 |
|
||||
|------|--------|------|
|
||||
| 2026-02-03 | DDD1542 | 초안 작성 |
|
||||
| 2026-02-03 | DDD1542 | 컴포넌트-컬럼 연결 주의사항 추가 (이전 실패 원인) |
|
||||
| 2026-02-03 | DDD1542 | 개인 학습노트 작성 (V2_마이그레이션_학습노트_DDD1542.md) |
|
||||
| **2026-02-04** | **AI** | **핵심 원인 발견: overrides.type 필드 누락 문제** |
|
||||
| **2026-02-04** | **AI** | **일괄 수정 SQL 추가 및 397개 화면 수정 완료** |
|
||||
|
|
@ -0,0 +1,553 @@
|
|||
# 본서버 → 개발서버 마이그레이션 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
본 문서는 **본서버(Production)**의 `screen_layouts` (V1) 데이터를 **개발서버(Development)**의 `screen_layouts_v2` 시스템으로 마이그레이션하는 절차를 정의합니다.
|
||||
|
||||
### 마이그레이션 방향
|
||||
```
|
||||
본서버 (Production) 개발서버 (Development)
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ screen_layouts (V1) │ → │ screen_layouts_v2 │
|
||||
│ - 컴포넌트별 레코드 │ │ - 화면당 1개 레코드 │
|
||||
│ - properties JSONB │ │ - layout_data JSONB │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
### 최종 목표
|
||||
개발서버에서 완성 후 **개발서버 → 본서버**로 배포
|
||||
|
||||
---
|
||||
|
||||
## 1. V1 vs V2 구조 차이
|
||||
|
||||
### 1.1 screen_layouts (V1) - 본서버
|
||||
|
||||
```sql
|
||||
-- 컴포넌트별 1개 레코드
|
||||
CREATE TABLE screen_layouts (
|
||||
layout_id SERIAL PRIMARY KEY,
|
||||
screen_id INTEGER,
|
||||
component_type VARCHAR(50),
|
||||
component_id VARCHAR(100),
|
||||
properties JSONB, -- 모든 설정값 포함
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- 화면당 N개 레코드 (컴포넌트 수만큼)
|
||||
- `properties`에 모든 설정 저장 (defaults + overrides 구분 없음)
|
||||
- `menu_objid` 기반 채번/카테고리 관리
|
||||
|
||||
### 1.2 screen_layouts_v2 - 개발서버
|
||||
|
||||
```sql
|
||||
-- 화면당 1개 레코드
|
||||
CREATE TABLE screen_layouts_v2 (
|
||||
layout_id SERIAL PRIMARY KEY,
|
||||
screen_id INTEGER NOT NULL,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
layout_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE(screen_id, company_code)
|
||||
);
|
||||
```
|
||||
|
||||
**layout_data 구조:**
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp_xxx",
|
||||
"url": "@/lib/registry/components/v2-table-list",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 100, "height": 50 },
|
||||
"displayOrder": 0,
|
||||
"overrides": {
|
||||
"tableName": "inspection_standard",
|
||||
"columns": ["id", "name"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-02-03T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- 화면당 1개 레코드
|
||||
- `url` + `overrides` 방식 (Zod 스키마 defaults와 병합)
|
||||
- `table_name + column_name` 기반 채번/카테고리 관리 (전역)
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 타입 관리 구조 (V2)
|
||||
|
||||
### 2.1 핵심 테이블 관계
|
||||
|
||||
```
|
||||
table_type_columns (컬럼 타입 정의)
|
||||
├── input_type = 'category' → category_values
|
||||
├── input_type = 'numbering' → numbering_rules
|
||||
└── input_type = 'text', 'date', 'number', etc.
|
||||
```
|
||||
|
||||
### 2.2 table_type_columns
|
||||
|
||||
각 테이블의 컬럼별 입력 타입을 정의합니다.
|
||||
|
||||
```sql
|
||||
SELECT table_name, column_name, input_type, column_label
|
||||
FROM table_type_columns
|
||||
WHERE input_type IN ('category', 'numbering');
|
||||
```
|
||||
|
||||
**주요 input_type:**
|
||||
| input_type | 설명 | 연결 테이블 |
|
||||
|------------|------|-------------|
|
||||
| text | 텍스트 입력 | - |
|
||||
| number | 숫자 입력 | - |
|
||||
| date | 날짜 입력 | - |
|
||||
| category | 카테고리 드롭다운 | category_values |
|
||||
| numbering | 자동 채번 | numbering_rules |
|
||||
| entity | 엔티티 검색 | - |
|
||||
|
||||
### 2.3 category_values (카테고리 관리)
|
||||
|
||||
```sql
|
||||
-- 카테고리 값 조회
|
||||
SELECT value_id, table_name, column_name, value_code, value_label,
|
||||
parent_value_id, depth, company_code
|
||||
FROM category_values
|
||||
WHERE table_name = 'inspection_standard'
|
||||
AND column_name = 'inspection_method'
|
||||
AND company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
**V1 vs V2 차이:**
|
||||
| 구분 | V1 | V2 |
|
||||
|------|----|----|
|
||||
| 키 | menu_objid | table_name + column_name |
|
||||
| 범위 | 화면별 | 전역 (테이블.컬럼별) |
|
||||
| 계층 | 단일 | 3단계 (대/중/소분류) |
|
||||
|
||||
### 2.4 numbering_rules (채번 규칙)
|
||||
|
||||
```sql
|
||||
-- 채번 규칙 조회
|
||||
SELECT rule_id, rule_name, table_name, column_name, separator,
|
||||
reset_period, current_sequence, company_code
|
||||
FROM numbering_rules
|
||||
WHERE company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
**연결 방식:**
|
||||
```
|
||||
table_type_columns.detail_settings = '{"numberingRuleId": "rule-xxx"}'
|
||||
↓
|
||||
numbering_rules.rule_id = "rule-xxx"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 컴포넌트 매핑
|
||||
|
||||
### 3.1 기본 컴포넌트 매핑
|
||||
|
||||
| V1 (본서버) | V2 (개발서버) | 비고 |
|
||||
|-------------|---------------|------|
|
||||
| table-list | v2-table-list | 테이블 목록 |
|
||||
| button-primary | v2-button-primary | 버튼 |
|
||||
| text-input | v2-text-input | 텍스트 입력 |
|
||||
| select-basic | v2-select | 드롭다운 |
|
||||
| date-input | v2-date-input | 날짜 입력 |
|
||||
| entity-search-input | v2-entity-search | 엔티티 검색 |
|
||||
| tabs-widget | v2-tabs-widget | 탭 |
|
||||
|
||||
### 3.2 특수 컴포넌트 매핑
|
||||
|
||||
| V1 (본서버) | V2 (개발서버) | 마이그레이션 방식 |
|
||||
|-------------|---------------|-------------------|
|
||||
| category-manager | v2-category-manager | table_name 기반으로 변경 |
|
||||
| numbering-rule | v2-numbering-rule | table_name 기반으로 변경 |
|
||||
| 모달 화면 | overlay 통합 | 부모 화면에 통합 |
|
||||
|
||||
### 3.3 모달 처리 방식 변경
|
||||
|
||||
**V1 (본서버):**
|
||||
```
|
||||
화면 A (screen_id: 142) - 검사장비관리
|
||||
└── 버튼 클릭 → 화면 B (screen_id: 143) - 검사장비 등록모달
|
||||
```
|
||||
|
||||
**V2 (개발서버):**
|
||||
```
|
||||
화면 A (screen_id: 142) - 검사장비관리
|
||||
└── v2-dialog-form 컴포넌트로 모달 통합
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 마이그레이션 절차
|
||||
|
||||
### 4.1 사전 분석
|
||||
|
||||
```sql
|
||||
-- 1. 본서버 화면 목록 확인
|
||||
SELECT sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name,
|
||||
COUNT(sl.layout_id) as component_count
|
||||
FROM screen_definitions sd
|
||||
LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
WHERE sd.screen_code LIKE 'COMPANY_7_%'
|
||||
AND sd.screen_name LIKE '%품질%'
|
||||
GROUP BY sd.screen_id, sd.screen_code, sd.screen_name, sd.table_name;
|
||||
|
||||
-- 2. 개발서버 V2 화면 현황 확인
|
||||
SELECT sd.screen_id, sd.screen_code, sd.screen_name,
|
||||
sv2.layout_data IS NOT NULL as has_v2_layout
|
||||
FROM screen_definitions sd
|
||||
LEFT JOIN screen_layouts_v2 sv2 ON sd.screen_id = sv2.screen_id
|
||||
WHERE sd.company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
### 4.2 Step 1: screen_definitions 동기화
|
||||
|
||||
```sql
|
||||
-- 본서버에만 있는 화면을 개발서버에 추가
|
||||
INSERT INTO screen_definitions (screen_code, screen_name, table_name, company_code, ...)
|
||||
SELECT screen_code, screen_name, table_name, company_code, ...
|
||||
FROM [본서버].screen_definitions
|
||||
WHERE screen_code NOT IN (SELECT screen_code FROM screen_definitions);
|
||||
```
|
||||
|
||||
### 4.3 Step 2: V1 → V2 레이아웃 변환
|
||||
|
||||
```typescript
|
||||
// 변환 로직 (pseudo-code)
|
||||
async function convertV1toV2(screenId: number, companyCode: string) {
|
||||
// 1. V1 레이아웃 조회
|
||||
const v1Layouts = await getV1Layouts(screenId);
|
||||
|
||||
// 2. V2 형식으로 변환
|
||||
const v2Layout = {
|
||||
version: "2.0",
|
||||
components: v1Layouts.map(v1 => ({
|
||||
id: v1.component_id,
|
||||
url: mapComponentUrl(v1.component_type),
|
||||
position: { x: v1.position_x, y: v1.position_y },
|
||||
size: { width: v1.width, height: v1.height },
|
||||
displayOrder: v1.display_order,
|
||||
overrides: extractOverrides(v1.properties)
|
||||
})),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 3. V2 테이블에 저장
|
||||
await saveV2Layout(screenId, companyCode, v2Layout);
|
||||
}
|
||||
|
||||
function mapComponentUrl(v1Type: string): string {
|
||||
const mapping = {
|
||||
'table-list': '@/lib/registry/components/v2-table-list',
|
||||
'button-primary': '@/lib/registry/components/v2-button-primary',
|
||||
'category-manager': '@/lib/registry/components/v2-category-manager',
|
||||
'numbering-rule': '@/lib/registry/components/v2-numbering-rule',
|
||||
// ... 기타 매핑
|
||||
};
|
||||
return mapping[v1Type] || `@/lib/registry/components/v2-${v1Type}`;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Step 3: 카테고리 데이터 마이그레이션
|
||||
|
||||
```sql
|
||||
-- 본서버 카테고리 데이터 → 개발서버 category_values
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label,
|
||||
value_order, parent_value_id, depth, company_code
|
||||
)
|
||||
SELECT
|
||||
-- V1 카테고리 데이터를 table_name + column_name 기반으로 변환
|
||||
'inspection_standard' as table_name,
|
||||
'inspection_method' as column_name,
|
||||
value_code,
|
||||
value_label,
|
||||
sort_order,
|
||||
NULL as parent_value_id,
|
||||
1 as depth,
|
||||
'COMPANY_7' as company_code
|
||||
FROM [본서버_카테고리_데이터];
|
||||
```
|
||||
|
||||
### 4.5 Step 4: 채번 규칙 마이그레이션
|
||||
|
||||
```sql
|
||||
-- 본서버 채번 규칙 → 개발서버 numbering_rules
|
||||
INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, table_name, column_name,
|
||||
separator, reset_period, current_sequence, company_code
|
||||
)
|
||||
SELECT
|
||||
rule_id,
|
||||
rule_name,
|
||||
'inspection_standard' as table_name,
|
||||
'inspection_code' as column_name,
|
||||
separator,
|
||||
reset_period,
|
||||
0 as current_sequence, -- 시퀀스 초기화
|
||||
'COMPANY_7' as company_code
|
||||
FROM [본서버_채번_규칙];
|
||||
```
|
||||
|
||||
### 4.6 Step 5: table_type_columns 설정
|
||||
|
||||
```sql
|
||||
-- 카테고리 컬럼 설정
|
||||
UPDATE table_type_columns
|
||||
SET input_type = 'category'
|
||||
WHERE table_name = 'inspection_standard'
|
||||
AND column_name = 'inspection_method'
|
||||
AND company_code = 'COMPANY_7';
|
||||
|
||||
-- 채번 컬럼 설정
|
||||
UPDATE table_type_columns
|
||||
SET
|
||||
input_type = 'numbering',
|
||||
detail_settings = '{"numberingRuleId": "rule-xxx"}'
|
||||
WHERE table_name = 'inspection_standard'
|
||||
AND column_name = 'inspection_code'
|
||||
AND company_code = 'COMPANY_7';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 품질관리 메뉴 마이그레이션 현황
|
||||
|
||||
### 5.1 화면 매핑 현황
|
||||
|
||||
| 본서버 코드 | 화면명 | 테이블 | 개발서버 상태 | 비고 |
|
||||
|-------------|--------|--------|---------------|------|
|
||||
| COMPANY_7_126 | 검사정보 관리 | inspection_standard | ✅ V2 존재 | 컴포넌트 수 확인 필요 |
|
||||
| COMPANY_7_127 | 품목옵션 설정 | - | ✅ V2 존재 | v2-category-manager 사용중 |
|
||||
| COMPANY_7_138 | 카테고리 설정 | inspection_standard | ❌ 누락 | V2: table_name 기반으로 변경 |
|
||||
| COMPANY_7_139 | 코드 설정 | inspection_standard | ❌ 누락 | V2: table_name 기반으로 변경 |
|
||||
| COMPANY_7_142 | 검사장비 관리 | inspection_equipment_mng | ❌ 누락 | 모달 통합 필요 |
|
||||
| COMPANY_7_143 | 검사장비 등록모달 | inspection_equipment_mng | ❌ 누락 | COMPANY_7_142에 통합 |
|
||||
| COMPANY_7_144 | 불량기준 정보 | defect_standard_mng | ❌ 누락 | 모달 통합 필요 |
|
||||
| COMPANY_7_145 | 불량기준 등록모달 | defect_standard_mng | ❌ 누락 | COMPANY_7_144에 통합 |
|
||||
|
||||
### 5.2 카테고리/채번 컬럼 현황
|
||||
|
||||
**inspection_standard:**
|
||||
| 컬럼 | input_type | 라벨 |
|
||||
|------|------------|------|
|
||||
| inspection_method | category | 검사방법 |
|
||||
| unit | category | 단위 |
|
||||
| apply_type | category | 적용구분 |
|
||||
| inspection_type | category | 유형 |
|
||||
|
||||
**inspection_equipment_mng:**
|
||||
| 컬럼 | input_type | 라벨 |
|
||||
|------|------------|------|
|
||||
| equipment_type | category | 장비유형 |
|
||||
| installation_location | category | 설치장소 |
|
||||
| equipment_status | category | 장비상태 |
|
||||
|
||||
**defect_standard_mng:**
|
||||
| 컬럼 | input_type | 라벨 |
|
||||
|------|------------|------|
|
||||
| defect_type | category | 불량유형 |
|
||||
| severity | category | 심각도 |
|
||||
| inspection_type | category | 검사유형 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 자동화 스크립트
|
||||
|
||||
### 6.1 마이그레이션 실행 스크립트
|
||||
|
||||
```typescript
|
||||
// backend-node/src/scripts/migrateV1toV2.ts
|
||||
import { getPool } from "../database/db";
|
||||
|
||||
interface MigrationResult {
|
||||
screenCode: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
componentCount?: number;
|
||||
}
|
||||
|
||||
async function migrateScreenToV2(
|
||||
screenCode: string,
|
||||
companyCode: string
|
||||
): Promise<MigrationResult> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// 1. V1 레이아웃 조회 (본서버에서)
|
||||
const v1Result = await pool.query(`
|
||||
SELECT sl.*, sd.table_name, sd.screen_name
|
||||
FROM screen_layouts sl
|
||||
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
|
||||
WHERE sd.screen_code = $1
|
||||
ORDER BY sl.display_order
|
||||
`, [screenCode]);
|
||||
|
||||
if (v1Result.rows.length === 0) {
|
||||
return { screenCode, success: false, message: "V1 레이아웃 없음" };
|
||||
}
|
||||
|
||||
// 2. V2 형식으로 변환
|
||||
const components = v1Result.rows
|
||||
.filter(row => row.component_type !== '_metadata')
|
||||
.map(row => ({
|
||||
id: row.component_id || `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
url: mapComponentUrl(row.component_type),
|
||||
position: { x: row.position_x || 0, y: row.position_y || 0 },
|
||||
size: { width: row.width || 100, height: row.height || 50 },
|
||||
displayOrder: row.display_order || 0,
|
||||
overrides: extractOverrides(row.properties, row.component_type)
|
||||
}));
|
||||
|
||||
const layoutData = {
|
||||
version: "2.0",
|
||||
components,
|
||||
migratedFrom: "V1",
|
||||
migratedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 3. 개발서버 V2 테이블에 저장
|
||||
const screenId = v1Result.rows[0].screen_id;
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()
|
||||
`, [screenId, companyCode, JSON.stringify(layoutData)]);
|
||||
|
||||
return {
|
||||
screenCode,
|
||||
success: true,
|
||||
message: "마이그레이션 완료",
|
||||
componentCount: components.length
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { screenCode, success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function mapComponentUrl(v1Type: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'table-list': '@/lib/registry/components/v2-table-list',
|
||||
'button-primary': '@/lib/registry/components/v2-button-primary',
|
||||
'text-input': '@/lib/registry/components/v2-text-input',
|
||||
'select-basic': '@/lib/registry/components/v2-select',
|
||||
'date-input': '@/lib/registry/components/v2-date-input',
|
||||
'entity-search-input': '@/lib/registry/components/v2-entity-search',
|
||||
'category-manager': '@/lib/registry/components/v2-category-manager',
|
||||
'numbering-rule': '@/lib/registry/components/v2-numbering-rule',
|
||||
'tabs-widget': '@/lib/registry/components/v2-tabs-widget',
|
||||
'textarea-basic': '@/lib/registry/components/v2-textarea',
|
||||
};
|
||||
return mapping[v1Type] || `@/lib/registry/components/v2-${v1Type}`;
|
||||
}
|
||||
|
||||
function extractOverrides(properties: any, componentType: string): Record<string, any> {
|
||||
if (!properties) return {};
|
||||
|
||||
// V2 Zod 스키마 defaults와 비교하여 다른 값만 추출
|
||||
// (실제 구현 시 각 컴포넌트의 defaultConfig와 비교)
|
||||
const overrides: Record<string, any> = {};
|
||||
|
||||
// 필수 설정만 추출
|
||||
if (properties.tableName) overrides.tableName = properties.tableName;
|
||||
if (properties.columns) overrides.columns = properties.columns;
|
||||
if (properties.label) overrides.label = properties.label;
|
||||
if (properties.onClick) overrides.onClick = properties.onClick;
|
||||
|
||||
return overrides;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 체크리스트
|
||||
|
||||
### 7.1 마이그레이션 전
|
||||
|
||||
- [ ] 본서버 화면 목록 확인
|
||||
- [ ] 개발서버 기존 V2 데이터 백업
|
||||
- [ ] 컴포넌트 매핑 테이블 검토
|
||||
- [ ] 카테고리/채번 데이터 분석
|
||||
|
||||
### 7.2 마이그레이션 후
|
||||
|
||||
- [ ] screen_definitions 동기화 확인
|
||||
- [ ] screen_layouts_v2 데이터 생성 확인
|
||||
- [ ] 컴포넌트 렌더링 테스트
|
||||
- [ ] 카테고리 드롭다운 동작 확인
|
||||
- [ ] 채번 규칙 동작 확인
|
||||
- [ ] 저장/수정/삭제 기능 테스트
|
||||
|
||||
### 7.3 모달 통합 확인
|
||||
|
||||
- [ ] 기존 모달 화면 → overlay 통합 완료
|
||||
- [ ] 부모-자식 데이터 연동 확인
|
||||
- [ ] 모달 열기/닫기 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 8. 롤백 계획
|
||||
|
||||
마이그레이션 실패 시 롤백 절차:
|
||||
|
||||
```sql
|
||||
-- 1. V2 레이아웃 롤백
|
||||
DELETE FROM screen_layouts_v2
|
||||
WHERE screen_id IN (
|
||||
SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code LIKE 'COMPANY_7_%'
|
||||
);
|
||||
|
||||
-- 2. 추가된 screen_definitions 롤백
|
||||
DELETE FROM screen_definitions
|
||||
WHERE screen_code IN ('신규_추가된_코드들')
|
||||
AND company_code = 'COMPANY_7';
|
||||
|
||||
-- 3. category_values 롤백
|
||||
DELETE FROM category_values
|
||||
WHERE company_code = 'COMPANY_7'
|
||||
AND created_at > '[마이그레이션_시작_시간]';
|
||||
|
||||
-- 4. numbering_rules 롤백
|
||||
DELETE FROM numbering_rules
|
||||
WHERE company_code = 'COMPANY_7'
|
||||
AND created_at > '[마이그레이션_시작_시간]';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 자료
|
||||
|
||||
### 관련 코드 파일
|
||||
|
||||
- **V2 Category Manager**: `frontend/lib/registry/components/v2-category-manager/`
|
||||
- **V2 Numbering Rule**: `frontend/lib/registry/components/v2-numbering-rule/`
|
||||
- **Category Service**: `backend-node/src/services/categoryTreeService.ts`
|
||||
- **Numbering Service**: `backend-node/src/services/numberingRuleService.ts`
|
||||
|
||||
### 관련 문서
|
||||
|
||||
- [V2 컴포넌트 분석 가이드](../V2_컴포넌트_분석_가이드.md)
|
||||
- [V2 컴포넌트 연동 가이드](../V2_컴포넌트_연동_가이드.md)
|
||||
- [화면 개발 표준 가이드](../screen-implementation-guide/SCREEN_DEVELOPMENT_STANDARD.md)
|
||||
- [컴포넌트 레이아웃 V2 아키텍처](./COMPONENT_LAYOUT_V2_ARCHITECTURE.md)
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 작성자 | 내용 |
|
||||
|------|--------|------|
|
||||
| 2026-02-03 | DDD1542 | 초안 작성 |
|
||||
|
|
@ -23,7 +23,8 @@
|
|||
| 테이블명 | 용도 | 주요 컬럼 |
|
||||
|----------|------|----------|
|
||||
| `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` |
|
||||
| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 | `screen_id`, `properties` (JSONB - componentConfig 포함) |
|
||||
| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 (Legacy) | `screen_id`, `properties` (JSONB - componentConfig 포함) |
|
||||
| `screen_layouts_v2` | 화면 레이아웃/컴포넌트 정보 (V2) | `screen_id`, `layout_data` (JSONB - components 배열) |
|
||||
| `screen_groups` | 화면 그룹 정보 | `group_id`, `group_code`, `group_name`, `parent_group_id` |
|
||||
| `screen_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` |
|
||||
|
||||
|
|
@ -86,9 +87,17 @@ screen_groups (그룹)
|
|||
│ │
|
||||
│ └─── screen_definitions (화면)
|
||||
│ │
|
||||
│ └─── screen_layouts (레이아웃/컴포넌트)
|
||||
│ ├─── screen_layouts (Legacy)
|
||||
│ │ │
|
||||
│ │ └─── properties.componentConfig
|
||||
│ │ ├── fieldMappings
|
||||
│ │ ├── parentDataMapping
|
||||
│ │ ├── columns.mapping
|
||||
│ │ └── rightPanel.relation
|
||||
│ │
|
||||
│ └─── screen_layouts_v2 (V2) ← 현재 표준
|
||||
│ │
|
||||
│ └─── properties.componentConfig
|
||||
│ └─── layout_data.components[].overrides
|
||||
│ ├── fieldMappings
|
||||
│ ├── parentDataMapping
|
||||
│ ├── columns.mapping
|
||||
|
|
@ -1120,9 +1129,12 @@ screenSubTables[screenId].subTables.push({
|
|||
21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시)
|
||||
22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData)
|
||||
23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입)
|
||||
24. [ ] **선 교차점 이질감 해결** (계획 중)
|
||||
22. [ ] 범례 UI 추가 (선택사항)
|
||||
23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
|
||||
24. [x] **screen_layouts_v2 지원 추가** (rightPanel.relation V2 UNION 쿼리) ✅ 2026-01-30
|
||||
25. [x] **테이블 분류 우선순위 시스템** (메인 > 서브 우선순위 적용) ✅ 2026-01-30
|
||||
26. [x] **globalMainTables API 추가** (WHERE 조건 대상 테이블 목록 반환) ✅ 2026-01-30
|
||||
27. [ ] **선 교차점 이질감 해결** (계획 중)
|
||||
28. [ ] 범례 UI 추가 (선택사항)
|
||||
29. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1682,6 +1694,149 @@ frontend/
|
|||
|
||||
---
|
||||
|
||||
## 테이블 분류 우선순위 시스템 (2026-01-30)
|
||||
|
||||
### 배경
|
||||
|
||||
마스터-디테일 관계의 디테일 테이블(예: `user_dept`)이 다른 곳에서 autocomplete 참조로도 사용되는 경우,
|
||||
서브 테이블 영역에 잘못 배치되는 문제가 발생했습니다.
|
||||
|
||||
### 문제 상황
|
||||
|
||||
```
|
||||
[user_info] - 화면 139의 디테일 → 메인 테이블 영역 (O)
|
||||
[user_dept] - 화면 162의 디테일이지만 autocomplete 참조도 있음 → 서브 테이블 영역 (X)
|
||||
```
|
||||
|
||||
**원인**: 테이블 분류 시 우선순위가 없어서 먼저 발견된 관계 타입으로 분류됨
|
||||
|
||||
### 해결책: 우선순위 기반 테이블 분류
|
||||
|
||||
#### 분류 규칙
|
||||
|
||||
| 우선순위 | 분류 | 조건 | 비고 |
|
||||
|----------|------|------|------|
|
||||
| **1순위** | 메인 테이블 | `screen_definitions.table_name` | 컴포넌트 직접 연결 |
|
||||
| **1순위** | 메인 테이블 | `v2-split-panel-layout.rightPanel.tableName` | WHERE 조건 대상 |
|
||||
| **2순위** | 서브 테이블 | 조인으로만 연결된 테이블 | autocomplete 등 참조 |
|
||||
|
||||
#### 핵심 규칙
|
||||
|
||||
> **메인 조건에 해당하면, 서브 조건이 있어도 무조건 메인으로 분류**
|
||||
|
||||
### 백엔드 변경 (`screenGroupController.ts`)
|
||||
|
||||
#### 1. screen_layouts_v2 지원 추가
|
||||
|
||||
`rightPanelQuery`에 V2 테이블 UNION 추가:
|
||||
|
||||
```sql
|
||||
-- V1: screen_layouts에서 조회
|
||||
SELECT ...
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
WHERE sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- V2: screen_layouts_v2에서 조회 (v2-split-panel-layout 컴포넌트)
|
||||
SELECT
|
||||
sd.screen_id,
|
||||
comp->'overrides'->>'type' as component_type,
|
||||
comp->'overrides'->'rightPanel'->'relation' as right_panel_relation,
|
||||
comp->'overrides'->'rightPanel'->>'tableName' as right_panel_table,
|
||||
...
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
|
||||
jsonb_array_elements(slv2.layout_data->'components') as comp
|
||||
WHERE comp->'overrides'->'rightPanel'->'relation' IS NOT NULL
|
||||
```
|
||||
|
||||
#### 2. globalMainTables API 추가
|
||||
|
||||
`getScreenSubTables` 응답에 전역 메인 테이블 목록 추가:
|
||||
|
||||
```sql
|
||||
-- 모든 화면의 메인 테이블 수집
|
||||
SELECT DISTINCT table_name as main_table FROM screen_definitions WHERE screen_id = ANY($1)
|
||||
UNION
|
||||
SELECT DISTINCT comp->'overrides'->'rightPanel'->>'tableName' as main_table
|
||||
FROM screen_layouts_v2 ...
|
||||
```
|
||||
|
||||
**응답 구조:**
|
||||
```typescript
|
||||
res.json({
|
||||
success: true,
|
||||
data: screenSubTables,
|
||||
globalMainTables: globalMainTables, // 메인 테이블 목록 추가
|
||||
});
|
||||
```
|
||||
|
||||
### 프론트엔드 변경 (`ScreenRelationFlow.tsx`)
|
||||
|
||||
#### 1. globalMainTables 상태 추가
|
||||
|
||||
```typescript
|
||||
const [globalMainTables, setGlobalMainTables] = useState<Set<string>>(new Set());
|
||||
```
|
||||
|
||||
#### 2. 우선순위 기반 테이블 분류
|
||||
|
||||
```typescript
|
||||
// 1. globalMainTables를 mainTableSet에 먼저 추가 (우선순위 적용)
|
||||
globalMainTables.forEach((tableName) => {
|
||||
if (!mainTableSet.has(tableName)) {
|
||||
mainTableSet.add(tableName);
|
||||
filterTableSet.add(tableName); // 보라색 테두리
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 서브 테이블 수집 (mainTableSet에 없는 것만)
|
||||
screenSubData.subTables.forEach((subTable) => {
|
||||
if (mainTableSet.has(subTable.tableName)) {
|
||||
return; // 메인 테이블은 서브에서 제외
|
||||
}
|
||||
subTableSet.add(subTable.tableName);
|
||||
});
|
||||
```
|
||||
|
||||
### 시각적 결과
|
||||
|
||||
#### 변경 전
|
||||
|
||||
```
|
||||
[화면 노드들]
|
||||
│
|
||||
▼
|
||||
[메인 테이블: dept_info, user_info] ← user_dept 없음
|
||||
│
|
||||
▼
|
||||
[서브 테이블: user_dept, customer_mng] ← user_dept가 잘못 배치됨
|
||||
```
|
||||
|
||||
#### 변경 후
|
||||
|
||||
```
|
||||
[화면 노드들]
|
||||
│
|
||||
▼
|
||||
[메인 테이블: dept_info, user_info, user_dept] ← user_dept 보라색 테두리
|
||||
│
|
||||
▼
|
||||
[서브 테이블: customer_mng] ← 조인 참조용 테이블만
|
||||
```
|
||||
|
||||
### 관련 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `backend-node/src/controllers/screenGroupController.ts` | screen_layouts_v2 UNION 추가, globalMainTables 반환 |
|
||||
| `frontend/components/screen/ScreenRelationFlow.tsx` | globalMainTables 상태, 우선순위 분류 로직 |
|
||||
| `frontend/components/screen/ScreenNode.tsx` | isFilterTable prop 및 보라색 테두리 스타일 |
|
||||
|
||||
---
|
||||
|
||||
## 화면 설정 모달 개선 (2026-01-12)
|
||||
|
||||
### 개요
|
||||
|
|
@ -1742,4 +1897,6 @@ npm install react-zoom-pan-pinch
|
|||
- [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc)
|
||||
- [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.mdc)
|
||||
- [관리자 페이지 스타일 가이드](.cursor/rules/admin-page-style-guide.mdc)
|
||||
- [화면 복제 V2 마이그레이션 계획서](../SCREEN_COPY_V2_MIGRATION_PLAN.md) - screen_layouts_v2 복제 로직
|
||||
- [V2 컴포넌트 마이그레이션 분석](../V2_COMPONENT_MIGRATION_ANALYSIS.md) - V2 아키텍처
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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
|
|
@ -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곳 수정.
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
# 저장 후 플로우 실행 시 폼 데이터 전달 오류 수정
|
||||
|
||||
## 오류 현상
|
||||
|
||||
사용자가 폼에서 데이터를 저장한 후, 연결된 노드 플로우(예: 비밀번호 자동 설정)가 실행될 때 `sabun` 값이 `undefined`로 전달되어 UPDATE 쿼리의 WHERE 조건이 작동하지 않는 문제.
|
||||
|
||||
### 증상
|
||||
- 저장 버튼 클릭 시 INSERT는 정상 작동
|
||||
- 저장 후 실행되는 노드 플로우에서 `user_password` UPDATE가 실패 (0건 업데이트)
|
||||
- 콘솔 로그에서 `savedData.sabun: undefined` 출력
|
||||
|
||||
```
|
||||
📦 [executeAfterSaveControl] savedData 필드: ['id', 'screenId', 'tableName', 'data', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']
|
||||
📦 [executeAfterSaveControl] savedData.sabun: undefined
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 원인 분석
|
||||
|
||||
### API 응답 구조의 3단계 중첩
|
||||
|
||||
저장 API(`DynamicFormApi.saveFormData`)의 응답이 3단계로 중첩되어 있었음:
|
||||
|
||||
```typescript
|
||||
// 1단계: Axios 응답
|
||||
saveResult = {
|
||||
data: { ... } // API 응답
|
||||
}
|
||||
|
||||
// 2단계: API 응답 래핑 (ApiResponse 인터페이스)
|
||||
saveResult.data = {
|
||||
success: true,
|
||||
data: { ... }, // 저장된 레코드
|
||||
message: "저장 완료"
|
||||
}
|
||||
|
||||
// 3단계: 저장된 레코드 (dynamic_form_data 테이블 구조)
|
||||
saveResult.data.data = {
|
||||
id: 123,
|
||||
screenId: 106,
|
||||
tableName: "user_info",
|
||||
data: { sabun: "20260205-087", user_name: "TEST", ... }, // ← 실제 폼 데이터
|
||||
createdAt: "2026-02-05T...",
|
||||
updatedAt: "2026-02-05T...",
|
||||
createdBy: "admin",
|
||||
updatedBy: "admin"
|
||||
}
|
||||
|
||||
// 4단계: 실제 폼 데이터 (우리가 필요한 데이터)
|
||||
saveResult.data.data.data = {
|
||||
sabun: "20260205-087",
|
||||
user_name: "TEST",
|
||||
user_id: "Kim1542",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 기존 코드의 문제점
|
||||
|
||||
```typescript
|
||||
// 기존 코드 (buttonActions.ts:1619-1621)
|
||||
const savedData = saveResult?.data?.data || saveResult?.data || {};
|
||||
const formData = savedData; // ← 2단계까지만 추출
|
||||
|
||||
// savedData = { id, screenId, tableName, data: {...}, createdAt, ... }
|
||||
// savedData.sabun = undefined ← 문제 발생!
|
||||
```
|
||||
|
||||
기존 코드는 2단계(`saveResult.data.data`)까지만 추출했기 때문에, `savedData`가 저장된 레코드 메타데이터를 가리키고 있었음. 실제 폼 데이터는 `savedData.data` 안에 있었음.
|
||||
|
||||
---
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 수정된 코드
|
||||
|
||||
```typescript
|
||||
// 수정된 코드 (buttonActions.ts:1619-1628)
|
||||
// 🔧 수정: saveResult.data가 3단계로 중첩된 경우 실제 폼 데이터 추출
|
||||
// saveResult.data = API 응답 { success, data, message }
|
||||
// saveResult.data.data = 저장된 레코드 { id, screenId, tableName, data, createdAt... }
|
||||
// saveResult.data.data.data = 실제 폼 데이터 { sabun, user_name... }
|
||||
const savedRecord = saveResult?.data?.data || saveResult?.data || {};
|
||||
const actualFormData = savedRecord?.data || savedRecord; // ← 3단계까지 추출
|
||||
const formData = (Object.keys(actualFormData).length > 0 ? actualFormData : context.formData || {});
|
||||
```
|
||||
|
||||
### 수정 핵심
|
||||
1. `savedRecord`: 저장된 레코드 메타데이터 (`{ id, screenId, tableName, data, ... }`)
|
||||
2. `actualFormData`: `savedRecord.data`가 있으면 그것을 사용, 없으면 `savedRecord` 자체 사용
|
||||
3. 폴백: `actualFormData`가 비어있으면 `context.formData` 사용
|
||||
|
||||
---
|
||||
|
||||
## 수정된 파일
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|-----------|
|
||||
| `frontend/lib/utils/buttonActions.ts` | 3단계 중첩 데이터 구조에서 실제 폼 데이터 추출 로직 수정 (라인 1619-1628) |
|
||||
|
||||
---
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### 수정 전
|
||||
```
|
||||
📦 [executeAfterSaveControl] savedData 필드: ['id', 'screenId', 'tableName', 'data', ...]
|
||||
📦 [executeAfterSaveControl] savedData.sabun: undefined
|
||||
```
|
||||
|
||||
### 수정 후
|
||||
```
|
||||
📦 [executeAfterSaveControl] savedRecord 구조: ['id', 'screenId', 'tableName', 'data', ...]
|
||||
📦 [executeAfterSaveControl] actualFormData 추출: ['sabun', 'user_id', 'user_password', ...]
|
||||
📦 [executeAfterSaveControl] formData.sabun: 20260205-087
|
||||
```
|
||||
|
||||
### DB 확인
|
||||
```sql
|
||||
SELECT sabun, user_name, user_password FROM user_info WHERE sabun = '20260205-087';
|
||||
-- 결과: sabun: "20260205-087", user_name: "TEST", user_password: "1e538e2abdd9663437343212a4853591"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 교훈
|
||||
|
||||
1. **API 응답 구조 확인**: API 응답이 여러 단계로 래핑될 수 있음. 프론트엔드에서 `apiClient`가 한 번, `ApiResponse` 인터페이스가 한 번, 그리고 실제 데이터 구조가 또 다른 레벨을 가질 수 있음.
|
||||
|
||||
2. **로그 추가의 중요성**: 중간 단계마다 로그를 찍어 데이터 구조를 확인하는 것이 디버깅에 필수적.
|
||||
|
||||
3. **폴백 처리**: 데이터 추출 시 여러 단계의 폴백을 두어 다양한 응답 구조에 대응.
|
||||
|
||||
---
|
||||
|
||||
## 관련 이슈
|
||||
|
||||
- 비밀번호 자동 설정 노드 플로우가 저장 후 실행되지 않는 문제
|
||||
- 저장 후 연결된 UPDATE 플로우에서 WHERE 조건이 작동하지 않는 문제
|
||||
|
||||
---
|
||||
|
||||
## 작성 정보
|
||||
|
||||
- **작성일**: 2026-02-05
|
||||
- **작성자**: AI Assistant
|
||||
- **관련 화면**: 부서관리 > 사용자 등록 모달
|
||||
- **관련 플로우**: flowId: 120 (부서관리 비밀번호 자동세팅)
|
||||
|
|
@ -0,0 +1,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*
|
||||
|
|
@ -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 (타임라인, 모바일 스타일)로 개별 개발 필요
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
|
@ -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 구조만
|
||||
**별도 설정 필요한 것**: 저장 테이블, 버튼 액션, 조건 처리, 다중 행 처리
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 개발 시 재활용 가능**
|
||||
|
|
@ -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 컴포넌트로 핵심 기능 구현 가능**
|
||||
|
|
@ -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 컴포넌트로 완전 구현 가능**
|
||||
|
|
@ -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 개발 시 그룹화 기능 추가 가능**
|
||||
|
|
@ -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 컴포넌트로 핵심 기능 구현 가능**
|
||||
|
|
@ -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)
|
||||
|
|
@ -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` |
|
||||
|
|
@ -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)
|
||||
- 특이 사항: 없음
|
||||
|
|
@ -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` |
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -773,18 +773,81 @@ export default function TableManagementPage() {
|
|||
|
||||
// 2. 모든 컬럼 설정 저장
|
||||
if (columns.length > 0) {
|
||||
const columnSettings = columns.map((column) => ({
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: column.detailSettings || "",
|
||||
description: column.description || "",
|
||||
codeCategory: column.codeCategory || "",
|
||||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
referenceColumn: column.referenceColumn || "",
|
||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
}));
|
||||
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: 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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
|||
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -126,6 +127,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
|
||||
const modalOpenedAtRef = React.useRef<number>(0);
|
||||
|
||||
// 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleNumberingValueChanged = (event: CustomEvent) => {
|
||||
const { columnName, value } = event.detail;
|
||||
if (columnName && modalState.isOpen) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[columnName]: value,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
|
||||
};
|
||||
}, [modalState.isOpen]);
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
|
|
@ -139,6 +158,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
splitPanelParentData,
|
||||
selectedData: eventSelectedData,
|
||||
selectedIds,
|
||||
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
|
||||
} = event.detail;
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
|
|
@ -162,7 +182,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||
if (editData) {
|
||||
// 🔧 단, isCreateMode가 true이면 (복사 모드) originalData를 설정하지 않음 → 채번 생성 가능
|
||||
if (editData && !isCreateMode) {
|
||||
// 🆕 배열인 경우 두 가지 데이터를 설정:
|
||||
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
|
||||
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
|
||||
|
|
@ -176,6 +197,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
|
||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
}
|
||||
} else if (editData && isCreateMode) {
|
||||
// 🆕 복사 모드: formData만 설정하고 originalData는 null로 유지 (채번 생성 가능)
|
||||
if (Array.isArray(editData)) {
|
||||
const firstRecord = editData[0] || {};
|
||||
setFormData(firstRecord);
|
||||
setSelectedData(editData);
|
||||
} else {
|
||||
setFormData(editData);
|
||||
setSelectedData([editData]);
|
||||
}
|
||||
setOriginalData(null); // 🔧 복사 모드에서는 originalData를 null로 설정
|
||||
} else {
|
||||
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
||||
|
|
@ -322,12 +354,27 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 화면 정보와 레이아웃 데이터 로딩
|
||||
const [screenInfo, layoutData] = await Promise.all([
|
||||
// 화면 정보와 레이아웃 데이터 로딩 (V2 API 사용으로 기본값 병합)
|
||||
const [screenInfo, v2LayoutData] = await Promise.all([
|
||||
screenApi.getScreen(screenId),
|
||||
screenApi.getLayout(screenId),
|
||||
screenApi.getLayoutV2(screenId),
|
||||
]);
|
||||
|
||||
// V2 → Legacy 변환 (기본값 병합 포함)
|
||||
let layoutData: any = null;
|
||||
if (v2LayoutData && isValidV2Layout(v2LayoutData)) {
|
||||
layoutData = convertV2ToLegacy(v2LayoutData);
|
||||
if (layoutData) {
|
||||
// screenResolution은 V2 레이아웃에서 직접 가져오기
|
||||
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
|
||||
}
|
||||
}
|
||||
|
||||
// V2 레이아웃이 없으면 기존 API로 fallback
|
||||
if (!layoutData) {
|
||||
layoutData = await screenApi.getLayout(screenId);
|
||||
}
|
||||
|
||||
// 🆕 URL 파라미터 확인 (수정 모드)
|
||||
if (typeof window !== "undefined") {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
|
@ -337,8 +384,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const groupByColumnsParam = urlParams.get("groupByColumns");
|
||||
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
|
||||
|
||||
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn });
|
||||
|
||||
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
||||
if (mode === "edit" && editId && tableName) {
|
||||
try {
|
||||
|
|
@ -363,14 +408,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용)
|
||||
if (primaryKeyColumn) {
|
||||
params.primaryKeyColumn = primaryKeyColumn;
|
||||
console.log("✅ [ScreenModal] primaryKeyColumn을 params에 추가:", primaryKeyColumn);
|
||||
}
|
||||
|
||||
console.log("📡 [ScreenModal] 실제 API 요청:", {
|
||||
url: `/data/${tableName}/${editId}`,
|
||||
params,
|
||||
});
|
||||
|
||||
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
|
||||
const response = apiResponse.data;
|
||||
|
||||
|
|
@ -483,26 +522,34 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||
needsScroll: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding
|
||||
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
const dialogGap = 16; // DialogContent gap-4
|
||||
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
|
||||
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
|
||||
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
|
||||
const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이)
|
||||
const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩)
|
||||
const horizontalPadding = 16; // 좌우 패딩 최소화
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + extraPadding;
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding;
|
||||
const maxAvailableHeight = window.innerHeight * 0.95;
|
||||
|
||||
// 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요
|
||||
const needsScroll = totalHeight > maxAvailableHeight;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
|
||||
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
|
||||
maxHeight: `${maxAvailableHeight}px`,
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
},
|
||||
needsScroll,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -570,7 +617,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
||||
{...(modalStyle.style && { style: modalStyle.style })}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
|
|
@ -585,7 +632,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
<div
|
||||
className={`flex-1 min-h-0 flex items-start justify-center pt-6 ${modalStyle.needsScroll ? 'overflow-auto' : 'overflow-visible'}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -597,30 +646,137 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
// 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
{(() => {
|
||||
// 🆕 동적 y 좌표 조정을 위해 먼저 숨겨지는 컴포넌트들 파악
|
||||
const isComponentHidden = (comp: any) => {
|
||||
const cc = comp.componentConfig?.conditionalConfig || comp.conditionalConfig;
|
||||
if (!cc?.enabled || !formData) return false;
|
||||
|
||||
const { field, operator, value, action } = cc;
|
||||
const fieldValue = formData[field];
|
||||
|
||||
let conditionMet = false;
|
||||
switch (operator) {
|
||||
case "=":
|
||||
case "==":
|
||||
case "===":
|
||||
conditionMet = fieldValue === value;
|
||||
break;
|
||||
case "!=":
|
||||
case "!==":
|
||||
conditionMet = fieldValue !== value;
|
||||
break;
|
||||
default:
|
||||
conditionMet = fieldValue === value;
|
||||
}
|
||||
|
||||
return (action === "show" && !conditionMet) || (action === "hide" && conditionMet);
|
||||
};
|
||||
|
||||
// 표시되는 컴포넌트들의 y 범위 수집
|
||||
const visibleRanges: { y: number; bottom: number }[] = [];
|
||||
screenData.components.forEach((comp: any) => {
|
||||
if (!isComponentHidden(comp)) {
|
||||
const y = parseFloat(comp.position?.y?.toString() || "0");
|
||||
const height = parseFloat(comp.size?.height?.toString() || "0");
|
||||
visibleRanges.push({ y, bottom: y + height });
|
||||
}
|
||||
});
|
||||
|
||||
// 숨겨지는 컴포넌트의 "실제 빈 공간" 계산 (표시되는 컴포넌트와 겹치지 않는 영역)
|
||||
const getActualGap = (hiddenY: number, hiddenBottom: number): number => {
|
||||
// 숨겨지는 영역 중 표시되는 컴포넌트와 겹치는 부분을 제외
|
||||
let gapStart = hiddenY;
|
||||
let gapEnd = hiddenBottom;
|
||||
|
||||
for (const visible of visibleRanges) {
|
||||
// 겹치는 영역 확인
|
||||
if (visible.y < gapEnd && visible.bottom > gapStart) {
|
||||
// 겹치는 부분을 제외
|
||||
if (visible.y <= gapStart && visible.bottom >= gapEnd) {
|
||||
// 완전히 덮힘 - 빈 공간 없음
|
||||
return 0;
|
||||
} else if (visible.y <= gapStart) {
|
||||
// 위쪽이 덮힘
|
||||
gapStart = visible.bottom;
|
||||
} else if (visible.bottom >= gapEnd) {
|
||||
// 아래쪽이 덮힘
|
||||
gapEnd = visible.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, gapEnd - gapStart);
|
||||
};
|
||||
|
||||
// 숨겨지는 컴포넌트들의 실제 빈 공간 수집
|
||||
const hiddenGaps: { bottom: number; gap: number }[] = [];
|
||||
screenData.components.forEach((comp: any) => {
|
||||
if (isComponentHidden(comp)) {
|
||||
const y = parseFloat(comp.position?.y?.toString() || "0");
|
||||
const height = parseFloat(comp.size?.height?.toString() || "0");
|
||||
const bottom = y + height;
|
||||
const gap = getActualGap(y, bottom);
|
||||
if (gap > 0) {
|
||||
hiddenGaps.push({ bottom, gap });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// bottom 기준으로 정렬 및 중복 제거 (같은 bottom은 가장 큰 gap만 유지)
|
||||
const mergedGaps = new Map<number, number>();
|
||||
hiddenGaps.forEach(({ bottom, gap }) => {
|
||||
const existing = mergedGaps.get(bottom) || 0;
|
||||
mergedGaps.set(bottom, Math.max(existing, gap));
|
||||
});
|
||||
|
||||
const sortedGaps = Array.from(mergedGaps.entries())
|
||||
.map(([bottom, gap]) => ({ bottom, gap }))
|
||||
.sort((a, b) => a.bottom - b.bottom);
|
||||
|
||||
// 각 컴포넌트의 y 조정값 계산 함수
|
||||
const getYOffset = (compY: number, compId?: string) => {
|
||||
let offset = 0;
|
||||
for (const { bottom, gap } of sortedGaps) {
|
||||
// 컴포넌트가 숨겨진 영역 아래에 있으면 그 빈 공간만큼 위로 이동
|
||||
if (compY > bottom) {
|
||||
offset += gap;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
};
|
||||
|
||||
return screenData.components.map((component: any) => {
|
||||
// 숨겨지는 컴포넌트는 렌더링 안함
|
||||
if (isComponentHidden(component)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
|
||||
// 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동)
|
||||
const compY = parseFloat(component.position?.y?.toString() || "0");
|
||||
const yAdjustment = getYOffset(compY, component.id);
|
||||
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent =
|
||||
offsetX === 0 && offsetY === 0
|
||||
? component
|
||||
: {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
|
|
@ -652,7 +808,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
companyCode={user?.companyCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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,31 +1200,36 @@ 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">
|
||||
{/* 로딩 오버레이 */}
|
||||
{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>
|
||||
<>
|
||||
{/* 로딩 오버레이 - 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">{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>
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
</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" />
|
||||
그룹 복제
|
||||
</DialogTitle>
|
||||
|
|
@ -1426,15 +1509,22 @@ 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) => (
|
||||
<option key={company.companyCode} value={company.companyCode}>
|
||||
{company.companyName} ({company.companyCode})
|
||||
</option>
|
||||
))}
|
||||
{companies
|
||||
.filter((company) => company.companyCode !== sourceGroup?.company_code)
|
||||
.map((company) => (
|
||||
<option key={company.companyCode} value={company.companyCode}>
|
||||
{company.companyName} ({company.companyCode})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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,14 +1620,25 @@ export default function CopyScreenModal({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 복제 모드 렌더링
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<>
|
||||
{/* 로딩 오버레이 - 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>
|
||||
<DialogTitle className="text-base sm:text-lg">화면 복제</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
"{sourceScreen?.screenName}" 화면을 복제합니다.
|
||||
|
|
@ -1634,13 +1735,20 @@ export default function CopyScreenModal({
|
|||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.companyCode} value={company.companyCode}>
|
||||
{company.companyName}
|
||||
</SelectItem>
|
||||
))}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
const newCode = allocateResult.data.generatedCode;
|
||||
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]} → ${newCode}`);
|
||||
dataToSave[fieldName] = newCode;
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
|
||||
hasAllocationFailure = true;
|
||||
failedFields.push(fieldName);
|
||||
|
||||
try {
|
||||
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
return { fieldName, success: true, code: allocateResult.data.generatedCode };
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
|
||||
}
|
||||
} catch (allocateError) {
|
||||
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
|
||||
}
|
||||
} catch (allocateError) {
|
||||
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
|
||||
hasAllocationFailure = true;
|
||||
failedFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const allocationResults = await Promise.all(allocationPromises);
|
||||
|
||||
// 결과 처리
|
||||
const failedFields: string[] = [];
|
||||
for (const result of allocationResults) {
|
||||
if (result.success && result.code) {
|
||||
console.log(`✅ [EditModal] ${result.fieldName} 새 코드 할당: ${result.code}`);
|
||||
dataToSave[result.fieldName] = result.code;
|
||||
} else if (!result.hasExistingValue) {
|
||||
failedFields.push(result.fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
// 채번 규칙 할당 실패 시 저장 중단
|
||||
if (hasAllocationFailure) {
|
||||
if (failedFields.length > 0) {
|
||||
const fieldNames = failedFields.join(", ");
|
||||
toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`);
|
||||
console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`);
|
||||
|
|
@ -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 {
|
||||
console.log(`🔄 [EditModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
// 리피터 데이터인지 확인 (객체 배열이고 _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");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// 현재 행의 기본키 값 가져오기
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -335,13 +335,42 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
// 동적 대화형 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
// 조건부 표시 평가
|
||||
// 조건부 표시 평가 (기존 conditional 시스템)
|
||||
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
|
||||
|
||||
// 조건에 따라 숨김 처리
|
||||
if (!conditionalResult.visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 🆕 conditionalConfig 시스템 체크 (V2 레이아웃용)
|
||||
const conditionalConfig = (comp as any).componentConfig?.conditionalConfig;
|
||||
if (conditionalConfig?.enabled && formData) {
|
||||
const { field, operator, value, action } = conditionalConfig;
|
||||
const fieldValue = formData[field];
|
||||
|
||||
let conditionMet = false;
|
||||
switch (operator) {
|
||||
case "=":
|
||||
case "==":
|
||||
case "===":
|
||||
conditionMet = fieldValue === value;
|
||||
break;
|
||||
case "!=":
|
||||
case "!==":
|
||||
conditionMet = fieldValue !== value;
|
||||
break;
|
||||
default:
|
||||
conditionMet = fieldValue === value;
|
||||
}
|
||||
|
||||
if (action === "show" && !conditionMet) {
|
||||
return null;
|
||||
}
|
||||
if (action === "hide" && conditionMet) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 테이블 컴포넌트 처리
|
||||
if (isDataTableComponent(comp)) {
|
||||
|
|
@ -533,11 +562,31 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
try {
|
||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
||||
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
||||
const masterFormData: Record<string, any> = {};
|
||||
|
||||
// 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함)
|
||||
const mediaColumnNames = new Set(
|
||||
allComponents
|
||||
.filter((c: any) =>
|
||||
c.componentType === "v2-media" ||
|
||||
c.componentType === "file-upload" ||
|
||||
c.url?.includes("v2-media") ||
|
||||
c.url?.includes("file-upload")
|
||||
)
|
||||
.map((c: any) => c.columnName || c.componentConfig?.columnName)
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
// 배열 데이터는 리피터 데이터이므로 제외
|
||||
if (!Array.isArray(value)) {
|
||||
// 배열이 아닌 값은 그대로 저장
|
||||
masterFormData[key] = value;
|
||||
} else if (mediaColumnNames.has(key)) {
|
||||
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
|
||||
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
|
||||
masterFormData[key] = value.length > 0 ? value[0] : null;
|
||||
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
|
||||
} else {
|
||||
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
}
|
||||
|
|
@ -1018,22 +1067,35 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
|
||||
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
||||
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
|
||||
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
|
||||
const hasVisibleLabel = isV2InputComponent &&
|
||||
style?.labelDisplay !== false &&
|
||||
(style?.labelText || (component as any).label);
|
||||
|
||||
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
|
||||
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0,
|
||||
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
|
||||
zIndex: position?.z || 1,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
|
||||
overflow: labelOffset > 0 ? "visible" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
||||
{/* 위젯 렌더링 */}
|
||||
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
||||
{renderInteractiveWidget(component)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 />
|
||||
{deleteScreensWithGroup
|
||||
? <span className="text-destructive font-medium">그룹에 속한 화면들도 함께 삭제됩니다.</span>
|
||||
: "그룹에 속한 화면들은 미분류로 이동됩니다."
|
||||
}
|
||||
<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">
|
||||
"{deletingGroup?.group_name}" 그룹을 정말 삭제하시겠습니까?
|
||||
</p>
|
||||
<p className="mt-2 text-destructive/80">
|
||||
{deleteScreensWithGroup
|
||||
? "⚠️ 그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
: "그룹에 속한 화면들은 미분류로 이동됩니다."
|
||||
}
|
||||
</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">
|
||||
"{deletingScreen?.screenName}" 화면을 정말 삭제하시겠습니까?
|
||||
</p>
|
||||
<p className="mt-2 text-destructive/80">
|
||||
⚠️ 화면과 연결된 플로우, 레이아웃 데이터가 모두 삭제됩니다. 삭제된 화면은 휴지통으로 이동됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -171,6 +184,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(
|
||||
|
|
@ -267,24 +284,26 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
const flows = flowsRes.success ? flowsRes.data || [] : [];
|
||||
const relations = relationsRes.success ? relationsRes.data || [] : [];
|
||||
|
||||
// 데이터 흐름에서 연결된 화면들 추가
|
||||
flows.forEach((flow: any) => {
|
||||
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
|
||||
const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
|
||||
if (!exists) {
|
||||
screenList.push({
|
||||
screenId: flow.target_screen_id,
|
||||
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
|
||||
screenCode: "",
|
||||
tableName: "",
|
||||
companyCode: screen.companyCode,
|
||||
isActive: "Y",
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
} as ScreenDefinition);
|
||||
// 데이터 흐름에서 연결된 화면들 추가 (개별 화면 모드에서만 - 그룹 모드에서는 그룹 내 화면만 표시)
|
||||
if (!selectedGroup && screen) {
|
||||
flows.forEach((flow: any) => {
|
||||
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
|
||||
const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
|
||||
if (!exists) {
|
||||
screenList.push({
|
||||
screenId: flow.target_screen_id,
|
||||
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
|
||||
screenCode: "",
|
||||
tableName: "",
|
||||
companyCode: screen.companyCode,
|
||||
isActive: "Y",
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
} as ScreenDefinition);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 화면 레이아웃 요약 정보 로드
|
||||
const screenIds = screenList.map((s) => s.screenId);
|
||||
|
|
@ -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)) {
|
||||
subTableSet.add(subTable.tableName);
|
||||
subTableNames.push(subTable.tableName);
|
||||
// mainTableSet에 있으면 서브 테이블에서 제외 (우선순위: 메인 > 서브)
|
||||
if (mainTableSet.has(subTable.tableName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 조인으로만 연결된 테이블 → 서브 테이블
|
||||
subTableSet.add(subTable.tableName);
|
||||
subTableNames.push(subTable.tableName);
|
||||
});
|
||||
|
||||
if (subTableNames.length > 0) {
|
||||
|
|
@ -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에서 포커스 상태에 따라 동적으로 설정
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,34 +528,30 @@ 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
|
||||
selectedScreen={{
|
||||
screenId: currentScreenId,
|
||||
screenCode: `screen_${currentScreenId}`,
|
||||
screenName: currentScreenName,
|
||||
tableName: currentMainTable || "",
|
||||
companyCode: companyCode || "*",
|
||||
description: "",
|
||||
isActive: "Y" as const,
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
}}
|
||||
onBackToList={async () => {
|
||||
setShowDesignerModal(false);
|
||||
// 디자이너에서 저장 후 모달 닫으면 데이터 새로고침
|
||||
await loadData();
|
||||
// 데이터 로드 완료 후 iframe 갱신
|
||||
setIframeKey(prev => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
|
||||
{/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
|
||||
{showDesignerModal && (
|
||||
<div className="bg-background fixed inset-0 z-[1000] flex flex-col">
|
||||
<ScreenDesigner
|
||||
selectedScreen={{
|
||||
screenId: currentScreenId,
|
||||
screenCode: `screen_${currentScreenId}`,
|
||||
screenName: currentScreenName,
|
||||
tableName: currentMainTable || "",
|
||||
companyCode: companyCode || "*",
|
||||
description: "",
|
||||
isActive: "Y" as const,
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
}}
|
||||
onBackToList={async () => {
|
||||
setShowDesignerModal(false);
|
||||
await loadData();
|
||||
setIframeKey(prev => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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: "" });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, inputType } : col)));
|
||||
|
||||
// console.log(`컬럼 ${columnName}의 입력 타입을 ${inputType}로 변경했습니다.`);
|
||||
} catch (error) {
|
||||
// console.error("입력 타입 변경 실패:", error);
|
||||
alert("입력 타입 설정에 실패했습니다. 다시 시도해주세요.");
|
||||
}
|
||||
// 입력 타입 변경 (로컬 상태만 - DB에 저장하지 않음)
|
||||
const handleInputTypeChange = (columnName: string, inputType: "direct" | "auto") => {
|
||||
// 로컬 상태만 업데이트 (DB에는 저장하지 않음 - inputType은 화면 렌더링용)
|
||||
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, inputType } : col)));
|
||||
};
|
||||
|
||||
const filteredTables = tables.filter((table) => table.displayName.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
|
|
|||
|
|
@ -51,13 +51,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}) => {
|
||||
// 🔧 component가 없는 경우 방어 처리
|
||||
if (!component) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
컴포넌트 정보를 불러올 수 없습니다.
|
||||
</div>
|
||||
);
|
||||
return <div className="text-muted-foreground p-4 text-sm">컴포넌트 정보를 불러올 수 없습니다.</div>;
|
||||
}
|
||||
|
||||
|
||||
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
||||
const config = component.componentConfig || {};
|
||||
const currentAction = component.componentConfig?.action || {};
|
||||
|
|
@ -122,7 +118,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null);
|
||||
const [modalActionSourceColumns, setModalActionSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalActionTargetColumns, setModalActionTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<Array<{ sourceField: string; targetField: string }>>([]);
|
||||
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<
|
||||
Array<{ sourceField: string; targetField: string }>
|
||||
>([]);
|
||||
const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
|
|
@ -353,7 +351,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
useEffect(() => {
|
||||
const actionType = config.action?.type;
|
||||
if (actionType !== "modal") return;
|
||||
|
||||
|
||||
const autoDetect = config.action?.autoDetectDataSource;
|
||||
if (!autoDetect) {
|
||||
// 데이터 전달이 비활성화되면 상태 초기화
|
||||
|
|
@ -363,19 +361,19 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
setModalActionTargetColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const targetScreenId = config.action?.targetScreenId;
|
||||
if (!targetScreenId) return;
|
||||
|
||||
|
||||
const loadModalActionMappingData = async () => {
|
||||
// 1. 소스 테이블 감지 (현재 화면)
|
||||
let sourceTableName: string | null = currentTableName || null;
|
||||
|
||||
|
||||
// allComponents에서 분할패널/테이블리스트/통합목록 감지
|
||||
for (const comp of allComponents) {
|
||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||
const compConfig = (comp as any).componentConfig || {};
|
||||
|
||||
|
||||
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
||||
sourceTableName = compConfig.leftPanel?.tableName || compConfig.tableName || null;
|
||||
if (sourceTableName) break;
|
||||
|
|
@ -389,9 +387,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
if (sourceTableName) break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setModalActionSourceTable(sourceTableName);
|
||||
|
||||
|
||||
// 2. 대상 화면의 테이블 조회
|
||||
let targetTableName: string | null = null;
|
||||
try {
|
||||
|
|
@ -405,9 +403,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
} catch (error) {
|
||||
console.error("대상 화면 정보 로드 실패:", error);
|
||||
}
|
||||
|
||||
|
||||
setModalActionTargetTable(targetTableName);
|
||||
|
||||
|
||||
// 3. 소스 테이블 컬럼 로드
|
||||
if (sourceTableName) {
|
||||
try {
|
||||
|
|
@ -416,7 +414,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
|
|
@ -429,7 +427,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. 대상 테이블 컬럼 로드
|
||||
if (targetTableName) {
|
||||
try {
|
||||
|
|
@ -438,7 +436,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
|
|
@ -451,7 +449,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
console.error("대상 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 5. 기존 필드 매핑 로드 또는 자동 매핑 생성
|
||||
const existingMappings = config.action?.fieldMappings || [];
|
||||
if (existingMappings.length > 0) {
|
||||
|
|
@ -461,10 +459,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
setModalActionFieldMappings([]); // 빈 배열 = 자동 매핑
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadModalActionMappingData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
config.action?.type,
|
||||
config.action?.autoDetectDataSource,
|
||||
config.action?.targetScreenId,
|
||||
currentTableName,
|
||||
allComponents,
|
||||
]);
|
||||
|
||||
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
|
||||
useEffect(() => {
|
||||
|
|
@ -818,21 +822,26 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="transferData">데이터 전달</SelectItem>
|
||||
|
||||
|
||||
{/* 엑셀 관련 */}
|
||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
|
||||
|
||||
{/* 고급 기능 */}
|
||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
|
||||
|
||||
{/* 특수 기능 (필요 시 사용) */}
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||
|
||||
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, 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,171 +1016,210 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 테이블이 같으면 자동 매핑 안내 */}
|
||||
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && (
|
||||
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
|
||||
동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다.
|
||||
</div>
|
||||
)}
|
||||
{modalActionSourceTable &&
|
||||
modalActionTargetTable &&
|
||||
modalActionSourceTable === modalActionTargetTable && (
|
||||
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
|
||||
동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블이 다르면 필드 매핑 UI 표시 */}
|
||||
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || []), { sourceField: "", targetField: "" }];
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{/* 소스 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingSourceOpen[index] || false}
|
||||
onOpenChange={(open) => setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.sourceField
|
||||
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingSourceSearch[index] || ""}
|
||||
onValueChange={(val) => setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionSourceColumns
|
||||
.filter((col) =>
|
||||
col.name.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
|
||||
col.label.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase())
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
|
||||
newMappings[index] = { ...newMappings[index], sourceField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", mapping.sourceField === col.name ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
|
||||
{/* 대상 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingTargetOpen[index] || false}
|
||||
onOpenChange={(open) => setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.targetField
|
||||
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||
: "대상 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingTargetSearch[index] || ""}
|
||||
onValueChange={(val) => setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionTargetColumns
|
||||
.filter((col) =>
|
||||
col.name.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
|
||||
col.label.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase())
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
|
||||
newMappings[index] = { ...newMappings[index], targetField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", mapping.targetField === col.name ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{modalActionSourceTable &&
|
||||
modalActionTargetTable &&
|
||||
modalActionSourceTable !== modalActionTargetTable && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter((_: any, i: number) => i !== index);
|
||||
const newMappings = [
|
||||
...(component.componentConfig?.action?.fieldMappings || []),
|
||||
{ sourceField: "", targetField: "" },
|
||||
];
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{/* 소스 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingSourceOpen[index] || false}
|
||||
onOpenChange={(open) =>
|
||||
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.sourceField
|
||||
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label ||
|
||||
mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingSourceSearch[index] || ""}
|
||||
onValueChange={(val) =>
|
||||
setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionSourceColumns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.name
|
||||
.toLowerCase()
|
||||
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
|
||||
col.label
|
||||
.toLowerCase()
|
||||
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()),
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [
|
||||
...(component.componentConfig?.action?.fieldMappings || []),
|
||||
];
|
||||
newMappings[index] = { ...newMappings[index], sourceField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.sourceField === col.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
{/* 대상 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingTargetOpen[index] || false}
|
||||
onOpenChange={(open) =>
|
||||
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.targetField
|
||||
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label ||
|
||||
mapping.targetField
|
||||
: "대상 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingTargetSearch[index] || ""}
|
||||
onValueChange={(val) =>
|
||||
setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionTargetColumns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.name
|
||||
.toLowerCase()
|
||||
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
|
||||
col.label
|
||||
.toLowerCase()
|
||||
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()),
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [
|
||||
...(component.componentConfig?.action?.fieldMappings || []),
|
||||
];
|
||||
newMappings[index] = { ...newMappings[index], targetField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.targetField === col.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0"
|
||||
onClick={() => {
|
||||
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter(
|
||||
(_: any, i: number) => i !== index,
|
||||
);
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 전용 (일반 화면에서 불필요)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -65,7 +65,34 @@ export function TabsWidget({
|
|||
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
||||
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
|
||||
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
||||
|
||||
|
||||
// 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트
|
||||
useEffect(() => {
|
||||
// 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택
|
||||
const validTabs = (tabs as ExtendedTabItem[]).filter((tab) => !tab.disabled);
|
||||
const firstValidTabId = validTabs[0]?.id;
|
||||
|
||||
if (firstValidTabId) {
|
||||
// 선택된 탭이 없거나 유효하지 않으면 첫 번째 탭으로 설정
|
||||
setSelectedTab((currentSelected) => {
|
||||
if (!currentSelected || !validTabs.some((t) => t.id === currentSelected)) {
|
||||
return firstValidTabId;
|
||||
}
|
||||
return currentSelected;
|
||||
});
|
||||
|
||||
// 첫 번째 탭이 mountedTabs에 없으면 추가
|
||||
setMountedTabs((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
// 첫 번째 탭 추가
|
||||
if (firstValidTabId && !newSet.has(firstValidTabId)) {
|
||||
newSet.add(firstValidTabId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}, [tabs]); // tabs가 변경될 때마다 실행
|
||||
|
||||
// screenId 기반 화면 로드 상태
|
||||
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -82,23 +109,28 @@ export function TabsWidget({
|
|||
for (const tab of visibleTabs) {
|
||||
const extTab = tab as ExtendedTabItem;
|
||||
// screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드
|
||||
if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) {
|
||||
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true }));
|
||||
if (
|
||||
extTab.screenId &&
|
||||
!screenLayouts[tab.id] &&
|
||||
!screenLoadingStates[tab.id] &&
|
||||
(!extTab.components || extTab.components.length === 0)
|
||||
) {
|
||||
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
|
||||
try {
|
||||
const layoutData = await screenApi.getLayout(extTab.screenId);
|
||||
if (layoutData && layoutData.components) {
|
||||
setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components }));
|
||||
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
|
||||
setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
||||
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
||||
} finally {
|
||||
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false }));
|
||||
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadScreenLayouts();
|
||||
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
||||
|
||||
|
|
@ -153,11 +185,7 @@ export function TabsWidget({
|
|||
const getTabsListClass = () => {
|
||||
const baseClass = orientation === "vertical" ? "flex-col" : "";
|
||||
const variantClass =
|
||||
variant === "pills"
|
||||
? "bg-muted p-1 rounded-lg"
|
||||
: variant === "underline"
|
||||
? "border-b"
|
||||
: "bg-muted p-1";
|
||||
variant === "pills" ? "bg-muted p-1 rounded-lg" : variant === "underline" ? "border-b" : "bg-muted p-1";
|
||||
return `${baseClass} ${variantClass}`;
|
||||
};
|
||||
|
||||
|
|
@ -165,47 +193,47 @@ export function TabsWidget({
|
|||
const renderTabContent = (tab: ExtendedTabItem) => {
|
||||
const extTab = tab as ExtendedTabItem;
|
||||
const inlineComponents = tab.components || [];
|
||||
|
||||
|
||||
// 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식
|
||||
if (extTab.screenId && inlineComponents.length === 0) {
|
||||
// 로딩 중
|
||||
if (screenLoadingStates[tab.id]) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-2 text-muted-foreground">화면을 불러오는 중...</span>
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2">화면을 불러오는 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 에러 발생
|
||||
if (screenErrors[tab.id]) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-destructive/50 bg-destructive/5">
|
||||
<div className="border-destructive/50 bg-destructive/5 flex h-full w-full items-center justify-center rounded border-2 border-dashed">
|
||||
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 화면 레이아웃이 로드된 경우
|
||||
const loadedComponents = screenLayouts[tab.id];
|
||||
if (loadedComponents && loadedComponents.length > 0) {
|
||||
return renderScreenComponents(loadedComponents);
|
||||
}
|
||||
|
||||
|
||||
// 아직 로드되지 않은 경우
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식
|
||||
if (inlineComponents.length > 0) {
|
||||
return renderInlineComponents(tab, inlineComponents);
|
||||
}
|
||||
|
||||
|
||||
// 3. 둘 다 없는 경우
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
|
|
@ -219,22 +247,17 @@ export function TabsWidget({
|
|||
// screenId로 로드한 화면 컴포넌트 렌더링
|
||||
const renderScreenComponents = (components: ComponentData[]) => {
|
||||
// InteractiveScreenViewerDynamic 동적 로드
|
||||
const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
|
||||
|
||||
const InteractiveScreenViewerDynamic =
|
||||
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
|
||||
|
||||
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||
const maxBottom = Math.max(
|
||||
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||
300
|
||||
);
|
||||
const maxRight = Math.max(
|
||||
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
||||
400
|
||||
);
|
||||
|
||||
const maxBottom = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 300);
|
||||
const maxRight = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400);
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto"
|
||||
style={{
|
||||
style={{
|
||||
minHeight: maxBottom + 20,
|
||||
minWidth: maxRight + 20,
|
||||
}}
|
||||
|
|
@ -268,17 +291,17 @@ export function TabsWidget({
|
|||
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||
const maxBottom = Math.max(
|
||||
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||
300 // 최소 높이
|
||||
300, // 최소 높이
|
||||
);
|
||||
const maxRight = Math.max(
|
||||
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
||||
400 // 최소 너비
|
||||
400, // 최소 너비
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
minHeight: maxBottom + 20,
|
||||
minWidth: maxRight + 20,
|
||||
}}
|
||||
|
|
@ -292,7 +315,7 @@ export function TabsWidget({
|
|||
className={cn(
|
||||
"absolute",
|
||||
isDesignMode && "cursor-move",
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2"
|
||||
isDesignMode && isSelected && "ring-primary ring-2 ring-offset-2",
|
||||
)}
|
||||
style={{
|
||||
left: comp.position?.x || 0,
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> = ({
|
||||
component,
|
||||
value,
|
||||
onChange,
|
||||
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,31 +132,27 @@ 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
|
||||
src={imageUrl}
|
||||
alt="업로드된 이미지"
|
||||
className="h-full w-full object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="업로드된 이미지"
|
||||
className="h-full w-full object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 호버 시 제거 버튼 */}
|
||||
{!readonly && !isDesignMode && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleRemove}
|
||||
className="gap-2"
|
||||
>
|
||||
<Button size="sm" variant="destructive" onClick={handleRemove} className="gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
이미지 제거
|
||||
</Button>
|
||||
|
|
@ -154,9 +162,9 @@ 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 ${
|
||||
isDesignMode
|
||||
? "cursor-default border-gray-200 bg-gray-50"
|
||||
className={`group relative flex w-full flex-1 flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
|
||||
isDesignMode
|
||||
? "cursor-default border-gray-200 bg-gray-50"
|
||||
: "cursor-pointer border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 hover:shadow-md"
|
||||
}`}
|
||||
onClick={handleFileSelect}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue