Compare commits

..

No commits in common. "dd1ddd6418fbd51cdde7808725c59be8f02e30a0" and "444791189272fe6e4ff0a9a793ca7232cfcbec62" have entirely different histories.

46 changed files with 979 additions and 4685 deletions

View File

@ -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); // 권한 그룹 관리

View File

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

View File

@ -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 || "스케줄 삭제 중 오류가 발생했습니다.",
});
}
};
}

View File

@ -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 삭제 (해당 그룹만 - 하위 그룹은 프론트엔드에서 순차 삭제)

View File

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

View File

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

View File

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

View File

@ -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 = '*'

View File

@ -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 };
}
/**
* + (최적화: 배치 )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">
&quot;{deletingGroup?.group_name}&quot; ?
</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">
&quot;{deletingScreen?.screenName}&quot; ?
</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">

View File

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

View File

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

View File

@ -321,7 +321,6 @@ export function TabsWidget({
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
isDesignMode={isDesignMode}
isInteractive={!isDesignMode}
/>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "작업배정" },
];
/**
*
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,9 +32,6 @@ export * from "./components";
// 어댑터
export * from "./adapters";
// 서비스
export * from "./services";
// 초기화
export { initV2Core, cleanupV2Core } from "./init";

View File

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

View File

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

View File

@ -1,14 +0,0 @@
/**
* V2
*
* export합니다.
*/
export {
useScheduleGenerator,
type ScheduleGenerationConfig,
type SchedulePreviewResult,
type UseScheduleGeneratorReturn,
} from "./ScheduleGeneratorService";
export { ScheduleConfirmDialog } from "./ScheduleConfirmDialog";

View File

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

View File

@ -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; // 카테고리 값 라벨 (조회 시 조인)
// 메타 정보