Compare commits
No commits in common. "dd1ddd6418fbd51cdde7808725c59be8f02e30a0" and "444791189272fe6e4ff0a9a793ca7232cfcbec62" have entirely different histories.
dd1ddd6418
...
4447911892
|
|
@ -10,43 +10,6 @@ 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";
|
||||
|
|
@ -101,7 +64,6 @@ 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"; // 권한 그룹 관리
|
||||
|
|
@ -284,7 +246,6 @@ 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,8 +1461,11 @@ async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
|||
[menuObjid]
|
||||
);
|
||||
|
||||
// 4. numbering_rules: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)
|
||||
// 새 스키마: table_name + column_name + company_code 기반
|
||||
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 5. rel_menu_auth에서 관련 권한 삭제
|
||||
await query(
|
||||
|
|
|
|||
|
|
@ -1,223 +0,0 @@
|
|||
/**
|
||||
* 스케줄 자동 생성 컨트롤러
|
||||
*
|
||||
* 스케줄 미리보기, 적용, 조회 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 || "스케줄 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -344,65 +344,13 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
childGroupIds: groupIdsToDelete
|
||||
});
|
||||
|
||||
// 2. 삭제될 그룹에 연결된 메뉴 정리
|
||||
// 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리
|
||||
if (groupIdsToDelete.length > 0) {
|
||||
// 2-1. 삭제할 메뉴 objid 수집
|
||||
const menusToDelete = await client.query(`
|
||||
SELECT objid FROM menu_info
|
||||
await client.query(`
|
||||
UPDATE menu_info
|
||||
SET screen_group_id = NULL
|
||||
WHERE screen_group_id = ANY($1::int[])
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
`, [groupIdsToDelete]);
|
||||
}
|
||||
|
||||
// 3. screen_groups 삭제 (해당 그룹만 - 하위 그룹은 프론트엔드에서 순차 삭제)
|
||||
|
|
|
|||
|
|
@ -557,16 +557,7 @@ export async function updateColumnInputType(
|
|||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
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";
|
||||
}
|
||||
const { inputType, detailSettings } = req.body;
|
||||
|
||||
// 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회)
|
||||
let companyCode = req.user?.companyCode;
|
||||
|
|
@ -1369,17 +1360,8 @@ export async function updateColumnWebType(
|
|||
`레거시 API 사용: updateColumnWebType → updateColumnInputType 사용 권장`
|
||||
);
|
||||
|
||||
// 🔥 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}`
|
||||
);
|
||||
// webType을 inputType으로 변환
|
||||
const convertedInputType = inputType || webType || "text";
|
||||
|
||||
// 새로운 메서드 호출
|
||||
req.body = { inputType: convertedInputType, detailSettings };
|
||||
|
|
|
|||
|
|
@ -81,26 +81,8 @@ 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}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* 스케줄 자동 생성 라우터
|
||||
*/
|
||||
|
||||
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;
|
||||
|
|
@ -89,7 +89,7 @@ class CategoryTreeService {
|
|||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM category_values
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND table_name = $2
|
||||
AND column_name = $3
|
||||
|
|
@ -142,7 +142,7 @@ class CategoryTreeService {
|
|||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM category_values
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND table_name = $2
|
||||
AND column_name = $3
|
||||
|
|
@ -184,7 +184,7 @@ class CategoryTreeService {
|
|||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM category_values
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
||||
`;
|
||||
|
||||
|
|
@ -221,7 +221,7 @@ class CategoryTreeService {
|
|||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO category_values (
|
||||
INSERT INTO category_values_test (
|
||||
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 +334,7 @@ class CategoryTreeService {
|
|||
}
|
||||
|
||||
const query = `
|
||||
UPDATE category_values
|
||||
UPDATE category_values_test
|
||||
SET
|
||||
value_code = COALESCE($3, value_code),
|
||||
value_label = COALESCE($4, value_label),
|
||||
|
|
@ -415,11 +415,11 @@ class CategoryTreeService {
|
|||
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
|
||||
const query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT value_id FROM category_values
|
||||
SELECT value_id FROM category_values_test
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
UNION ALL
|
||||
SELECT cv.value_id
|
||||
FROM category_values cv
|
||||
FROM category_values_test 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 +452,7 @@ class CategoryTreeService {
|
|||
|
||||
for (const id of reversedIds) {
|
||||
await pool.query(
|
||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
`DELETE FROM category_values_test WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
[companyCode, id]
|
||||
);
|
||||
}
|
||||
|
|
@ -479,7 +479,7 @@ class CategoryTreeService {
|
|||
|
||||
const query = `
|
||||
SELECT value_id, value_label
|
||||
FROM category_values
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2
|
||||
`;
|
||||
|
||||
|
|
@ -488,7 +488,7 @@ class CategoryTreeService {
|
|||
for (const child of result.rows) {
|
||||
const newPath = `${parentPath}/${child.value_label}`;
|
||||
|
||||
await pool.query(`UPDATE category_values SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
|
||||
await pool.query(`UPDATE category_values_test SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
|
||||
newPath,
|
||||
child.value_id,
|
||||
]);
|
||||
|
|
@ -550,7 +550,7 @@ class CategoryTreeService {
|
|||
|
||||
/**
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
* category_values 테이블에서 고유한 table_name, column_name 조합을 조회
|
||||
* category_values_test 테이블에서 고유한 table_name, column_name 조합을 조회
|
||||
* 라벨 정보도 함께 반환
|
||||
*/
|
||||
async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> {
|
||||
|
|
@ -564,7 +564,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 cv
|
||||
FROM category_values_test 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,10 +851,47 @@ export class MenuCopyService {
|
|||
]);
|
||||
logger.info(` ✅ 메뉴 권한 삭제 완료`);
|
||||
|
||||
// 5-4. 채번 규칙 처리 (새 스키마에서는 menu_objid 없음 - 스킵)
|
||||
// 새 numbering_rules 스키마: table_name + column_name + company_code 기반
|
||||
// 메뉴와 직접 연결되지 않으므로 메뉴 삭제 시 처리 불필요
|
||||
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-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가)
|
||||
// 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제
|
||||
|
|
@ -2553,9 +2590,8 @@ export class MenuCopyService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 복사 (새 스키마: table_name + column_name 기반)
|
||||
* 프론트엔드에서 /numbering-rules/copy-for-company API를 별도 호출하므로
|
||||
* 이 함수는 ruleIdMap 생성만 담당 (실제 복제는 numberingRuleService에서 처리)
|
||||
* 채번 규칙 복사 (최적화: 배치 조회/삽입)
|
||||
* 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨
|
||||
*/
|
||||
private async copyNumberingRulesWithMap(
|
||||
menuObjids: number[],
|
||||
|
|
@ -2564,46 +2600,221 @@ export class MenuCopyService {
|
|||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<{ copiedCount: number; ruleIdMap: Map<string, string> }> {
|
||||
let copiedCount = 0;
|
||||
const ruleIdMap = new Map<string, string>();
|
||||
|
||||
// 새 스키마에서는 채번규칙이 메뉴와 직접 연결되지 않음
|
||||
// 프론트엔드에서 /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 (menuObjids.length === 0) {
|
||||
return { copiedCount, ruleIdMap };
|
||||
}
|
||||
|
||||
// === 최적화: 배치 조회 ===
|
||||
// 1. 모든 원본 채번 규칙 한 번에 조회
|
||||
const allRulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`,
|
||||
[menuObjids]
|
||||
);
|
||||
|
||||
// 대상 회사의 채번규칙 조회 (이름 기준 매핑)
|
||||
const targetRulesResult = await client.query(
|
||||
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
|
||||
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`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
|
||||
const targetRulesByName = new Map(
|
||||
targetRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id])
|
||||
const existingRuleIds = new Set(
|
||||
existingRulesResult.rows.map((r) => r.rule_id)
|
||||
);
|
||||
|
||||
// 이름 기준으로 매핑 생성
|
||||
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}`);
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(` 📋 채번규칙 매핑 완료: ${ruleIdMap.size}개`);
|
||||
|
||||
// 실제 복제는 프론트엔드에서 별도 API 호출로 처리됨
|
||||
return { copiedCount: 0, ruleIdMap };
|
||||
}
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 매핑 + 값 복사 (최적화: 배치 조회)
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
|
|
@ -88,8 +88,8 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
|
|
@ -199,13 +199,13 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE 1=1
|
||||
WHERE scope_type = 'global'
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
|
|
@ -222,13 +222,14 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE company_code = $1 ORDER BY created_at DESC
|
||||
WHERE company_code = $1 AND scope_type = 'global'
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
|
@ -283,7 +284,7 @@ class NumberingRuleService {
|
|||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 규칙 조회
|
||||
// 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -295,18 +296,28 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
ORDER BY created_at DESC
|
||||
WHERE
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1))
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||
WHEN scope_type = 'table' THEN 2
|
||||
WHEN scope_type = 'global' THEN 3
|
||||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
logger.info("최고 관리자: 전체 채번 규칙 조회");
|
||||
params = [menuAndChildObjids];
|
||||
logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회
|
||||
// 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -318,17 +329,28 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
WHERE company_code = $1
|
||||
ORDER BY created_at DESC
|
||||
AND (
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2))
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($2)) THEN 1
|
||||
WHEN scope_type = 'table' THEN 2
|
||||
WHEN scope_type = 'global' THEN 3
|
||||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [companyCode];
|
||||
logger.info("회사별 채번 규칙 조회", { companyCode });
|
||||
params = [companyCode, menuAndChildObjids];
|
||||
logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
|
||||
}
|
||||
|
||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||
|
|
@ -453,8 +475,8 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
|
|
@ -478,8 +500,8 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
|
|
@ -555,8 +577,8 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
|
|
@ -577,8 +599,8 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
|
|
@ -654,7 +676,7 @@ class NumberingRuleService {
|
|||
INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
category_column, category_value_id, created_by
|
||||
menu_objid, scope_type, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -666,8 +688,8 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
|
|
@ -683,8 +705,8 @@ class NumberingRuleService {
|
|||
config.tableName || null,
|
||||
config.columnName || null,
|
||||
companyCode,
|
||||
config.categoryColumn || null,
|
||||
config.categoryValueId || null,
|
||||
config.menuObjid || null,
|
||||
config.scopeType || "global",
|
||||
userId,
|
||||
]);
|
||||
|
||||
|
|
@ -756,8 +778,8 @@ class NumberingRuleService {
|
|||
reset_period = COALESCE($4, reset_period),
|
||||
table_name = COALESCE($5, table_name),
|
||||
column_name = COALESCE($6, column_name),
|
||||
category_column = COALESCE($7, category_column),
|
||||
category_value_id = COALESCE($8, category_value_id),
|
||||
menu_objid = COALESCE($7, menu_objid),
|
||||
scope_type = COALESCE($8, scope_type),
|
||||
updated_at = NOW()
|
||||
WHERE rule_id = $9 AND company_code = $10
|
||||
RETURNING
|
||||
|
|
@ -770,8 +792,8 @@ class NumberingRuleService {
|
|||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
company_code AS "companyCode",
|
||||
category_column AS "categoryColumn",
|
||||
category_value_id AS "categoryValueId",
|
||||
menu_objid AS "menuObjid",
|
||||
scope_type AS "scopeType",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
|
|
@ -784,8 +806,8 @@ class NumberingRuleService {
|
|||
updates.resetPeriod,
|
||||
updates.tableName,
|
||||
updates.columnName,
|
||||
updates.categoryColumn,
|
||||
updates.categoryValueId,
|
||||
updates.menuObjid,
|
||||
updates.scopeType,
|
||||
ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
|
|
@ -1176,7 +1198,7 @@ class NumberingRuleService {
|
|||
|
||||
/**
|
||||
* [테스트] 테스트 테이블에서 채번 규칙 목록 조회
|
||||
* numbering_rules 테이블 사용
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
async getRulesFromTest(
|
||||
companyCode: string,
|
||||
|
|
@ -1209,7 +1231,7 @@ class NumberingRuleService {
|
|||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
FROM numbering_rules_test
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
params = [];
|
||||
|
|
@ -1231,7 +1253,7 @@ class NumberingRuleService {
|
|||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy"
|
||||
FROM numbering_rules
|
||||
FROM numbering_rules_test
|
||||
WHERE company_code = $1
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
|
@ -1250,7 +1272,7 @@ class NumberingRuleService {
|
|||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
|
@ -1278,8 +1300,8 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 테이블명 + 컬럼명 기반으로 채번규칙 조회
|
||||
* numbering_rules 테이블 사용
|
||||
* [테스트] 테이블명 + 컬럼명 기반으로 채번규칙 조회 (menu_objid 없이)
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
async getNumberingRuleByColumn(
|
||||
companyCode: string,
|
||||
|
|
@ -1311,8 +1333,8 @@ class NumberingRuleService {
|
|||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules r
|
||||
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
|
||||
FROM numbering_rules_test r
|
||||
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND r.column_name = $3
|
||||
|
|
@ -1343,7 +1365,7 @@ class NumberingRuleService {
|
|||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
|
@ -1369,7 +1391,7 @@ class NumberingRuleService {
|
|||
|
||||
/**
|
||||
* [테스트] 테스트 테이블에 채번규칙 저장
|
||||
* numbering_rules 테이블 사용
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
async saveRuleToTest(
|
||||
config: NumberingRuleConfig,
|
||||
|
|
@ -1392,7 +1414,7 @@ class NumberingRuleService {
|
|||
|
||||
// 기존 규칙 확인
|
||||
const existingQuery = `
|
||||
SELECT rule_id FROM numbering_rules
|
||||
SELECT rule_id FROM numbering_rules_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
`;
|
||||
const existingResult = await client.query(existingQuery, [config.ruleId, companyCode]);
|
||||
|
|
@ -1400,7 +1422,7 @@ class NumberingRuleService {
|
|||
if (existingResult.rows.length > 0) {
|
||||
// 업데이트
|
||||
const updateQuery = `
|
||||
UPDATE numbering_rules SET
|
||||
UPDATE numbering_rules_test SET
|
||||
rule_name = $1,
|
||||
description = $2,
|
||||
separator = $3,
|
||||
|
|
@ -1427,13 +1449,13 @@ class NumberingRuleService {
|
|||
|
||||
// 기존 파트 삭제
|
||||
await client.query(
|
||||
"DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2",
|
||||
"DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2",
|
||||
[config.ruleId, companyCode]
|
||||
);
|
||||
} else {
|
||||
// 신규 등록
|
||||
const insertQuery = `
|
||||
INSERT INTO numbering_rules (
|
||||
INSERT INTO numbering_rules_test (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
category_column, category_value_id,
|
||||
|
|
@ -1460,7 +1482,7 @@ class NumberingRuleService {
|
|||
if (config.parts && config.parts.length > 0) {
|
||||
for (const part of config.parts) {
|
||||
const partInsertQuery = `
|
||||
INSERT INTO numbering_rule_parts (
|
||||
INSERT INTO numbering_rule_parts_test (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
|
|
@ -1501,7 +1523,7 @@ class NumberingRuleService {
|
|||
|
||||
/**
|
||||
* [테스트] 테스트 테이블에서 채번규칙 삭제
|
||||
* numbering_rules 테이블 사용
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
async deleteRuleFromTest(ruleId: string, companyCode: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
|
|
@ -1514,13 +1536,13 @@ class NumberingRuleService {
|
|||
|
||||
// 파트 먼저 삭제
|
||||
await client.query(
|
||||
"DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2",
|
||||
"DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
|
||||
// 규칙 삭제
|
||||
const result = await client.query(
|
||||
"DELETE FROM numbering_rules WHERE rule_id = $1 AND company_code = $2",
|
||||
"DELETE FROM numbering_rules_test WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
|
||||
|
|
@ -1586,8 +1608,8 @@ class NumberingRuleService {
|
|||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules r
|
||||
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
|
||||
FROM numbering_rules_test r
|
||||
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND r.column_name = $3
|
||||
|
|
@ -1614,7 +1636,7 @@ class NumberingRuleService {
|
|||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
|
@ -1646,7 +1668,7 @@ class NumberingRuleService {
|
|||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules r
|
||||
FROM numbering_rules_test r
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND r.column_name = $3
|
||||
|
|
@ -1666,7 +1688,7 @@ class NumberingRuleService {
|
|||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
|
@ -1723,8 +1745,8 @@ class NumberingRuleService {
|
|||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules r
|
||||
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
|
||||
FROM numbering_rules_test r
|
||||
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND r.column_name = $3
|
||||
|
|
@ -1742,7 +1764,7 @@ class NumberingRuleService {
|
|||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
|
|
@ -1761,7 +1783,7 @@ class NumberingRuleService {
|
|||
|
||||
/**
|
||||
* 회사별 채번규칙 복제 (테이블 기반)
|
||||
* numbering_rules, numbering_rule_parts 테이블 사용
|
||||
* numbering_rules_test, numbering_rule_parts_test 테이블 사용
|
||||
* 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트
|
||||
*/
|
||||
async copyRulesForCompany(
|
||||
|
|
@ -1776,28 +1798,9 @@ class NumberingRuleService {
|
|||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 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 deleteResult = await client.query(
|
||||
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
if (deleteResult.rowCount && deleteResult.rowCount > 0) {
|
||||
logger.info("기존 채번규칙 삭제", {
|
||||
targetCompanyCode,
|
||||
deletedCount: deleteResult.rowCount
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 원본 회사의 채번규칙 조회 - numbering_rules 사용
|
||||
// 1. 원본 회사의 채번규칙 조회 - numbering_rules_test 사용
|
||||
const sourceRulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules WHERE company_code = $1`,
|
||||
`SELECT * FROM numbering_rules_test WHERE company_code = $1`,
|
||||
[sourceCompanyCode]
|
||||
);
|
||||
|
||||
|
|
@ -1811,9 +1814,9 @@ class NumberingRuleService {
|
|||
// 새 rule_id 생성
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 이미 존재하는지 확인 (이름 기반) - numbering_rules 사용
|
||||
// 이미 존재하는지 확인 (이름 기반) - numbering_rules_test 사용
|
||||
const existsCheck = await client.query(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
`SELECT rule_id FROM numbering_rules_test
|
||||
WHERE company_code = $1 AND rule_name = $2`,
|
||||
[targetCompanyCode, rule.rule_name]
|
||||
);
|
||||
|
|
@ -1826,9 +1829,9 @@ class NumberingRuleService {
|
|||
continue;
|
||||
}
|
||||
|
||||
// 채번규칙 복제 - numbering_rules 사용
|
||||
// 채번규칙 복제 - numbering_rules_test 사용
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
`INSERT INTO numbering_rules_test (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
created_at, updated_at, created_by, category_column, category_value_id
|
||||
|
|
@ -1849,15 +1852,15 @@ class NumberingRuleService {
|
|||
]
|
||||
);
|
||||
|
||||
// 채번규칙 파트 복제 - numbering_rule_parts 사용
|
||||
// 채번규칙 파트 복제 - numbering_rule_parts_test 사용
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
|
||||
`SELECT * FROM numbering_rule_parts_test 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 (
|
||||
`INSERT INTO numbering_rule_parts_test (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
||||
|
|
|
|||
|
|
@ -1,520 +0,0 @@
|
|||
/**
|
||||
* 스케줄 자동 생성 서비스
|
||||
*
|
||||
* 스케줄 미리보기 생성, 적용, 조회 로직을 처리합니다.
|
||||
*/
|
||||
|
||||
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,76 +635,7 @@ 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',
|
||||
|
|
@ -724,7 +655,7 @@ export class ScreenManagementService {
|
|||
],
|
||||
);
|
||||
|
||||
// 4. 메뉴 할당도 비활성화
|
||||
// 메뉴 할당도 비활성화
|
||||
await client.query(
|
||||
`UPDATE screen_menu_assignments
|
||||
SET is_active = 'N'
|
||||
|
|
@ -3015,7 +2946,7 @@ export class ScreenManagementService {
|
|||
* - current_sequence는 0으로 초기화
|
||||
*/
|
||||
/**
|
||||
* 채번 규칙 복제 (numbering_rules 테이블 사용)
|
||||
* 채번 규칙 복제 (numbering_rules_test 테이블 사용)
|
||||
* - menu_objid 의존성 제거됨
|
||||
* - table_name + column_name + company_code 기반
|
||||
*/
|
||||
|
|
@ -3033,10 +2964,10 @@ export class ScreenManagementService {
|
|||
|
||||
console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`);
|
||||
|
||||
// 1. 원본 채번 규칙 조회 (numbering_rules 테이블)
|
||||
// 1. 원본 채번 규칙 조회 (numbering_rules_test 테이블)
|
||||
const ruleIdArray = Array.from(ruleIds);
|
||||
const sourceRulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules WHERE rule_id = ANY($1)`,
|
||||
`SELECT * FROM numbering_rules_test WHERE rule_id = ANY($1)`,
|
||||
[ruleIdArray],
|
||||
);
|
||||
|
||||
|
|
@ -3049,7 +2980,7 @@ export class ScreenManagementService {
|
|||
|
||||
// 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준)
|
||||
const existingRulesResult = await client.query(
|
||||
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
|
||||
`SELECT rule_id, rule_name FROM numbering_rules_test WHERE company_code = $1`,
|
||||
[targetCompanyCode],
|
||||
);
|
||||
const existingRulesByName = new Map<string, string>(
|
||||
|
|
@ -3070,9 +3001,9 @@ export class ScreenManagementService {
|
|||
// 새로 복사 - 새 rule_id 생성
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// numbering_rules 복사 (current_sequence = 0으로 초기화)
|
||||
// numbering_rules_test 복사 (current_sequence = 0으로 초기화)
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
`INSERT INTO numbering_rules_test (
|
||||
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,
|
||||
|
|
@ -3097,15 +3028,15 @@ export class ScreenManagementService {
|
|||
],
|
||||
);
|
||||
|
||||
// numbering_rule_parts 복사
|
||||
// numbering_rule_parts_test 복사
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
|
||||
`SELECT * FROM numbering_rule_parts_test 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 (
|
||||
`INSERT INTO numbering_rule_parts_test (
|
||||
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)`,
|
||||
|
|
@ -4611,8 +4542,7 @@ export class ScreenManagementService {
|
|||
);
|
||||
|
||||
if (menuInfo.rows.length > 0) {
|
||||
// menu_type: "0" = 관리자 메뉴, "1" = 사용자 메뉴
|
||||
const isAdminMenu = menuInfo.rows[0].menu_type === "0";
|
||||
const isAdminMenu = menuInfo.rows[0].menu_type === "1";
|
||||
const newMenuUrl = isAdminMenu
|
||||
? `/screens/${newScreenId}?mode=admin`
|
||||
: `/screens/${newScreenId}`;
|
||||
|
|
@ -4777,7 +4707,7 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 복제 (category_values 테이블 사용)
|
||||
* 카테고리 값 복제 (category_values_test 테이블 사용)
|
||||
* - menu_objid 의존성 제거됨
|
||||
* - table_name + column_name + company_code 기반
|
||||
*/
|
||||
|
|
@ -4811,13 +4741,13 @@ export class ScreenManagementService {
|
|||
|
||||
// 1. 기존 대상 회사 데이터 삭제 (다른 회사로 복제 시에만)
|
||||
await client.query(
|
||||
`DELETE FROM category_values WHERE company_code = $1`,
|
||||
`DELETE FROM category_values_test WHERE company_code = $1`,
|
||||
[targetCompanyCode],
|
||||
);
|
||||
|
||||
// 2. category_values 복제
|
||||
// 2. category_values_test 복제
|
||||
const values = await client.query(
|
||||
`SELECT * FROM category_values WHERE company_code = $1`,
|
||||
`SELECT * FROM category_values_test WHERE company_code = $1`,
|
||||
[sourceCompanyCode],
|
||||
);
|
||||
|
||||
|
|
@ -4826,7 +4756,7 @@ export class ScreenManagementService {
|
|||
|
||||
for (const v of values.rows) {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO category_values
|
||||
`INSERT INTO category_values_test
|
||||
(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)
|
||||
|
|
@ -4861,7 +4791,7 @@ export class ScreenManagementService {
|
|||
const newValueId = valueIdMap.get(v.value_id);
|
||||
if (newParentId && newValueId) {
|
||||
await client.query(
|
||||
`UPDATE category_values SET parent_value_id = $1 WHERE value_id = $2`,
|
||||
`UPDATE category_values_test SET parent_value_id = $1 WHERE value_id = $2`,
|
||||
[newParentId, newValueId],
|
||||
);
|
||||
}
|
||||
|
|
@ -5110,18 +5040,6 @@ export class ScreenManagementService {
|
|||
console.log(
|
||||
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
|
||||
);
|
||||
|
||||
// 🐛 디버깅: finished_timeline의 fieldMapping 확인
|
||||
const splitPanel = layout.layout_data?.components?.find((c: any) =>
|
||||
c.url?.includes("v2-split-panel-layout")
|
||||
);
|
||||
const finishedTimeline = splitPanel?.overrides?.rightPanel?.components?.find(
|
||||
(c: any) => c.id === "finished_timeline"
|
||||
);
|
||||
if (finishedTimeline) {
|
||||
console.log("🐛 [Backend] finished_timeline fieldMapping:", JSON.stringify(finishedTimeline.componentConfig?.fieldMapping));
|
||||
}
|
||||
|
||||
return layout.layout_data;
|
||||
}
|
||||
|
||||
|
|
@ -5161,20 +5079,16 @@ export class ScreenManagementService {
|
|||
...layoutData
|
||||
};
|
||||
|
||||
// SUPER_ADMIN인 경우 화면 정의의 company_code로 저장 (로드와 일관성 유지)
|
||||
const saveCompanyCode = companyCode === "*" ? existingScreen.company_code : companyCode;
|
||||
console.log(`저장할 company_code: ${saveCompanyCode} (원본: ${companyCode}, 화면 정의: ${existingScreen.company_code})`);
|
||||
|
||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[screenId, saveCompanyCode, JSON.stringify(dataToSave)],
|
||||
[screenId, companyCode, JSON.stringify(dataToSave)],
|
||||
);
|
||||
|
||||
console.log(`V2 레이아웃 저장 완료 (company_code: ${saveCompanyCode})`);
|
||||
console.log(`V2 레이아웃 저장 완료`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -212,22 +212,22 @@ class TableCategoryValueService {
|
|||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM category_values
|
||||
FROM category_values_test
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
|
||||
// category_values 테이블 사용 (menu_objid 없음)
|
||||
// category_values_test 테이블 사용 (menu_objid 없음)
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 값 조회
|
||||
query = baseSelect;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)");
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values_test)");
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
|
||||
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회 (category_values)", { companyCode });
|
||||
logger.info("회사별 카테고리 값 조회 (category_values_test)", { companyCode });
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
|
|
|
|||
|
|
@ -289,46 +289,29 @@ export class TableManagementService {
|
|||
companyCode,
|
||||
});
|
||||
|
||||
try {
|
||||
// menu_objid 컬럼이 있는지 먼저 확인
|
||||
const columnCheck = await query<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
|
||||
);
|
||||
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]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
mappings: mappings,
|
||||
});
|
||||
|
||||
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 컬럼이 없음, 카테고리 매핑 스킵");
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
}
|
||||
} catch (mappingError: any) {
|
||||
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
|
||||
error: mappingError.message,
|
||||
});
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
|
||||
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
|
||||
size: categoryMappings.size,
|
||||
|
|
@ -473,15 +456,6 @@ 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);
|
||||
|
||||
|
|
@ -734,22 +708,12 @@ 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} = ${finalWebType}`
|
||||
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
|
||||
);
|
||||
|
||||
// 웹 타입별 기본 상세 설정 생성
|
||||
const defaultDetailSettings = this.generateDefaultDetailSettings(finalWebType);
|
||||
const defaultDetailSettings = this.generateDefaultDetailSettings(webType);
|
||||
|
||||
// 사용자 정의 설정과 기본 설정 병합
|
||||
const finalDetailSettings = {
|
||||
|
|
@ -768,10 +732,10 @@ export class TableManagementService {
|
|||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
updated_date = NOW()`,
|
||||
[tableName, columnName, finalWebType, JSON.stringify(finalDetailSettings)]
|
||||
[tableName, columnName, webType, JSON.stringify(finalDetailSettings)]
|
||||
);
|
||||
logger.info(
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}`
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
|
@ -796,23 +760,13 @@ 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} = ${finalInputType}, company: ${companyCode}`
|
||||
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
|
||||
);
|
||||
|
||||
// 입력 타입별 기본 상세 설정 생성
|
||||
const defaultDetailSettings =
|
||||
this.generateDefaultInputTypeSettings(finalInputType);
|
||||
this.generateDefaultInputTypeSettings(inputType);
|
||||
|
||||
// 사용자 정의 설정과 기본 설정 병합
|
||||
const finalDetailSettings = {
|
||||
|
|
@ -834,7 +788,7 @@ export class TableManagementService {
|
|||
[
|
||||
tableName,
|
||||
columnName,
|
||||
finalInputType,
|
||||
inputType,
|
||||
JSON.stringify(finalDetailSettings),
|
||||
companyCode,
|
||||
]
|
||||
|
|
@ -844,7 +798,7 @@ export class TableManagementService {
|
|||
await this.syncScreenLayoutsInputType(
|
||||
tableName,
|
||||
columnName,
|
||||
finalInputType,
|
||||
inputType,
|
||||
companyCode
|
||||
);
|
||||
|
||||
|
|
@ -4209,46 +4163,31 @@ export class TableManagementService {
|
|||
if (mappingTableExists) {
|
||||
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
||||
|
||||
try {
|
||||
// menu_objid 컬럼이 있는지 먼저 확인
|
||||
const columnCheck = await query<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
|
||||
);
|
||||
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]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
logger.info("카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
mappings: mappings,
|
||||
});
|
||||
|
||||
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 컬럼이 없음, 카테고리 매핑 스킵");
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
}
|
||||
} catch (mappingError: any) {
|
||||
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
|
||||
error: mappingError.message,
|
||||
});
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
|
||||
logger.info("categoryMappings Map 생성 완료", {
|
||||
size: categoryMappings.size,
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
# 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';
|
||||
```
|
||||
|
|
@ -1,894 +0,0 @@
|
|||
# 스케줄 자동 생성 기능 구현 가이드
|
||||
|
||||
> 버전: 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);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
|
@ -1209,117 +1209,17 @@ v2-table-list (생산계획 목록)
|
|||
|
||||
---
|
||||
|
||||
## 16. 자동 스케줄 생성 기능
|
||||
|
||||
> 상세 가이드: [스케줄 자동 생성 기능 구현 가이드](../00_analysis/schedule-auto-generation-guide.md)
|
||||
|
||||
### 16.1 개요
|
||||
|
||||
좌측 수주 테이블에서 품목을 선택하고 "자동 스케줄 생성" 버튼을 클릭하면, 선택된 품목들에 대한 생산 스케줄이 자동으로 생성되어 우측 타임라인에 표시됩니다.
|
||||
|
||||
### 16.2 데이터 흐름
|
||||
|
||||
```
|
||||
1. 좌측 v2-table-grouped에서 품목 선택 (그룹 선택 시 자식 포함)
|
||||
2. "자동 스케줄 생성" 버튼 클릭
|
||||
3. 백엔드 API에서 미리보기 생성 (생성/삭제/수정될 스케줄)
|
||||
4. 변경사항 확인 다이얼로그 표시
|
||||
5. 확인 시 스케줄 적용 및 타임라인 새로고침
|
||||
6. 다음 방문 시: 좌측 선택 → linkedFilter로 우측 자동 필터링
|
||||
```
|
||||
|
||||
### 16.3 스케줄 생성 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"scheduleType": "PRODUCTION",
|
||||
"source": {
|
||||
"componentId": "order_table",
|
||||
"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"
|
||||
},
|
||||
"target": {
|
||||
"tableName": "production_plan_mng",
|
||||
"timelineComponentId": "production_timeline"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 16.4 버튼 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"componentType": "v2-button-primary",
|
||||
"componentId": "btn_auto_schedule",
|
||||
"componentConfig": {
|
||||
"label": "자동 스케줄 생성",
|
||||
"variant": "default",
|
||||
"icon": "Calendar",
|
||||
"action": {
|
||||
"type": "custom",
|
||||
"customAction": "autoGenerateSchedule",
|
||||
"scheduleConfig": { /* 위 설정 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 16.5 연결 필터 설정 (linkedFilters)
|
||||
|
||||
좌측 테이블 선택 시 우측 타임라인 자동 필터링:
|
||||
|
||||
```json
|
||||
{
|
||||
"linkedFilters": [
|
||||
{
|
||||
"sourceComponentId": "order_table",
|
||||
"sourceField": "part_code",
|
||||
"targetColumn": "resource_id"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 16.6 구현 상태
|
||||
|
||||
| 항목 | 상태 | 비고 |
|
||||
|------|:----:|------|
|
||||
| schedule_mng 테이블 | ⏳ 대기 | 범용 스케줄 테이블 |
|
||||
| /api/schedule/preview API | ⏳ 대기 | 미리보기 |
|
||||
| /api/schedule/apply API | ⏳ 대기 | 적용 |
|
||||
| autoGenerateSchedule 버튼 액션 | ⏳ 대기 | buttonActions.ts |
|
||||
| 확인 다이얼로그 | ⏳ 대기 | 기존 AlertDialog 활용 |
|
||||
| linkedFilter 연동 | ⏳ 대기 | 타임라인 필터링 |
|
||||
|
||||
---
|
||||
|
||||
## 17. 관련 문서
|
||||
## 16. 관련 문서
|
||||
|
||||
- [수주관리](../02_sales/order.md)
|
||||
- [품목정보](../01_master-data/item-info.md)
|
||||
- [설비관리](../05_equipment/equipment-info.md)
|
||||
- [BOM관리](../01_master-data/bom.md)
|
||||
- [작업지시](./work-order.md)
|
||||
- **[스케줄 자동 생성 기능 가이드](../00_analysis/schedule-auto-generation-guide.md)**
|
||||
|
||||
---
|
||||
|
||||
## 18. 참고: 표준 가이드
|
||||
## 17. 참고: 표준 가이드
|
||||
|
||||
- [화면개발 표준 가이드](../화면개발_표준_가이드.md)
|
||||
- [V2 컴포넌트 사용 가이드](../00_analysis/v2-component-usage-guide.md)
|
||||
|
|
|
|||
|
|
@ -26,11 +26,8 @@ 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();
|
||||
|
|
@ -994,16 +991,6 @@ function ScreenViewPage() {
|
|||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 스케줄 생성 확인 다이얼로그 */}
|
||||
<ScheduleConfirmDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={(open) => !open && closeDialog()}
|
||||
preview={previewResult}
|
||||
onConfirm={() => handleConfirm(true)}
|
||||
onCancel={closeDialog}
|
||||
isLoading={scheduleLoading}
|
||||
/>
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
|
||||
const loadAllCategoryOptions = async () => {
|
||||
try {
|
||||
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
|
||||
// category_values_test 테이블에서 고유한 테이블.컬럼 조합 조회
|
||||
const response = await getAllCategoryKeys();
|
||||
if (response.success && response.data) {
|
||||
const options: CategoryOption[] = response.data.map((item) => ({
|
||||
|
|
@ -341,7 +341,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
ruleToSave,
|
||||
});
|
||||
|
||||
// 테스트 테이블에 저장 (numbering_rules)
|
||||
// 테스트 테이블에 저장 (numbering_rules_test)
|
||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||
|
||||
if (response.success && response.data) {
|
||||
|
|
|
|||
|
|
@ -253,24 +253,6 @@ 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) {
|
||||
|
|
@ -1200,36 +1182,31 @@ export default function CopyScreenModal({
|
|||
// 그룹 복제 모드 렌더링
|
||||
if (mode === "group") {
|
||||
return (
|
||||
<>
|
||||
{/* 로딩 오버레이 - Dialog 바깥에서 화면 전체 고정 */}
|
||||
{isCopying && (
|
||||
<div className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background/95 backdrop-blur-md">
|
||||
<div className="rounded-lg bg-card p-8 shadow-lg border flex flex-col items-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 text-base font-medium">{copyProgress.message}</p>
|
||||
<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>
|
||||
{copyProgress.total > 0 && (
|
||||
<>
|
||||
<div className="mt-4 h-3 w-64 overflow-hidden rounded-full bg-secondary">
|
||||
<div className="mt-3 h-2 w-48 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-3 text-sm text-muted-foreground">
|
||||
{copyProgress.current} / {copyProgress.total} 화면 복제 중...
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{copyProgress.current} / {copyProgress.total} 화면
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
복제가 완료될 때까지 잠시 기다려주세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<FolderTree className="h-5 w-5" />
|
||||
그룹 복제
|
||||
</DialogTitle>
|
||||
|
|
@ -1509,22 +1486,15 @@ 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
|
||||
.filter((company) => company.companyCode !== sourceGroup?.company_code)
|
||||
.map((company) => (
|
||||
<option key={company.companyCode} value={company.companyCode}>
|
||||
{company.companyName} ({company.companyCode})
|
||||
</option>
|
||||
))}
|
||||
{companies.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>
|
||||
)}
|
||||
|
||||
|
|
@ -1620,25 +1590,14 @@ export default function CopyScreenModal({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 복제 모드 렌더링
|
||||
return (
|
||||
<>
|
||||
{/* 로딩 오버레이 - Dialog 바깥에서 화면 전체 고정 */}
|
||||
{isCopying && (
|
||||
<div className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background/95 backdrop-blur-md">
|
||||
<div className="rounded-lg bg-card p-8 shadow-lg border flex flex-col items-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 text-base font-medium">복제가 완료될 때까지 잠시 기다려주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<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}" 화면을 복제합니다.
|
||||
|
|
@ -1735,20 +1694,13 @@ export default function CopyScreenModal({
|
|||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies
|
||||
.filter((company) => company.companyCode !== sourceScreen?.companyCode)
|
||||
.map((company) => (
|
||||
<SelectItem key={company.companyCode} value={company.companyCode}>
|
||||
{company.companyName}
|
||||
</SelectItem>
|
||||
))}
|
||||
{companies.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>
|
||||
)}
|
||||
|
||||
|
|
@ -1888,7 +1840,6 @@ export default function CopyScreenModal({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -18,7 +18,6 @@ import {
|
|||
Loader2,
|
||||
RefreshCw,
|
||||
Building2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import {
|
||||
|
|
@ -1464,26 +1463,16 @@ export function ScreenGroupTreeView({
|
|||
|
||||
{/* 그룹 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px] border-destructive/50">
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<AlertDialogHeader>
|
||||
<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>
|
||||
<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>
|
||||
: "그룹에 속한 화면들은 미분류로 이동됩니다."
|
||||
}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
|
|
@ -1581,21 +1570,11 @@ export function ScreenGroupTreeView({
|
|||
)}
|
||||
|
||||
<AlertDialogHeader>
|
||||
<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>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">화면 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
"{deletingScreen?.screenName}" 화면을 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 화면은 휴지통으로 이동됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
|
|
|
|||
|
|
@ -174,10 +174,30 @@ export default function TableTypeSelector({
|
|||
}
|
||||
};
|
||||
|
||||
// 입력 타입 변경 (로컬 상태만 - DB에 저장하지 않음)
|
||||
const handleInputTypeChange = (columnName: string, inputType: "direct" | "auto") => {
|
||||
// 로컬 상태만 업데이트 (DB에는 저장하지 않음 - inputType은 화면 렌더링용)
|
||||
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, inputType } : col)));
|
||||
// 입력 타입 변경
|
||||
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("입력 타입 설정에 실패했습니다. 다시 시도해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTables = tables.filter((table) => table.displayName.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
|
|
|||
|
|
@ -831,9 +831,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||
|
||||
{/* 이벤트 버스 */}
|
||||
<SelectItem value="event">이벤트 발송</SelectItem>
|
||||
|
||||
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
|
||||
|
|
@ -3539,99 +3536,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -321,7 +321,6 @@ export function TabsWidget({
|
|||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
isDesignMode={isDesignMode}
|
||||
isInteractive={!isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -492,7 +492,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
|
|||
const categoryTable = (config as any).categoryTable;
|
||||
const categoryColumn = (config as any).categoryColumn;
|
||||
|
||||
// category 소스 유지 (category_values 테이블에서 로드)
|
||||
// category 소스 유지 (category_values_test 테이블에서 로드)
|
||||
const source = rawSource;
|
||||
const codeGroup = config.codeGroup;
|
||||
|
||||
|
|
@ -612,7 +612,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
|
|||
fetchedOptions = data;
|
||||
}
|
||||
} else if (source === "category") {
|
||||
// 카테고리에서 로드 (category_values 테이블)
|
||||
// 카테고리에서 로드 (category_values_test 테이블)
|
||||
// tableName, columnName은 props에서 가져옴
|
||||
const catTable = categoryTable || tableName;
|
||||
const catColumn = categoryColumn || columnName;
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const categoryTable = (config as any).categoryTable;
|
||||
const categoryColumn = (config as any).categoryColumn;
|
||||
|
||||
// category 소스 유지 (category_values 테이블에서 로드)
|
||||
// category 소스 유지 (category_values_test 테이블에서 로드)
|
||||
const source = rawSource;
|
||||
const codeGroup = config.codeGroup;
|
||||
|
||||
|
|
@ -590,7 +590,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
fetchedOptions = data;
|
||||
}
|
||||
} else if (source === "category") {
|
||||
// 카테고리에서 로드 (category_values 테이블)
|
||||
// 카테고리에서 로드 (category_values_test 테이블)
|
||||
// tableName, columnName은 props에서 가져옴
|
||||
const catTable = categoryTable || tableName;
|
||||
const catColumn = categoryColumn || columnName;
|
||||
|
|
|
|||
|
|
@ -173,11 +173,11 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
|
|||
}
|
||||
}
|
||||
|
||||
// ====== 테스트용 API (numbering_rules 테이블 사용) ======
|
||||
// ====== 테스트용 API (numbering_rules_test 테이블 사용) ======
|
||||
|
||||
/**
|
||||
* [테스트] 테스트 테이블에서 채번규칙 목록 조회
|
||||
* numbering_rules 테이블 사용
|
||||
* numbering_rules_test 테이블 사용
|
||||
* @param menuObjid 메뉴 OBJID (선택) - 필터링용
|
||||
*/
|
||||
export async function getNumberingRulesFromTest(
|
||||
|
|
@ -199,7 +199,7 @@ export async function getNumberingRulesFromTest(
|
|||
|
||||
/**
|
||||
* [테스트] 테이블+컬럼 기반 채번규칙 조회
|
||||
* numbering_rules 테이블 사용
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
export async function getNumberingRuleByColumn(
|
||||
tableName: string,
|
||||
|
|
@ -220,7 +220,7 @@ export async function getNumberingRuleByColumn(
|
|||
|
||||
/**
|
||||
* [테스트] 테스트 테이블에 채번규칙 저장
|
||||
* numbering_rules 테이블 사용
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
export async function saveNumberingRuleToTest(
|
||||
config: NumberingRuleConfig
|
||||
|
|
@ -238,7 +238,7 @@ export async function saveNumberingRuleToTest(
|
|||
|
||||
/**
|
||||
* [테스트] 테스트 테이블에서 채번규칙 삭제
|
||||
* numbering_rules 테이블 사용
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
export async function deleteNumberingRuleFromTest(
|
||||
ruleId: string
|
||||
|
|
|
|||
|
|
@ -347,10 +347,12 @@ export const tableTypeApi = {
|
|||
columnName: string,
|
||||
webType: string,
|
||||
detailSettings?: Record<string, any>,
|
||||
inputType?: "direct" | "auto",
|
||||
): Promise<void> => {
|
||||
await apiClient.put(`/table-management/tables/${tableName}/columns/${columnName}/web-type`, {
|
||||
webType,
|
||||
detailSettings,
|
||||
inputType,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -67,20 +67,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
...props
|
||||
}) => {
|
||||
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
|
||||
|
||||
// 🐛 디버깅: 로드 시 rightPanel.components 확인
|
||||
const rightComps = componentConfig.rightPanel?.components || [];
|
||||
const finishedTimeline = rightComps.find((c: any) => c.id === "finished_timeline");
|
||||
if (finishedTimeline) {
|
||||
const fm = finishedTimeline.componentConfig?.fieldMapping;
|
||||
console.log("🔍 [SplitPanelLayout] finished_timeline fieldMapping:", {
|
||||
componentId: finishedTimeline.id,
|
||||
fieldMapping: fm ? JSON.stringify(fm) : "undefined",
|
||||
fieldMappingKeys: fm ? Object.keys(fm) : [],
|
||||
fieldMappingId: fm?.id,
|
||||
fullComponentConfig: JSON.stringify(finishedTimeline.componentConfig || {}, null, 2),
|
||||
});
|
||||
}
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능)
|
||||
const companyCode = (props as any).companyCode as string | undefined;
|
||||
|
||||
|
|
@ -245,33 +231,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
[component, componentConfig, onUpdateComponent]
|
||||
);
|
||||
|
||||
// 🆕 중첩된 컴포넌트 업데이트 핸들러 (탭 컴포넌트 내부 위치 변경 등)
|
||||
const handleNestedComponentUpdate = useCallback(
|
||||
(panelSide: "left" | "right", compId: string, updatedNestedComponent: any) => {
|
||||
if (!onUpdateComponent) return;
|
||||
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
const panelConfig = componentConfig[panelKey] || {};
|
||||
const panelComponents = panelConfig.components || [];
|
||||
|
||||
const updatedComponents = panelComponents.map((c: PanelInlineComponent) =>
|
||||
c.id === compId ? { ...c, ...updatedNestedComponent, id: c.id } : c
|
||||
);
|
||||
|
||||
onUpdateComponent({
|
||||
...component,
|
||||
componentConfig: {
|
||||
...componentConfig,
|
||||
[panelKey]: {
|
||||
...panelConfig,
|
||||
components: updatedComponents,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[component, componentConfig, onUpdateComponent]
|
||||
);
|
||||
|
||||
// 🆕 커스텀 모드: 드래그 시작 핸들러
|
||||
const handlePanelDragStart = useCallback(
|
||||
(e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent) => {
|
||||
|
|
@ -2334,7 +2293,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
)}
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
{/* 좌측 데이터 목록/테이블/커스텀 */}
|
||||
{console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)}
|
||||
{componentConfig.leftPanel?.displayMode === "custom" ? (
|
||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||
<div
|
||||
|
|
@ -2440,42 +2398,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
height: displayHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🆕 컨테이너 컴포넌트(탭, 분할 패널)는 드롭 이벤트를 받을 수 있어야 함 */}
|
||||
<div className={cn(
|
||||
"h-full w-full",
|
||||
// 탭/분할 패널 같은 컨테이너 컴포넌트는 pointer-events 활성화
|
||||
(comp.componentType === "v2-tabs-widget" ||
|
||||
comp.componentType === "tabs-widget" ||
|
||||
comp.componentType === "v2-split-panel-layout" ||
|
||||
comp.componentType === "split-panel-layout")
|
||||
? ""
|
||||
: "pointer-events-none"
|
||||
)}>
|
||||
<div className="pointer-events-none h-full w-full">
|
||||
<DynamicComponentRenderer
|
||||
component={componentData as any}
|
||||
isDesignMode={true}
|
||||
formData={{}}
|
||||
// 🆕 중첩된 컴포넌트 업데이트 핸들러 전달
|
||||
onUpdateComponent={(updatedComp: any) => {
|
||||
handleNestedComponentUpdate("left", comp.id, updatedComp);
|
||||
}}
|
||||
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
|
||||
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
|
||||
console.log("🔍 [SplitPanel-Left] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id });
|
||||
// 부모 분할 패널 정보와 함께 전역 이벤트 발생
|
||||
const event = new CustomEvent("nested-tab-component-select", {
|
||||
detail: {
|
||||
tabsComponentId: comp.id,
|
||||
tabId,
|
||||
componentId: compId,
|
||||
component: tabComp,
|
||||
parentSplitPanelId: component.id,
|
||||
parentPanelSide: "left",
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
selectedTabComponentId={undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -3152,42 +3079,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
height: displayHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🆕 컨테이너 컴포넌트(탭, 분할 패널)는 드롭 이벤트를 받을 수 있어야 함 */}
|
||||
<div className={cn(
|
||||
"h-full w-full",
|
||||
// 탭/분할 패널 같은 컨테이너 컴포넌트는 pointer-events 활성화
|
||||
(comp.componentType === "v2-tabs-widget" ||
|
||||
comp.componentType === "tabs-widget" ||
|
||||
comp.componentType === "v2-split-panel-layout" ||
|
||||
comp.componentType === "split-panel-layout")
|
||||
? ""
|
||||
: "pointer-events-none"
|
||||
)}>
|
||||
<div className="pointer-events-none h-full w-full">
|
||||
<DynamicComponentRenderer
|
||||
component={componentData as any}
|
||||
isDesignMode={true}
|
||||
formData={{}}
|
||||
// 🆕 중첩된 컴포넌트 업데이트 핸들러 전달
|
||||
onUpdateComponent={(updatedComp: any) => {
|
||||
handleNestedComponentUpdate("right", comp.id, updatedComp);
|
||||
}}
|
||||
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
|
||||
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
|
||||
console.log("🔍 [SplitPanel-Right] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id });
|
||||
// 부모 분할 패널 정보와 함께 전역 이벤트 발생
|
||||
const event = new CustomEvent("nested-tab-component-select", {
|
||||
detail: {
|
||||
tabsComponentId: comp.id,
|
||||
tabId,
|
||||
componentId: compId,
|
||||
component: tabComp,
|
||||
parentSplitPanelId: component.id,
|
||||
parentPanelSide: "right",
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
selectedTabComponentId={undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { GroupHeader } from "./components/GroupHeader";
|
|||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core/events";
|
||||
|
||||
/**
|
||||
* v2-table-grouped 메인 컴포넌트
|
||||
|
|
@ -268,9 +267,8 @@ export function TableGroupedComponent({
|
|||
[columns]
|
||||
);
|
||||
|
||||
// 선택 변경 시 콜백 및 이벤트 발송
|
||||
// 선택 변경 시 콜백
|
||||
useEffect(() => {
|
||||
// 기존 콜백 호출
|
||||
if (onSelectionChange && selectedItems.length >= 0) {
|
||||
onSelectionChange({
|
||||
selectedGroups: groups
|
||||
|
|
@ -280,21 +278,7 @@ export function TableGroupedComponent({
|
|||
isAllSelected,
|
||||
});
|
||||
}
|
||||
|
||||
// TABLE_SELECTION_CHANGE 이벤트 발송 (선택 데이터 변경 시 다른 컴포넌트에 알림)
|
||||
v2EventBus.emit(V2_EVENTS.TABLE_SELECTION_CHANGE, {
|
||||
componentId: componentId || tableId,
|
||||
tableName: config.selectedTable || "",
|
||||
selectedRows: selectedItems,
|
||||
selectedCount: selectedItems.length,
|
||||
});
|
||||
|
||||
console.log("[TableGroupedComponent] 선택 변경 이벤트 발송:", {
|
||||
componentId: componentId || tableId,
|
||||
tableName: config.selectedTable,
|
||||
selectedCount: selectedItems.length,
|
||||
});
|
||||
}, [selectedItems, groups, isAllSelected, onSelectionChange, componentId, tableId, config.selectedTable]);
|
||||
}, [selectedItems, groups, isAllSelected, onSelectionChange]);
|
||||
|
||||
// 그룹 토글 핸들러
|
||||
const handleGroupToggle = useCallback(
|
||||
|
|
|
|||
|
|
@ -365,7 +365,6 @@ const TabsDesignEditor: React.FC<{
|
|||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log("🔍 [탭 컴포넌트] 클릭:", { activeTabId, compId: comp.id, hasOnSelectTabComponent: !!onSelectTabComponent });
|
||||
onSelectTabComponent?.(activeTabId, comp.id, comp);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -85,31 +85,11 @@ export function TimelineSchedulerComponent({
|
|||
const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
|
||||
const cellWidth = cellWidthConfig[zoomLevel] || 60;
|
||||
|
||||
// 리소스가 없으면 스케줄의 resourceId로 자동 생성
|
||||
const effectiveResources = useMemo(() => {
|
||||
if (resources.length > 0) {
|
||||
return resources;
|
||||
}
|
||||
|
||||
// 스케줄에서 고유한 resourceId 추출하여 자동 리소스 생성
|
||||
const uniqueResourceIds = new Set<string>();
|
||||
schedules.forEach((schedule) => {
|
||||
if (schedule.resourceId) {
|
||||
uniqueResourceIds.add(schedule.resourceId);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueResourceIds).map((id) => ({
|
||||
id,
|
||||
name: id, // resourceId를 이름으로 사용
|
||||
}));
|
||||
}, [resources, schedules]);
|
||||
|
||||
// 리소스별 스케줄 그룹화
|
||||
const schedulesByResource = useMemo(() => {
|
||||
const grouped = new Map<string, ScheduleItem[]>();
|
||||
|
||||
effectiveResources.forEach((resource) => {
|
||||
resources.forEach((resource) => {
|
||||
grouped.set(resource.id, []);
|
||||
});
|
||||
|
||||
|
|
@ -119,7 +99,7 @@ export function TimelineSchedulerComponent({
|
|||
list.push(schedule);
|
||||
} else {
|
||||
// 리소스가 없는 스케줄은 첫 번째 리소스에 할당
|
||||
const firstResource = effectiveResources[0];
|
||||
const firstResource = resources[0];
|
||||
if (firstResource) {
|
||||
const firstList = grouped.get(firstResource.id);
|
||||
if (firstList) {
|
||||
|
|
@ -130,7 +110,7 @@ export function TimelineSchedulerComponent({
|
|||
});
|
||||
|
||||
return grouped;
|
||||
}, [schedules, effectiveResources]);
|
||||
}, [schedules, resources]);
|
||||
|
||||
// 줌 레벨 변경
|
||||
const handleZoomIn = useCallback(() => {
|
||||
|
|
@ -152,12 +132,12 @@ export function TimelineSchedulerComponent({
|
|||
// 스케줄 클릭 핸들러
|
||||
const handleScheduleClick = useCallback(
|
||||
(schedule: ScheduleItem) => {
|
||||
const resource = effectiveResources.find((r) => r.id === schedule.resourceId);
|
||||
const resource = resources.find((r) => r.id === schedule.resourceId);
|
||||
if (resource && onScheduleClick) {
|
||||
onScheduleClick({ schedule, resource });
|
||||
}
|
||||
},
|
||||
[effectiveResources, onScheduleClick]
|
||||
[resources, onScheduleClick]
|
||||
);
|
||||
|
||||
// 빈 셀 클릭 핸들러
|
||||
|
|
@ -215,13 +195,13 @@ export function TimelineSchedulerComponent({
|
|||
|
||||
// 추가 버튼 클릭
|
||||
const handleAddClick = useCallback(() => {
|
||||
if (onAddSchedule && effectiveResources.length > 0) {
|
||||
if (onAddSchedule && resources.length > 0) {
|
||||
onAddSchedule(
|
||||
effectiveResources[0].id,
|
||||
resources[0].id,
|
||||
new Date().toISOString().split("T")[0]
|
||||
);
|
||||
}
|
||||
}, [onAddSchedule, effectiveResources]);
|
||||
}, [onAddSchedule, resources]);
|
||||
|
||||
// 디자인 모드 플레이스홀더
|
||||
if (isDesignMode) {
|
||||
|
|
@ -270,20 +250,17 @@ export function TimelineSchedulerComponent({
|
|||
);
|
||||
}
|
||||
|
||||
// 스케줄 데이터 없음
|
||||
if (schedules.length === 0) {
|
||||
// 리소스 없음
|
||||
if (resources.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-center justify-center bg-muted/10 rounded-lg border"
|
||||
className="w-full flex items-center justify-center bg-muted/10 rounded-lg"
|
||||
style={{ height: config.height || 500 }}
|
||||
>
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium">스케줄 데이터가 없습니다</p>
|
||||
<p className="text-xs mt-2 max-w-[200px]">
|
||||
좌측 테이블에서 품목을 선택하거나,<br />
|
||||
스케줄 생성 버튼을 눌러 스케줄을 생성하세요
|
||||
</p>
|
||||
<Calendar className="h-8 w-8 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium">리소스가 없습니다</p>
|
||||
<p className="text-xs mt-1">리소스 테이블을 설정하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -408,7 +385,7 @@ export function TimelineSchedulerComponent({
|
|||
|
||||
{/* 리소스 행들 */}
|
||||
<div>
|
||||
{effectiveResources.map((resource) => (
|
||||
{resources.map((resource) => (
|
||||
<ResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ import {
|
|||
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { TimelineSchedulerConfig, ScheduleType, SourceDataConfig } from "./types";
|
||||
import { zoomLevelOptions, scheduleTypeOptions } from "./config";
|
||||
import { TimelineSchedulerConfig } from "./types";
|
||||
import { zoomLevelOptions, statusOptions } from "./config";
|
||||
|
||||
interface TimelineSchedulerConfigPanelProps {
|
||||
config: TimelineSchedulerConfig;
|
||||
|
|
@ -57,10 +57,10 @@ export function TimelineSchedulerConfigPanel({
|
|||
onChange,
|
||||
}: TimelineSchedulerConfigPanelProps) {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
|
||||
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
|
||||
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||
const [resourceTableSelectOpen, setResourceTableSelectOpen] = useState(false);
|
||||
|
||||
// 테이블 목록 로드
|
||||
|
|
@ -86,17 +86,17 @@ export function TimelineSchedulerConfigPanel({
|
|||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 소스 테이블 컬럼 로드
|
||||
// 스케줄 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadSourceColumns = async () => {
|
||||
if (!config.sourceConfig?.tableName) {
|
||||
setSourceColumns([]);
|
||||
const loadColumns = async () => {
|
||||
if (!config.selectedTable) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(config.sourceConfig.tableName);
|
||||
const columns = await tableTypeApi.getColumns(config.selectedTable);
|
||||
if (Array.isArray(columns)) {
|
||||
setSourceColumns(
|
||||
setTableColumns(
|
||||
columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||
|
|
@ -104,12 +104,12 @@ export function TimelineSchedulerConfigPanel({
|
|||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("소스 컬럼 로드 오류:", err);
|
||||
setSourceColumns([]);
|
||||
console.error("컬럼 로드 오류:", err);
|
||||
setTableColumns([]);
|
||||
}
|
||||
};
|
||||
loadSourceColumns();
|
||||
}, [config.sourceConfig?.tableName]);
|
||||
loadColumns();
|
||||
}, [config.selectedTable]);
|
||||
|
||||
// 리소스 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -141,12 +141,12 @@ export function TimelineSchedulerConfigPanel({
|
|||
onChange({ ...config, ...updates });
|
||||
};
|
||||
|
||||
// 소스 데이터 설정 업데이트
|
||||
const updateSourceConfig = (updates: Partial<SourceDataConfig>) => {
|
||||
// 필드 매핑 업데이트
|
||||
const updateFieldMapping = (field: string, value: string) => {
|
||||
updateConfig({
|
||||
sourceConfig: {
|
||||
...config.sourceConfig,
|
||||
...updates,
|
||||
fieldMapping: {
|
||||
...config.fieldMapping,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -165,54 +165,35 @@ export function TimelineSchedulerConfigPanel({
|
|||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Accordion type="multiple" defaultValue={["source", "resource", "display"]}>
|
||||
{/* 소스 데이터 설정 (스케줄 생성 기준) */}
|
||||
<AccordionItem value="source">
|
||||
<Accordion type="multiple" defaultValue={["table", "mapping", "display"]}>
|
||||
{/* 테이블 설정 */}
|
||||
<AccordionItem value="table">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
스케줄 생성 설정
|
||||
테이블 설정
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
스케줄 자동 생성 시 참조할 원본 데이터 설정 (저장: schedule_mng)
|
||||
</p>
|
||||
|
||||
{/* 스케줄 타입 */}
|
||||
{/* 스케줄 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">스케줄 타입</Label>
|
||||
<Select
|
||||
value={config.scheduleType || "PRODUCTION"}
|
||||
onValueChange={(v) => updateConfig({ scheduleType: v as ScheduleType })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scheduleTypeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 소스 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">소스 테이블 (수주/작업요청 등)</Label>
|
||||
<Popover open={sourceTableSelectOpen} onOpenChange={setSourceTableSelectOpen}>
|
||||
<Label className="text-xs">스케줄 테이블</Label>
|
||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={sourceTableSelectOpen}
|
||||
aria-expanded={tableSelectOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{config.sourceConfig?.tableName ? (
|
||||
tables.find((t) => t.tableName === config.sourceConfig?.tableName)
|
||||
?.displayName || config.sourceConfig.tableName
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</span>
|
||||
) : config.selectedTable ? (
|
||||
tables.find((t) => t.tableName === config.selectedTable)
|
||||
?.displayName || config.selectedTable
|
||||
) : (
|
||||
"소스 테이블 선택..."
|
||||
"테이블 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
|
@ -242,15 +223,15 @@ export function TimelineSchedulerConfigPanel({
|
|||
key={table.tableName}
|
||||
value={`${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => {
|
||||
updateSourceConfig({ tableName: table.tableName });
|
||||
setSourceTableSelectOpen(false);
|
||||
updateConfig({ selectedTable: table.tableName });
|
||||
setTableSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.sourceConfig?.tableName === table.tableName
|
||||
config.selectedTable === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
|
|
@ -270,112 +251,9 @@ export function TimelineSchedulerConfigPanel({
|
|||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 소스 필드 매핑 */}
|
||||
{config.sourceConfig?.tableName && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 기준일 필드 */}
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label className="text-[10px]">기준일 (마감일/납기일) *</Label>
|
||||
<Select
|
||||
value={config.sourceConfig?.dueDateField || ""}
|
||||
onValueChange={(v) => updateSourceConfig({ dueDateField: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필수 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
스케줄 종료일로 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 수량 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">수량 필드</Label>
|
||||
<Select
|
||||
value={config.sourceConfig?.quantityField || ""}
|
||||
onValueChange={(v) => updateSourceConfig({ quantityField: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 그룹화 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">그룹 필드 (품목코드)</Label>
|
||||
<Select
|
||||
value={config.sourceConfig?.groupByField || ""}
|
||||
onValueChange={(v) => updateSourceConfig({ groupByField: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 그룹명 필드 */}
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label className="text-[10px]">그룹명 필드 (품목명)</Label>
|
||||
<Select
|
||||
value={config.sourceConfig?.groupNameField || ""}
|
||||
onValueChange={(v) => updateSourceConfig({ groupNameField: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 리소스 설정 */}
|
||||
<AccordionItem value="resource">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
리소스 설정 (설비/작업자)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
타임라인 Y축에 표시할 리소스 (설비, 작업자 등)
|
||||
</p>
|
||||
|
||||
{/* 리소스 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">리소스 테이블</Label>
|
||||
<Label className="text-xs">리소스 테이블 (설비/작업자)</Label>
|
||||
<Popover
|
||||
open={resourceTableSelectOpen}
|
||||
onOpenChange={setResourceTableSelectOpen}
|
||||
|
|
@ -449,15 +327,152 @@ export function TimelineSchedulerConfigPanel({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<AccordionItem value="mapping">
|
||||
<AccordionTrigger className="text-sm font-medium">
|
||||
필드 매핑
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
{/* 스케줄 필드 매핑 */}
|
||||
{config.selectedTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">스케줄 필드</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* ID 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">ID</Label>
|
||||
<Select
|
||||
value={config.fieldMapping?.id || ""}
|
||||
onValueChange={(v) => updateFieldMapping("id", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 리소스 ID 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">리소스 ID</Label>
|
||||
<Select
|
||||
value={config.fieldMapping?.resourceId || ""}
|
||||
onValueChange={(v) => updateFieldMapping("resourceId", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 제목 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">제목</Label>
|
||||
<Select
|
||||
value={config.fieldMapping?.title || ""}
|
||||
onValueChange={(v) => updateFieldMapping("title", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 시작일 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">시작일</Label>
|
||||
<Select
|
||||
value={config.fieldMapping?.startDate || ""}
|
||||
onValueChange={(v) => updateFieldMapping("startDate", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 종료일 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">종료일</Label>
|
||||
<Select
|
||||
value={config.fieldMapping?.endDate || ""}
|
||||
onValueChange={(v) => updateFieldMapping("endDate", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상태 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">상태 (선택)</Label>
|
||||
<Select
|
||||
value={config.fieldMapping?.status || "__none__"}
|
||||
onValueChange={(v) => updateFieldMapping("status", v === "__none__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 리소스 필드 매핑 */}
|
||||
{config.resourceTable && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="space-y-2 mt-3">
|
||||
<Label className="text-xs font-medium">리소스 필드</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* ID 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">ID 필드</Label>
|
||||
<Label className="text-[10px]">ID</Label>
|
||||
<Select
|
||||
value={config.resourceFieldMapping?.id || ""}
|
||||
onValueChange={(v) => updateResourceFieldMapping("id", v)}
|
||||
|
|
@ -477,7 +492,7 @@ export function TimelineSchedulerConfigPanel({
|
|||
|
||||
{/* 이름 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">이름 필드</Label>
|
||||
<Label className="text-[10px]">이름</Label>
|
||||
<Select
|
||||
value={config.resourceFieldMapping?.name || ""}
|
||||
onValueChange={(v) => updateResourceFieldMapping("name", v)}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types";
|
||||
import { TimelineSchedulerConfig, ZoomLevel } from "./types";
|
||||
|
||||
/**
|
||||
* 기본 타임라인 스케줄러 설정
|
||||
* - 기본적으로 schedule_mng 테이블 사용 (공통 스케줄 테이블)
|
||||
* - 필드 매핑은 schedule_mng 컬럼에 맞춤
|
||||
*/
|
||||
export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> = {
|
||||
// schedule_mng 테이블 기본 사용
|
||||
useCustomTable: false,
|
||||
scheduleType: "PRODUCTION", // 기본: 생산계획
|
||||
|
||||
// 표시 설정
|
||||
defaultZoomLevel: "day",
|
||||
editable: true,
|
||||
draggable: true,
|
||||
|
|
@ -33,8 +26,6 @@ export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> =
|
|||
showNavigation: true,
|
||||
showAddButton: true,
|
||||
height: 500,
|
||||
|
||||
// 상태별 색상
|
||||
statusColors: {
|
||||
planned: "#3b82f6", // blue-500
|
||||
in_progress: "#f59e0b", // amber-500
|
||||
|
|
@ -42,26 +33,20 @@ export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> =
|
|||
delayed: "#ef4444", // red-500
|
||||
cancelled: "#6b7280", // gray-500
|
||||
},
|
||||
|
||||
// schedule_mng 테이블 필드 매핑
|
||||
fieldMapping: {
|
||||
id: "schedule_id",
|
||||
id: "id",
|
||||
resourceId: "resource_id",
|
||||
title: "schedule_name",
|
||||
title: "title",
|
||||
startDate: "start_date",
|
||||
endDate: "end_date",
|
||||
status: "status",
|
||||
progress: "progress",
|
||||
},
|
||||
|
||||
// 리소스 필드 매핑 (equipment_mng 기준)
|
||||
resourceFieldMapping: {
|
||||
id: "equipment_code",
|
||||
name: "equipment_name",
|
||||
group: "equipment_type",
|
||||
id: "id",
|
||||
name: "name",
|
||||
group: "group",
|
||||
},
|
||||
|
||||
// 기본 리소스 테이블
|
||||
resourceTable: "equipment_mng",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -84,16 +69,6 @@ export const statusOptions = [
|
|||
{ value: "cancelled", label: "취소", color: "#6b7280" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 스케줄 타입 옵션
|
||||
*/
|
||||
export const scheduleTypeOptions: { value: ScheduleType; label: string }[] = [
|
||||
{ value: "PRODUCTION", label: "생산계획" },
|
||||
{ value: "MAINTENANCE", label: "정비계획" },
|
||||
{ value: "SHIPPING", label: "배차계획" },
|
||||
{ value: "WORK_ASSIGN", label: "작업배정" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 줌 레벨별 표시 일수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
import {
|
||||
TimelineSchedulerConfig,
|
||||
ScheduleItem,
|
||||
|
|
@ -12,9 +11,6 @@ import {
|
|||
} from "../types";
|
||||
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
||||
|
||||
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
|
||||
const SCHEDULE_TABLE = "schedule_mng";
|
||||
|
||||
/**
|
||||
* 날짜를 ISO 문자열로 변환 (시간 제외)
|
||||
*/
|
||||
|
|
@ -58,55 +54,23 @@ export function useTimelineData(
|
|||
return today;
|
||||
});
|
||||
|
||||
// 선택된 품목 코드 (좌측 테이블에서 선택된 데이터 기준)
|
||||
const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
|
||||
const selectedSourceKeysRef = useRef<string[]>([]);
|
||||
|
||||
// 표시 종료일 계산
|
||||
const viewEndDate = useMemo(() => {
|
||||
const days = zoomLevelDays[zoomLevel];
|
||||
return addDays(viewStartDate, days);
|
||||
}, [viewStartDate, zoomLevel]);
|
||||
|
||||
// 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용
|
||||
const tableName = config.useCustomTable && config.customTableName
|
||||
// 테이블명
|
||||
const tableName = config.useCustomTable
|
||||
? config.customTableName
|
||||
: SCHEDULE_TABLE;
|
||||
: config.selectedTable;
|
||||
|
||||
const resourceTableName = config.resourceTable;
|
||||
|
||||
// 필드 매핑을 JSON 문자열로 안정화 (객체 참조 변경 방지)
|
||||
const fieldMappingKey = useMemo(() => {
|
||||
return JSON.stringify(config.fieldMapping || {});
|
||||
}, [config.fieldMapping]);
|
||||
|
||||
const resourceFieldMappingKey = useMemo(() => {
|
||||
return JSON.stringify(config.resourceFieldMapping || {});
|
||||
}, [config.resourceFieldMapping]);
|
||||
|
||||
// 🆕 필드 매핑 정규화 (이전 형식 → 새 형식 변환) - useMemo로 메모이제이션
|
||||
const fieldMapping = useMemo(() => {
|
||||
const mapping = config.fieldMapping;
|
||||
if (!mapping) return defaultTimelineSchedulerConfig.fieldMapping!;
|
||||
|
||||
return {
|
||||
id: mapping.id || mapping.idField || "id",
|
||||
resourceId: mapping.resourceId || mapping.resourceIdField || "resource_id",
|
||||
title: mapping.title || mapping.titleField || "title",
|
||||
startDate: mapping.startDate || mapping.startDateField || "start_date",
|
||||
endDate: mapping.endDate || mapping.endDateField || "end_date",
|
||||
status: mapping.status || mapping.statusField || undefined,
|
||||
progress: mapping.progress || mapping.progressField || undefined,
|
||||
color: mapping.color || mapping.colorField || undefined,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fieldMappingKey]);
|
||||
|
||||
// 리소스 필드 매핑 - useMemo로 메모이제이션
|
||||
const resourceFieldMapping = useMemo(() => {
|
||||
return config.resourceFieldMapping || defaultTimelineSchedulerConfig.resourceFieldMapping!;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resourceFieldMappingKey]);
|
||||
// 필드 매핑
|
||||
const fieldMapping = config.fieldMapping || defaultTimelineSchedulerConfig.fieldMapping!;
|
||||
const resourceFieldMapping =
|
||||
config.resourceFieldMapping || defaultTimelineSchedulerConfig.resourceFieldMapping!;
|
||||
|
||||
// 스케줄 데이터 로드
|
||||
const fetchSchedules = useCallback(async () => {
|
||||
|
|
@ -124,96 +88,61 @@ export function useTimelineData(
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// schedule_mng 테이블 사용 시 필터 조건 구성
|
||||
const isScheduleMng = tableName === SCHEDULE_TABLE;
|
||||
const currentSourceKeys = selectedSourceKeysRef.current;
|
||||
|
||||
console.log("[useTimelineData] 스케줄 조회:", {
|
||||
tableName,
|
||||
scheduleType: config.scheduleType,
|
||||
sourceKeys: currentSourceKeys,
|
||||
});
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 10000,
|
||||
autoFilter: true,
|
||||
search: {
|
||||
// 표시 범위 내의 스케줄만 조회
|
||||
[fieldMapping.startDate]: {
|
||||
value: toDateString(viewEndDate),
|
||||
operator: "lte",
|
||||
},
|
||||
[fieldMapping.endDate]: {
|
||||
value: toDateString(viewStartDate),
|
||||
operator: "gte",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const responseData =
|
||||
response.data?.data?.data || response.data?.data || [];
|
||||
let rawData = Array.isArray(responseData) ? responseData : [];
|
||||
|
||||
// 클라이언트 측 필터링 적용 (schedule_mng 테이블인 경우)
|
||||
if (isScheduleMng) {
|
||||
// 스케줄 타입 필터
|
||||
if (config.scheduleType) {
|
||||
rawData = rawData.filter((row: any) => row.schedule_type === config.scheduleType);
|
||||
}
|
||||
|
||||
// 선택된 품목 필터 (source_group_key 기준)
|
||||
if (currentSourceKeys.length > 0) {
|
||||
rawData = rawData.filter((row: any) =>
|
||||
currentSourceKeys.includes(row.source_group_key)
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[useTimelineData] 필터링 후 스케줄:", rawData.length, "건");
|
||||
}
|
||||
|
||||
// schedule_mng 테이블용 필드 매핑 (고정)
|
||||
const scheduleMngFieldMapping = {
|
||||
id: "schedule_id",
|
||||
resourceId: "resource_id",
|
||||
title: "schedule_name",
|
||||
startDate: "start_date",
|
||||
endDate: "end_date",
|
||||
status: "status",
|
||||
progress: undefined, // actual_qty / plan_qty로 계산 가능
|
||||
};
|
||||
|
||||
// 사용할 필드 매핑 결정
|
||||
const effectiveMapping = isScheduleMng ? scheduleMngFieldMapping : fieldMapping;
|
||||
const rawData = Array.isArray(responseData) ? responseData : [];
|
||||
|
||||
// 데이터를 ScheduleItem 형태로 변환
|
||||
const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => {
|
||||
// 진행률 계산 (schedule_mng일 경우)
|
||||
let progress: number | undefined;
|
||||
if (isScheduleMng && row.plan_qty && row.plan_qty > 0) {
|
||||
progress = Math.round(((row.actual_qty || 0) / row.plan_qty) * 100);
|
||||
} else if (effectiveMapping.progress) {
|
||||
progress = Number(row[effectiveMapping.progress]) || 0;
|
||||
}
|
||||
const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => ({
|
||||
id: String(row[fieldMapping.id] || ""),
|
||||
resourceId: String(row[fieldMapping.resourceId] || ""),
|
||||
title: String(row[fieldMapping.title] || ""),
|
||||
startDate: row[fieldMapping.startDate] || "",
|
||||
endDate: row[fieldMapping.endDate] || "",
|
||||
status: fieldMapping.status
|
||||
? row[fieldMapping.status] || "planned"
|
||||
: "planned",
|
||||
progress: fieldMapping.progress
|
||||
? Number(row[fieldMapping.progress]) || 0
|
||||
: undefined,
|
||||
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
|
||||
data: row,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: String(row[effectiveMapping.id] || ""),
|
||||
resourceId: String(row[effectiveMapping.resourceId] || ""),
|
||||
title: String(row[effectiveMapping.title] || ""),
|
||||
startDate: row[effectiveMapping.startDate] || "",
|
||||
endDate: row[effectiveMapping.endDate] || "",
|
||||
status: effectiveMapping.status
|
||||
? row[effectiveMapping.status] || "planned"
|
||||
: "planned",
|
||||
progress,
|
||||
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
|
||||
data: row,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("[useTimelineData] 스케줄 로드 완료:", mappedSchedules.length, "건");
|
||||
setSchedules(mappedSchedules);
|
||||
} catch (err: any) {
|
||||
console.error("[useTimelineData] 스케줄 로드 오류:", err);
|
||||
setError(err.message || "스케줄 데이터 로드 중 오류 발생");
|
||||
setSchedules([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableName, externalSchedules, fieldMappingKey, config.scheduleType]);
|
||||
}, [
|
||||
tableName,
|
||||
externalSchedules,
|
||||
fieldMapping,
|
||||
viewStartDate,
|
||||
viewEndDate,
|
||||
]);
|
||||
|
||||
// 리소스 데이터 로드
|
||||
const fetchResources = useCallback(async () => {
|
||||
|
|
@ -255,9 +184,7 @@ export function useTimelineData(
|
|||
console.error("리소스 로드 오류:", err);
|
||||
setResources([]);
|
||||
}
|
||||
// resourceFieldMappingKey를 의존성으로 사용하여 객체 참조 변경 방지
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resourceTableName, externalResources, resourceFieldMappingKey]);
|
||||
}, [resourceTableName, externalResources, resourceFieldMapping]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -268,91 +195,6 @@ export function useTimelineData(
|
|||
fetchResources();
|
||||
}, [fetchResources]);
|
||||
|
||||
// 이벤트 버스 리스너 - 테이블 선택 변경 (품목 선택 시 해당 스케줄만 표시)
|
||||
useEffect(() => {
|
||||
const unsubscribeSelection = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_SELECTION_CHANGE,
|
||||
(payload) => {
|
||||
console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", {
|
||||
tableName: payload.tableName,
|
||||
selectedCount: payload.selectedCount,
|
||||
});
|
||||
|
||||
// 설정된 그룹 필드명 사용 (없으면 기본값들 fallback)
|
||||
const groupByField = config.sourceConfig?.groupByField;
|
||||
|
||||
// 선택된 데이터에서 source_group_key 추출
|
||||
const sourceKeys: string[] = [];
|
||||
for (const row of payload.selectedRows || []) {
|
||||
// 설정된 필드명 우선, 없으면 일반적인 필드명 fallback
|
||||
let key: string | undefined;
|
||||
if (groupByField && row[groupByField]) {
|
||||
key = row[groupByField];
|
||||
} else {
|
||||
// fallback: 일반적으로 사용되는 필드명들
|
||||
key = row.part_code || row.source_group_key || row.item_code;
|
||||
}
|
||||
|
||||
if (key && !sourceKeys.includes(key)) {
|
||||
sourceKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[useTimelineData] 선택된 그룹 키:", {
|
||||
groupByField,
|
||||
keys: sourceKeys,
|
||||
});
|
||||
|
||||
// 상태 업데이트 및 ref 동기화
|
||||
selectedSourceKeysRef.current = sourceKeys;
|
||||
setSelectedSourceKeys(sourceKeys);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribeSelection();
|
||||
};
|
||||
}, [config.sourceConfig?.groupByField]);
|
||||
|
||||
// 선택된 품목이 변경되면 스케줄 다시 로드
|
||||
useEffect(() => {
|
||||
if (tableName === SCHEDULE_TABLE) {
|
||||
console.log("[useTimelineData] 선택 품목 변경으로 스케줄 새로고침:", selectedSourceKeys);
|
||||
fetchSchedules();
|
||||
}
|
||||
}, [selectedSourceKeys, tableName, fetchSchedules]);
|
||||
|
||||
// 이벤트 버스 리스너 - 스케줄 생성 완료 및 테이블 새로고침
|
||||
useEffect(() => {
|
||||
// TABLE_REFRESH 이벤트 수신 - 스케줄 새로고침
|
||||
const unsubscribeRefresh = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_REFRESH,
|
||||
(payload) => {
|
||||
// schedule_mng 또는 해당 테이블에 대한 새로고침
|
||||
if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) {
|
||||
console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
|
||||
fetchSchedules();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// SCHEDULE_GENERATE_COMPLETE 이벤트 수신 - 스케줄 자동 생성 완료 시 새로고침
|
||||
const unsubscribeComplete = v2EventBus.subscribe(
|
||||
V2_EVENTS.SCHEDULE_GENERATE_COMPLETE,
|
||||
(payload) => {
|
||||
if (payload.success) {
|
||||
console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
|
||||
fetchSchedules();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribeRefresh();
|
||||
unsubscribeComplete();
|
||||
};
|
||||
}, [tableName, fetchSchedules]);
|
||||
|
||||
// 네비게이션 함수들
|
||||
const goToPrevious = useCallback(() => {
|
||||
const days = zoomLevelDays[zoomLevel];
|
||||
|
|
|
|||
|
|
@ -103,58 +103,16 @@ export interface ResourceFieldMapping {
|
|||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 타입 (schedule_mng.schedule_type)
|
||||
*/
|
||||
export type ScheduleType =
|
||||
| "PRODUCTION" // 생산계획
|
||||
| "MAINTENANCE" // 정비계획
|
||||
| "SHIPPING" // 배차계획
|
||||
| "WORK_ASSIGN"; // 작업배정
|
||||
|
||||
/**
|
||||
* 소스 데이터 설정 (스케줄 생성 기준이 되는 원본 데이터)
|
||||
* 예: 수주 데이터, 작업 요청 등
|
||||
*/
|
||||
export interface SourceDataConfig {
|
||||
/** 소스 테이블명 (예: sales_order_mng) */
|
||||
tableName?: string;
|
||||
|
||||
/** 기준일 필드 - 스케줄 종료일로 사용 (예: due_date, delivery_date) */
|
||||
dueDateField?: string;
|
||||
|
||||
/** 수량 필드 (예: balance_qty, order_qty) */
|
||||
quantityField?: string;
|
||||
|
||||
/** 그룹화 필드 - 품목/작업 단위 (예: part_code, item_code) */
|
||||
groupByField?: string;
|
||||
|
||||
/** 그룹명 표시 필드 (예: part_name, item_name) */
|
||||
groupNameField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임라인 스케줄러 설정
|
||||
*/
|
||||
export interface TimelineSchedulerConfig extends ComponentConfig {
|
||||
/** 스케줄 타입 (필터링 기준) - schedule_mng.schedule_type */
|
||||
scheduleType?: ScheduleType;
|
||||
|
||||
/** 스케줄 데이터 테이블명 (기본: schedule_mng, 커스텀 테이블 사용 시) */
|
||||
/** 스케줄 데이터 테이블명 */
|
||||
selectedTable?: string;
|
||||
|
||||
/** 커스텀 테이블 사용 여부 (false면 schedule_mng 사용) */
|
||||
useCustomTable?: boolean;
|
||||
|
||||
/** 커스텀 테이블명 */
|
||||
customTableName?: string;
|
||||
|
||||
/** 리소스 테이블명 (설비/작업자) */
|
||||
/** 리소스 테이블명 */
|
||||
resourceTable?: string;
|
||||
|
||||
/** 소스 데이터 설정 (스케줄 자동 생성 시 참조) */
|
||||
sourceConfig?: SourceDataConfig;
|
||||
|
||||
/** 스케줄 필드 매핑 */
|
||||
fieldMapping: FieldMapping;
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@ export type ButtonActionType =
|
|||
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||
| "event"; // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||
| "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
|
|
@ -252,12 +251,6 @@ export interface ButtonActionConfig {
|
|||
successMessage?: string; // 성공 메시지
|
||||
};
|
||||
};
|
||||
|
||||
// 이벤트 버스 발송 관련 (event 액션용)
|
||||
eventConfig?: {
|
||||
eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키)
|
||||
eventPayload?: Record<string, any>; // 이벤트 페이로드 (requestId는 자동 생성)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -423,9 +416,6 @@ export class ButtonActionExecutor {
|
|||
case "quickInsert":
|
||||
return await this.handleQuickInsert(config, context);
|
||||
|
||||
case "event":
|
||||
return await this.handleEvent(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
|
|
@ -7020,52 +7010,6 @@ export class ButtonActionExecutor {
|
|||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||
*/
|
||||
private static async handleEvent(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
const { eventConfig } = config;
|
||||
|
||||
if (!eventConfig?.eventName) {
|
||||
toast.error("이벤트 설정이 올바르지 않습니다.");
|
||||
console.error("[handleEvent] eventName이 설정되지 않음", { config });
|
||||
return false;
|
||||
}
|
||||
|
||||
// V2_EVENTS에서 이벤트 이름 가져오기
|
||||
const { v2EventBus, V2_EVENTS } = await import("@/lib/v2-core");
|
||||
|
||||
// 이벤트 이름 검증
|
||||
const eventName = eventConfig.eventName as keyof typeof V2_EVENTS;
|
||||
if (!V2_EVENTS[eventName]) {
|
||||
toast.error(`알 수 없는 이벤트: ${eventConfig.eventName}`);
|
||||
console.error("[handleEvent] 알 수 없는 이벤트", { eventName, V2_EVENTS });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 페이로드 구성
|
||||
const eventPayload = {
|
||||
requestId: crypto.randomUUID(),
|
||||
...eventConfig.eventPayload,
|
||||
};
|
||||
|
||||
console.log("[handleEvent] 이벤트 발송:", {
|
||||
eventName: V2_EVENTS[eventName],
|
||||
payload: eventPayload,
|
||||
});
|
||||
|
||||
// 이벤트 발송
|
||||
v2EventBus.emit(V2_EVENTS[eventName], eventPayload);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[handleEvent] 이벤트 발송 오류:", error);
|
||||
toast.error("이벤트 발송 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -7187,7 +7131,4 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
successMessage: "저장되었습니다.",
|
||||
errorMessage: "저장 중 오류가 발생했습니다.",
|
||||
},
|
||||
event: {
|
||||
type: "event",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,105 +33,6 @@ interface LegacyLayoutData {
|
|||
metadata?: any;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 중첩 컴포넌트 기본값 적용 헬퍼 함수 (재귀적)
|
||||
// ============================================
|
||||
function applyDefaultsToNestedComponents(components: any[]): any[] {
|
||||
if (!Array.isArray(components)) return components;
|
||||
|
||||
return components.map((nestedComp: any) => {
|
||||
if (!nestedComp) return nestedComp;
|
||||
|
||||
// 중첩 컴포넌트의 타입 확인 (componentType 또는 url에서 추출)
|
||||
let nestedComponentType = nestedComp.componentType;
|
||||
if (!nestedComponentType && nestedComp.url) {
|
||||
nestedComponentType = getComponentTypeFromUrl(nestedComp.url);
|
||||
}
|
||||
|
||||
// 결과 객체 초기화 (원본 복사)
|
||||
let result = { ...nestedComp };
|
||||
|
||||
// 🆕 탭 위젯인 경우 재귀적으로 탭 내부 컴포넌트도 처리
|
||||
if (nestedComponentType === "v2-tabs-widget") {
|
||||
const config = result.componentConfig || {};
|
||||
if (config.tabs && Array.isArray(config.tabs)) {
|
||||
result.componentConfig = {
|
||||
...config,
|
||||
tabs: config.tabs.map((tab: any) => {
|
||||
if (tab?.components && Array.isArray(tab.components)) {
|
||||
return {
|
||||
...tab,
|
||||
components: applyDefaultsToNestedComponents(tab.components),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 분할 패널인 경우 재귀적으로 내부 컴포넌트도 처리
|
||||
if (nestedComponentType === "v2-split-panel-layout") {
|
||||
const config = result.componentConfig || {};
|
||||
result.componentConfig = {
|
||||
...config,
|
||||
leftPanel: config.leftPanel ? {
|
||||
...config.leftPanel,
|
||||
components: applyDefaultsToNestedComponents(config.leftPanel.components || []),
|
||||
} : config.leftPanel,
|
||||
rightPanel: config.rightPanel ? {
|
||||
...config.rightPanel,
|
||||
components: applyDefaultsToNestedComponents(config.rightPanel.components || []),
|
||||
} : config.rightPanel,
|
||||
};
|
||||
}
|
||||
|
||||
// 컴포넌트 타입이 없으면 그대로 반환
|
||||
if (!nestedComponentType) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 중첩 컴포넌트의 기본값 가져오기
|
||||
const nestedDefaults = getDefaultsByUrl(`registry://${nestedComponentType}`);
|
||||
|
||||
// componentConfig가 있으면 기본값과 병합
|
||||
if (result.componentConfig && Object.keys(nestedDefaults).length > 0) {
|
||||
const mergedNestedConfig = mergeComponentConfig(nestedDefaults, result.componentConfig);
|
||||
return {
|
||||
...result,
|
||||
componentConfig: mergedNestedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 분할 패널 내부 컴포넌트 기본값 적용
|
||||
// ============================================
|
||||
function applyDefaultsToSplitPanelComponents(mergedConfig: Record<string, any>): Record<string, any> {
|
||||
const result = { ...mergedConfig };
|
||||
|
||||
// leftPanel.components 처리
|
||||
if (result.leftPanel?.components) {
|
||||
result.leftPanel = {
|
||||
...result.leftPanel,
|
||||
components: applyDefaultsToNestedComponents(result.leftPanel.components),
|
||||
};
|
||||
}
|
||||
|
||||
// rightPanel.components 처리
|
||||
if (result.rightPanel?.components) {
|
||||
result.rightPanel = {
|
||||
...result.rightPanel,
|
||||
components: applyDefaultsToNestedComponents(result.rightPanel.components),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// V2 → Legacy 변환 (로드 시)
|
||||
// ============================================
|
||||
|
|
@ -143,28 +44,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
|||
const components: LegacyComponentData[] = v2Layout.components.map((comp) => {
|
||||
const componentType = getComponentTypeFromUrl(comp.url);
|
||||
const defaults = getDefaultsByUrl(comp.url);
|
||||
let mergedConfig = mergeComponentConfig(defaults, comp.overrides);
|
||||
|
||||
// 🆕 분할 패널인 경우 내부 컴포넌트에도 기본값 적용
|
||||
if (componentType === "v2-split-panel-layout") {
|
||||
mergedConfig = applyDefaultsToSplitPanelComponents(mergedConfig);
|
||||
}
|
||||
|
||||
// 🆕 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용
|
||||
if (componentType === "v2-tabs-widget" && mergedConfig.tabs) {
|
||||
mergedConfig = {
|
||||
...mergedConfig,
|
||||
tabs: mergedConfig.tabs.map((tab: any) => {
|
||||
if (tab?.components) {
|
||||
return {
|
||||
...tab,
|
||||
components: applyDefaultsToNestedComponents(tab.components),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
}),
|
||||
};
|
||||
}
|
||||
const mergedConfig = mergeComponentConfig(defaults, comp.overrides);
|
||||
|
||||
// 🆕 overrides에서 상위 레벨 속성들 추출
|
||||
const overrides = comp.overrides || {};
|
||||
|
|
|
|||
|
|
@ -53,13 +53,6 @@ export const V2_EVENTS = {
|
|||
RELATED_BUTTON_REGISTER: "v2:related-button:register",
|
||||
RELATED_BUTTON_UNREGISTER: "v2:related-button:unregister",
|
||||
RELATED_BUTTON_SELECT: "v2:related-button:select",
|
||||
|
||||
// 스케줄 자동 생성
|
||||
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 type V2EventName = (typeof V2_EVENTS)[keyof typeof V2_EVENTS];
|
||||
|
|
@ -237,64 +230,6 @@ export interface V2RelatedButtonSelectEvent {
|
|||
selectedData: any[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 스케줄 자동 생성 이벤트
|
||||
// ============================================================================
|
||||
|
||||
/** 스케줄 타입 */
|
||||
export type ScheduleType = "PRODUCTION" | "MAINTENANCE" | "SHIPPING" | "WORK_ASSIGN";
|
||||
|
||||
/** 스케줄 생성 요청 이벤트 */
|
||||
export interface V2ScheduleGenerateRequestEvent {
|
||||
requestId: string;
|
||||
scheduleType: ScheduleType;
|
||||
sourceData?: any[]; // 선택 데이터 (없으면 TABLE_SELECTION_CHANGE로 받은 데이터 사용)
|
||||
period?: { start: string; end: string };
|
||||
}
|
||||
|
||||
/** 스케줄 미리보기 결과 이벤트 */
|
||||
export interface V2ScheduleGeneratePreviewEvent {
|
||||
requestId: string;
|
||||
scheduleType: ScheduleType;
|
||||
preview: {
|
||||
toCreate: any[];
|
||||
toDelete: any[];
|
||||
toUpdate: any[];
|
||||
summary: {
|
||||
createCount: number;
|
||||
deleteCount: number;
|
||||
updateCount: number;
|
||||
totalQty: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** 스케줄 적용 이벤트 */
|
||||
export interface V2ScheduleGenerateApplyEvent {
|
||||
requestId: string;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/** 스케줄 생성 완료 이벤트 */
|
||||
export interface V2ScheduleGenerateCompleteEvent {
|
||||
requestId: string;
|
||||
success: boolean;
|
||||
applied: {
|
||||
created: number;
|
||||
deleted: number;
|
||||
updated: number;
|
||||
};
|
||||
scheduleType: ScheduleType;
|
||||
targetTableName: string;
|
||||
}
|
||||
|
||||
/** 스케줄 생성 에러 이벤트 */
|
||||
export interface V2ScheduleGenerateErrorEvent {
|
||||
requestId: string;
|
||||
error: string;
|
||||
scheduleType?: ScheduleType;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 타입 맵핑 (타입 안전성을 위한)
|
||||
// ============================================================================
|
||||
|
|
@ -333,12 +268,6 @@ export interface V2EventPayloadMap {
|
|||
[V2_EVENTS.RELATED_BUTTON_REGISTER]: V2RelatedButtonRegisterEvent;
|
||||
[V2_EVENTS.RELATED_BUTTON_UNREGISTER]: V2RelatedButtonUnregisterEvent;
|
||||
[V2_EVENTS.RELATED_BUTTON_SELECT]: V2RelatedButtonSelectEvent;
|
||||
|
||||
[V2_EVENTS.SCHEDULE_GENERATE_REQUEST]: V2ScheduleGenerateRequestEvent;
|
||||
[V2_EVENTS.SCHEDULE_GENERATE_PREVIEW]: V2ScheduleGeneratePreviewEvent;
|
||||
[V2_EVENTS.SCHEDULE_GENERATE_APPLY]: V2ScheduleGenerateApplyEvent;
|
||||
[V2_EVENTS.SCHEDULE_GENERATE_COMPLETE]: V2ScheduleGenerateCompleteEvent;
|
||||
[V2_EVENTS.SCHEDULE_GENERATE_ERROR]: V2ScheduleGenerateErrorEvent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -32,9 +32,6 @@ export * from "./components";
|
|||
// 어댑터
|
||||
export * from "./adapters";
|
||||
|
||||
// 서비스
|
||||
export * from "./services";
|
||||
|
||||
// 초기화
|
||||
export { initV2Core, cleanupV2Core } from "./init";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
/**
|
||||
* 스케줄 생성 확인 다이얼로그
|
||||
*
|
||||
* 스케줄 자동 생성 시 미리보기 결과를 표시하고 확인을 받는 다이얼로그입니다.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Calendar, Plus, Trash2, RefreshCw } from "lucide-react";
|
||||
import type { SchedulePreviewResult } from "./ScheduleGeneratorService";
|
||||
|
||||
interface ScheduleConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
preview: SchedulePreviewResult | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
preview,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
}: ScheduleConfirmDialogProps) {
|
||||
if (!preview) return null;
|
||||
|
||||
const { summary, toCreate, toDelete, toUpdate } = preview;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Calendar className="h-5 w-5" />
|
||||
스케줄 생성 확인
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
다음과 같이 스케줄이 변경됩니다. 계속하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{/* 요약 정보 */}
|
||||
<div className="grid grid-cols-3 gap-3 py-4">
|
||||
<div className="flex flex-col items-center rounded-lg border bg-green-50 p-3 dark:bg-green-900/20">
|
||||
<Plus className="mb-1 h-5 w-5 text-green-600" />
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{summary.createCount}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">생성</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-lg border bg-red-50 p-3 dark:bg-red-900/20">
|
||||
<Trash2 className="mb-1 h-5 w-5 text-red-600" />
|
||||
<span className="text-2xl font-bold text-red-600">
|
||||
{summary.deleteCount}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">삭제</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-lg border bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<RefreshCw className="mb-1 h-5 w-5 text-blue-600" />
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{summary.updateCount}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">수정</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="space-y-4">
|
||||
{/* 생성될 스케줄 */}
|
||||
{toCreate.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
생성
|
||||
</Badge>
|
||||
{toCreate.length}건
|
||||
</h4>
|
||||
<div className="space-y-1 rounded-md border p-2">
|
||||
{toCreate.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{item.resource_name || item.resource_id}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{item.start_date} ~ {item.end_date} / {item.plan_qty}개
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{toCreate.length > 5 && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
... 외 {toCreate.length - 5}건
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제될 스케줄 */}
|
||||
{toDelete.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Badge variant="destructive">삭제</Badge>
|
||||
{toDelete.length}건
|
||||
</h4>
|
||||
<div className="space-y-1 rounded-md border border-red-200 bg-red-50/50 p-2 dark:border-red-800 dark:bg-red-900/10">
|
||||
{toDelete.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{item.resource_name || item.resource_id}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{item.start_date} ~ {item.end_date}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{toDelete.length > 5 && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
... 외 {toDelete.length - 5}건
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수정될 스케줄 */}
|
||||
{toUpdate.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Badge variant="secondary">수정</Badge>
|
||||
{toUpdate.length}건
|
||||
</h4>
|
||||
<div className="space-y-1 rounded-md border border-blue-200 bg-blue-50/50 p-2 dark:border-blue-800 dark:bg-blue-900/10">
|
||||
{toUpdate.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{item.resource_name || item.resource_id}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{item.start_date} ~ {item.end_date}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{toUpdate.length > 5 && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
... 외 {toUpdate.length - 5}건
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 총 수량 */}
|
||||
<div className="flex items-center justify-between rounded-md bg-muted p-3">
|
||||
<span className="text-sm font-medium">총 계획 수량</span>
|
||||
<span className="text-lg font-bold">
|
||||
{summary.totalQty.toLocaleString()}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isLoading ? "처리 중..." : "확인 및 적용"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
/**
|
||||
* 스케줄 자동 생성 서비스
|
||||
*
|
||||
* 이벤트 버스 기반으로 스케줄 자동 생성을 처리합니다.
|
||||
* - TABLE_SELECTION_CHANGE 이벤트로 선택 데이터 추적
|
||||
* - SCHEDULE_GENERATE_REQUEST 이벤트로 생성 요청 처리
|
||||
* - SCHEDULE_GENERATE_APPLY 이벤트로 적용 처리
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { v2EventBus } from "../events/EventBus";
|
||||
import { V2_EVENTS } from "../events/types";
|
||||
import type {
|
||||
ScheduleType,
|
||||
V2ScheduleGenerateRequestEvent,
|
||||
V2ScheduleGenerateApplyEvent,
|
||||
} from "../events/types";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ============================================================================
|
||||
// 타입 정의
|
||||
// ============================================================================
|
||||
|
||||
/** 스케줄 생성 설정 */
|
||||
export interface ScheduleGenerationConfig {
|
||||
// 스케줄 타입
|
||||
scheduleType: ScheduleType;
|
||||
|
||||
// 소스 설정
|
||||
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 또는 전용 테이블)
|
||||
};
|
||||
}
|
||||
|
||||
/** 미리보기 결과 */
|
||||
export interface SchedulePreviewResult {
|
||||
toCreate: any[];
|
||||
toDelete: any[];
|
||||
toUpdate: any[];
|
||||
summary: {
|
||||
createCount: number;
|
||||
deleteCount: number;
|
||||
updateCount: number;
|
||||
totalQty: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 훅 반환 타입 */
|
||||
export interface UseScheduleGeneratorReturn {
|
||||
// 상태
|
||||
isLoading: boolean;
|
||||
showConfirmDialog: boolean;
|
||||
previewResult: SchedulePreviewResult | null;
|
||||
|
||||
// 핸들러
|
||||
handleConfirm: (confirmed: boolean) => void;
|
||||
closeDialog: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 유틸리티 함수
|
||||
// ============================================================================
|
||||
|
||||
/** 기본 기간 계산 (현재 월) */
|
||||
function getDefaultPeriod(): { start: string; end: string } {
|
||||
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],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 스케줄 생성 서비스 훅
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 스케줄 자동 생성 훅
|
||||
*
|
||||
* @param scheduleConfig 스케줄 생성 설정
|
||||
* @returns 상태 및 핸들러
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const config: ScheduleGenerationConfig = {
|
||||
* scheduleType: "PRODUCTION",
|
||||
* source: { tableName: "sales_order_mng", groupByField: "part_code", quantityField: "balance_qty" },
|
||||
* resource: { type: "ITEM", idField: "part_code", nameField: "part_name" },
|
||||
* rules: { leadTimeDays: 3, dailyCapacity: 100 },
|
||||
* target: { tableName: "schedule_mng" },
|
||||
* };
|
||||
*
|
||||
* const { showConfirmDialog, previewResult, handleConfirm } = useScheduleGenerator(config);
|
||||
* ```
|
||||
*/
|
||||
export function useScheduleGenerator(
|
||||
scheduleConfig?: ScheduleGenerationConfig | null
|
||||
): UseScheduleGeneratorReturn {
|
||||
// 상태
|
||||
const [selectedData, setSelectedData] = useState<any[]>([]);
|
||||
const [previewResult, setPreviewResult] =
|
||||
useState<SchedulePreviewResult | null>(null);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentRequestIdRef = useRef<string>("");
|
||||
const currentConfigRef = useRef<ScheduleGenerationConfig | null>(null);
|
||||
|
||||
// 1. 테이블 선택 데이터 추적 (TABLE_SELECTION_CHANGE 이벤트 수신)
|
||||
useEffect(() => {
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_SELECTION_CHANGE,
|
||||
(payload) => {
|
||||
// scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장
|
||||
if (scheduleConfig?.source?.tableName) {
|
||||
if (payload.tableName === scheduleConfig.source.tableName) {
|
||||
setSelectedData(payload.selectedRows);
|
||||
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (특정 테이블):", payload.selectedCount, "건");
|
||||
}
|
||||
} else {
|
||||
// scheduleConfig가 없으면 모든 테이블의 선택 데이터를 저장
|
||||
setSelectedData(payload.selectedRows);
|
||||
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (모든 테이블):", payload.selectedCount, "건");
|
||||
}
|
||||
}
|
||||
);
|
||||
return unsubscribe;
|
||||
}, [scheduleConfig?.source?.tableName]);
|
||||
|
||||
// 2. 스케줄 생성 요청 처리 (SCHEDULE_GENERATE_REQUEST 수신)
|
||||
useEffect(() => {
|
||||
console.log("[useScheduleGenerator] 이벤트 구독 시작");
|
||||
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.SCHEDULE_GENERATE_REQUEST,
|
||||
async (payload: V2ScheduleGenerateRequestEvent) => {
|
||||
console.log("[useScheduleGenerator] SCHEDULE_GENERATE_REQUEST 수신:", payload);
|
||||
|
||||
// 이벤트에서 config가 오면 사용, 없으면 기존 scheduleConfig 또는 기본 config 사용
|
||||
const configToUse = (payload as any).config || scheduleConfig || {
|
||||
// 기본 설정 (생산계획 화면용)
|
||||
scheduleType: payload.scheduleType || "PRODUCTION",
|
||||
source: {
|
||||
tableName: "sales_order_mng",
|
||||
groupByField: "part_code",
|
||||
quantityField: "balance_qty",
|
||||
dueDateField: "delivery_date", // 기준일 필드 (납기일)
|
||||
},
|
||||
resource: {
|
||||
type: "ITEM",
|
||||
idField: "part_code",
|
||||
nameField: "part_name",
|
||||
},
|
||||
rules: {
|
||||
leadTimeDays: 3,
|
||||
dailyCapacity: 100,
|
||||
},
|
||||
target: {
|
||||
tableName: "schedule_mng",
|
||||
},
|
||||
};
|
||||
|
||||
console.log("[useScheduleGenerator] 사용할 config:", configToUse);
|
||||
|
||||
// scheduleType이 지정되어 있고 config도 있는 경우, 타입 일치 확인
|
||||
if (scheduleConfig && payload.scheduleType && payload.scheduleType !== scheduleConfig.scheduleType) {
|
||||
console.log("[useScheduleGenerator] scheduleType 불일치, 무시");
|
||||
return;
|
||||
}
|
||||
|
||||
// sourceData: 이벤트 페이로드 > 상태 저장된 선택 데이터 > 빈 배열
|
||||
const dataToUse = payload.sourceData || selectedData;
|
||||
const periodToUse = payload.period || getDefaultPeriod();
|
||||
|
||||
console.log("[useScheduleGenerator] 사용할 sourceData:", dataToUse.length, "건");
|
||||
console.log("[useScheduleGenerator] 사용할 period:", periodToUse);
|
||||
|
||||
currentRequestIdRef.current = payload.requestId;
|
||||
currentConfigRef.current = configToUse;
|
||||
setIsLoading(true);
|
||||
toast.loading("스케줄 생성 중...", { id: "schedule-generate" });
|
||||
|
||||
try {
|
||||
// 미리보기 API 호출
|
||||
const response = await apiClient.post("/schedule/preview", {
|
||||
config: configToUse,
|
||||
scheduleType: payload.scheduleType,
|
||||
sourceData: dataToUse,
|
||||
period: periodToUse,
|
||||
});
|
||||
|
||||
console.log("[useScheduleGenerator] 미리보기 응답:", response.data);
|
||||
|
||||
if (!response.data.success) {
|
||||
toast.error(response.data.message || "미리보기 생성 실패", { id: "schedule-generate" });
|
||||
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_ERROR, {
|
||||
requestId: payload.requestId,
|
||||
error: response.data.message || "미리보기 생성 실패",
|
||||
scheduleType: payload.scheduleType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewResult(response.data.preview);
|
||||
setShowConfirmDialog(true);
|
||||
toast.success("스케줄 미리보기가 생성되었습니다.", { id: "schedule-generate" });
|
||||
|
||||
// 미리보기 결과 이벤트 발송 (다른 컴포넌트가 필요할 수 있음)
|
||||
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_PREVIEW, {
|
||||
requestId: payload.requestId,
|
||||
scheduleType: payload.scheduleType,
|
||||
preview: response.data.preview,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[ScheduleGeneratorService] 미리보기 오류:", error);
|
||||
toast.error("스케줄 생성 중 오류가 발생했습니다.", { id: "schedule-generate" });
|
||||
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_ERROR, {
|
||||
requestId: payload.requestId,
|
||||
error: error.message,
|
||||
scheduleType: payload.scheduleType,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
return unsubscribe;
|
||||
}, [selectedData, scheduleConfig]);
|
||||
|
||||
// 3. 스케줄 적용 처리 (SCHEDULE_GENERATE_APPLY 수신)
|
||||
useEffect(() => {
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.SCHEDULE_GENERATE_APPLY,
|
||||
async (payload: V2ScheduleGenerateApplyEvent) => {
|
||||
if (payload.requestId !== currentRequestIdRef.current) return;
|
||||
|
||||
if (!payload.confirmed) {
|
||||
setShowConfirmDialog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 저장된 config 또는 기존 scheduleConfig 사용
|
||||
const configToUse = currentConfigRef.current || scheduleConfig;
|
||||
|
||||
setIsLoading(true);
|
||||
toast.loading("스케줄 적용 중...", { id: "schedule-apply" });
|
||||
|
||||
try {
|
||||
const response = await apiClient.post("/schedule/apply", {
|
||||
config: configToUse,
|
||||
preview: previewResult,
|
||||
options: { deleteExisting: true, updateMode: "replace" },
|
||||
});
|
||||
|
||||
if (!response.data.success) {
|
||||
toast.error(response.data.message || "스케줄 적용 실패", { id: "schedule-apply" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 완료 이벤트 발송
|
||||
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, {
|
||||
requestId: payload.requestId,
|
||||
success: true,
|
||||
applied: response.data.applied,
|
||||
scheduleType: configToUse?.scheduleType || "PRODUCTION",
|
||||
targetTableName: configToUse?.target?.tableName || "schedule_mng",
|
||||
});
|
||||
|
||||
// 테이블 새로고침 이벤트 발송
|
||||
v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, {
|
||||
tableName: configToUse?.target?.tableName || "schedule_mng",
|
||||
});
|
||||
|
||||
toast.success(
|
||||
`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`,
|
||||
{ id: "schedule-apply" }
|
||||
);
|
||||
setShowConfirmDialog(false);
|
||||
setPreviewResult(null);
|
||||
} catch (error: any) {
|
||||
console.error("[ScheduleGeneratorService] 적용 오류:", error);
|
||||
toast.error("스케줄 적용 중 오류가 발생했습니다.", { id: "schedule-apply" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
return unsubscribe;
|
||||
}, [previewResult, scheduleConfig]);
|
||||
|
||||
// 확인 다이얼로그 핸들러
|
||||
const handleConfirm = useCallback((confirmed: boolean) => {
|
||||
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_APPLY, {
|
||||
requestId: currentRequestIdRef.current,
|
||||
confirmed,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 다이얼로그 닫기
|
||||
const closeDialog = useCallback(() => {
|
||||
setShowConfirmDialog(false);
|
||||
setPreviewResult(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
showConfirmDialog,
|
||||
previewResult,
|
||||
handleConfirm,
|
||||
closeDialog,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 스케줄 확인 다이얼로그 컴포넌트
|
||||
// ============================================================================
|
||||
|
||||
export { ScheduleConfirmDialog } from "./ScheduleConfirmDialog";
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
/**
|
||||
* V2 서비스 모듈
|
||||
*
|
||||
* 이벤트 버스 기반 서비스들을 export합니다.
|
||||
*/
|
||||
|
||||
export {
|
||||
useScheduleGenerator,
|
||||
type ScheduleGenerationConfig,
|
||||
type SchedulePreviewResult,
|
||||
type UseScheduleGeneratorReturn,
|
||||
} from "./ScheduleGeneratorService";
|
||||
|
||||
export { ScheduleConfirmDialog } from "./ScheduleConfirmDialog";
|
||||
|
|
@ -259,6 +259,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -300,6 +301,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -333,6 +335,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -2663,6 +2666,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
|
|
@ -3316,6 +3320,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.6"
|
||||
},
|
||||
|
|
@ -3383,6 +3388,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
|
|
@ -3696,6 +3702,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
|
|
@ -6196,6 +6203,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -6206,6 +6214,7 @@
|
|||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -6239,6 +6248,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
|
|
@ -6321,6 +6331,7 @@
|
|||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
|
|
@ -6953,6 +6964,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -8103,7 +8115,8 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
|
|
@ -8425,6 +8438,7 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9184,6 +9198,7 @@
|
|||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -9272,6 +9287,7 @@
|
|||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
|
|
@ -9373,6 +9389,7 @@
|
|||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
|
@ -10523,6 +10540,7 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -11304,7 +11322,8 @@
|
|||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
|
|
@ -12603,6 +12622,7 @@
|
|||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -12898,6 +12918,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
|
|
@ -12927,6 +12948,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
|
|
@ -12975,6 +12997,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
|
@ -13101,6 +13124,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -13170,6 +13194,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -13188,6 +13213,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -13514,6 +13540,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -13536,7 +13563,8 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -14560,7 +14588,8 @@
|
|||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
|
@ -14648,6 +14677,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -14996,6 +15026,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export interface NumberingRuleConfig {
|
|||
|
||||
// 카테고리 조건 (특정 카테고리 값일 때만 이 규칙 적용)
|
||||
categoryColumn?: string; // 카테고리 조건 컬럼명 (예: 'type', 'material')
|
||||
categoryValueId?: number; // 카테고리 값 ID (category_values.value_id)
|
||||
categoryValueId?: number; // 카테고리 값 ID (category_values_test.value_id)
|
||||
categoryValueLabel?: string; // 카테고리 값 라벨 (조회 시 조인)
|
||||
|
||||
// 메타 정보
|
||||
|
|
|
|||
Loading…
Reference in New Issue