feat: 스케줄 자동 생성 기능 및 이벤트 발송 설정 추가

- 스케줄 자동 생성 관련 라우트를 추가하여 API 연동을 구현하였습니다.
- 버튼 설정 패널에 이벤트 발송 옵션을 추가하여 사용자가 이벤트를 설정할 수 있도록 하였습니다.
- 타임라인 스케줄러 컴포넌트에서 스케줄 데이터 필터링 및 선택된 품목에 따른 스케줄 로드 기능을 개선하였습니다.
- 이벤트 버스를 통해 다른 컴포넌트와의 상호작용을 강화하였습니다.
- 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다.
This commit is contained in:
kjs 2026-02-03 09:34:25 +09:00
parent 61b67c3619
commit f845dadc5d
19 changed files with 2026 additions and 297 deletions

View File

@ -64,6 +64,7 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
@ -246,6 +247,7 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/roles", roleRoutes); // 권한 그룹 관리

View File

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

View File

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

View File

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

View File

@ -289,29 +289,46 @@ export class TableManagementService {
companyCode,
});
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
try {
// menu_objid 컬럼이 있는지 먼저 확인
const columnCheck = await query<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
);
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
if (columnCheck.length > 0) {
// menu_objid 컬럼이 있는 경우
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} else {
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} catch (mappingError: any) {
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
error: mappingError.message,
});
}
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size,
@ -4163,31 +4180,46 @@ export class TableManagementService {
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>(
`SELECT DISTINCT ON (logical_column_name, menu_objid)
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code IN ($2, '*')
ORDER BY logical_column_name, menu_objid,
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
[tableName, companyCode]
);
try {
// menu_objid 컬럼이 있는지 먼저 확인
const columnCheck = await query<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
);
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
if (columnCheck.length > 0) {
const mappings = await query<any>(
`SELECT DISTINCT ON (logical_column_name, menu_objid)
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code IN ($2, '*')
ORDER BY logical_column_name, menu_objid,
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
[tableName, companyCode]
);
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} else {
logger.info("⚠️ menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} catch (mappingError: any) {
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
error: mappingError.message,
});
}
logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size,

View File

@ -26,8 +26,11 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
function ScreenViewPage() {
// 스케줄 자동 생성 서비스 활성화
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
@ -991,6 +994,16 @@ function ScreenViewPage() {
});
}}
/>
{/* 스케줄 생성 확인 다이얼로그 */}
<ScheduleConfirmDialog
open={showConfirmDialog}
onOpenChange={(open) => !open && closeDialog()}
preview={previewResult}
onConfirm={() => handleConfirm(true)}
onCancel={closeDialog}
isLoading={scheduleLoading}
/>
</div>
</TableOptionsProvider>
</ActiveTabProvider>

View File

@ -831,6 +831,9 @@ 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>
@ -3536,6 +3539,99 @@ 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,6 +321,7 @@ export function TabsWidget({
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
isDesignMode={isDesignMode}
isInteractive={!isDesignMode}
/>
</div>
);

View File

@ -11,6 +11,7 @@ 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
@ -267,8 +268,9 @@ export function TableGroupedComponent({
[columns]
);
// 선택 변경 시 콜백
// 선택 변경 시 콜백 및 이벤트 발송
useEffect(() => {
// 기존 콜백 호출
if (onSelectionChange && selectedItems.length >= 0) {
onSelectionChange({
selectedGroups: groups
@ -278,7 +280,21 @@ export function TableGroupedComponent({
isAllSelected,
});
}
}, [selectedItems, groups, isAllSelected, onSelectionChange]);
// 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]);
// 그룹 토글 핸들러
const handleGroupToggle = useCallback(

View File

@ -270,17 +270,20 @@ export function TimelineSchedulerComponent({
);
}
// 리소스 없음 (스케줄도 없는 경우에만 표시)
if (effectiveResources.length === 0) {
// 스케줄 데이터 없음
if (schedules.length === 0) {
return (
<div
className="w-full flex items-center justify-center bg-muted/10 rounded-lg"
className="w-full flex items-center justify-center bg-muted/10 rounded-lg border"
style={{ height: config.height || 500 }}
>
<div className="text-center text-muted-foreground">
<Calendar className="h-8 w-8 mx-auto mb-2" />
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium"> </p>
<p className="text-xs mt-1"> </p>
<p className="text-xs mt-2 max-w-[200px]">
,<br />
</p>
</div>
</div>
);

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 } from "./types";
import { zoomLevelOptions, statusOptions } from "./config";
import { TimelineSchedulerConfig, ScheduleType, SourceDataConfig } from "./types";
import { zoomLevelOptions, scheduleTypeOptions } from "./config";
interface TimelineSchedulerConfigPanelProps {
config: TimelineSchedulerConfig;
@ -56,18 +56,11 @@ export function TimelineSchedulerConfigPanel({
config,
onChange,
}: TimelineSchedulerConfigPanelProps) {
// 🐛 디버깅: 받은 config 출력
console.log("🐛 [TimelineSchedulerConfigPanel] config:", {
selectedTable: config.selectedTable,
fieldMapping: config.fieldMapping,
fieldMappingKeys: config.fieldMapping ? Object.keys(config.fieldMapping) : [],
});
const [tables, setTables] = useState<TableInfo[]>([]);
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
const [loading, setLoading] = useState(false);
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
const [resourceTableSelectOpen, setResourceTableSelectOpen] = useState(false);
// 테이블 목록 로드
@ -93,17 +86,17 @@ export function TimelineSchedulerConfigPanel({
loadTables();
}, []);
// 케줄 테이블 컬럼 로드
// 스 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.selectedTable) {
setTableColumns([]);
const loadSourceColumns = async () => {
if (!config.sourceConfig?.tableName) {
setSourceColumns([]);
return;
}
try {
const columns = await tableTypeApi.getColumns(config.selectedTable);
const columns = await tableTypeApi.getColumns(config.sourceConfig.tableName);
if (Array.isArray(columns)) {
setTableColumns(
setSourceColumns(
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
@ -111,12 +104,12 @@ export function TimelineSchedulerConfigPanel({
);
}
} catch (err) {
console.error("컬럼 로드 오류:", err);
setTableColumns([]);
console.error("소스 컬럼 로드 오류:", err);
setSourceColumns([]);
}
};
loadColumns();
}, [config.selectedTable]);
loadSourceColumns();
}, [config.sourceConfig?.tableName]);
// 리소스 테이블 컬럼 로드
useEffect(() => {
@ -148,40 +141,13 @@ export function TimelineSchedulerConfigPanel({
onChange({ ...config, ...updates });
};
// 🆕 이전 형식(idField)과 새 형식(id) 모두 지원하는 헬퍼 함수
const getFieldMappingValue = (newKey: string, oldKey: string): string => {
const mapping = config.fieldMapping as Record<string, any> | undefined;
if (!mapping) return "";
return mapping[newKey] || mapping[oldKey] || "";
};
// 필드 매핑 업데이트 (새 형식으로 저장하고, 이전 형식 키 삭제)
const updateFieldMapping = (field: string, value: string) => {
const currentMapping = { ...config.fieldMapping } as Record<string, any>;
// 이전 형식 키 매핑
const oldKeyMap: Record<string, string> = {
id: "idField",
resourceId: "resourceIdField",
title: "titleField",
startDate: "startDateField",
endDate: "endDateField",
status: "statusField",
progress: "progressField",
color: "colorField",
};
// 새 형식으로 저장
currentMapping[field] = value;
// 이전 형식 키가 있으면 삭제
const oldKey = oldKeyMap[field];
if (oldKey && currentMapping[oldKey]) {
delete currentMapping[oldKey];
}
// 소스 데이터 설정 업데이트
const updateSourceConfig = (updates: Partial<SourceDataConfig>) => {
updateConfig({
fieldMapping: currentMapping,
sourceConfig: {
...config.sourceConfig,
...updates,
},
});
};
@ -199,35 +165,54 @@ export function TimelineSchedulerConfigPanel({
return (
<div className="space-y-4 p-4">
<Accordion type="multiple" defaultValue={["table", "mapping", "display"]}>
{/* 테이블 설정 */}
<AccordionItem value="table">
<Accordion type="multiple" defaultValue={["source", "resource", "display"]}>
{/* 소스 데이터 설정 (스케줄 생성 기준) */}
<AccordionItem value="source">
<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>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
<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}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSelectOpen}
aria-expanded={sourceTableSelectOpen}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{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
{config.sourceConfig?.tableName ? (
tables.find((t) => t.tableName === config.sourceConfig?.tableName)
?.displayName || config.sourceConfig.tableName
) : (
"테이블 선택..."
"소스 테이블 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
@ -257,15 +242,15 @@ export function TimelineSchedulerConfigPanel({
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({ selectedTable: table.tableName });
setTableSelectOpen(false);
updateSourceConfig({ tableName: table.tableName });
setSourceTableSelectOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.selectedTable === table.tableName
config.sourceConfig?.tableName === table.tableName
? "opacity-100"
: "opacity-0"
)}
@ -285,9 +270,112 @@ 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}
@ -361,152 +449,15 @@ 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={getFieldMappingValue("id", "idField")}
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={getFieldMappingValue("resourceId", "resourceIdField")}
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={getFieldMappingValue("title", "titleField")}
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={getFieldMappingValue("startDate", "startDateField")}
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={getFieldMappingValue("endDate", "endDateField")}
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={getFieldMappingValue("status", "statusField") || "__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-3">
<div className="space-y-2 mt-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>
<Label className="text-[10px]">ID </Label>
<Select
value={config.resourceFieldMapping?.id || ""}
onValueChange={(v) => updateResourceFieldMapping("id", v)}
@ -526,7 +477,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,11 +1,18 @@
"use client";
import { TimelineSchedulerConfig, ZoomLevel } from "./types";
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types";
/**
*
* - schedule_mng ( )
* - schedule_mng
*/
export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> = {
// schedule_mng 테이블 기본 사용
useCustomTable: false,
scheduleType: "PRODUCTION", // 기본: 생산계획
// 표시 설정
defaultZoomLevel: "day",
editable: true,
draggable: true,
@ -26,6 +33,8 @@ export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> =
showNavigation: true,
showAddButton: true,
height: 500,
// 상태별 색상
statusColors: {
planned: "#3b82f6", // blue-500
in_progress: "#f59e0b", // amber-500
@ -33,20 +42,26 @@ export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> =
delayed: "#ef4444", // red-500
cancelled: "#6b7280", // gray-500
},
// schedule_mng 테이블 필드 매핑
fieldMapping: {
id: "id",
id: "schedule_id",
resourceId: "resource_id",
title: "title",
title: "schedule_name",
startDate: "start_date",
endDate: "end_date",
status: "status",
progress: "progress",
},
// 리소스 필드 매핑 (equipment_mng 기준)
resourceFieldMapping: {
id: "id",
name: "name",
group: "group",
id: "equipment_code",
name: "equipment_name",
group: "equipment_type",
},
// 기본 리소스 테이블
resourceTable: "equipment_mng",
};
/**
@ -69,6 +84,16 @@ 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,7 +1,8 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
import {
TimelineSchedulerConfig,
ScheduleItem,
@ -11,6 +12,9 @@ import {
} from "../types";
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
const SCHEDULE_TABLE = "schedule_mng";
/**
* ISO ( )
*/
@ -54,16 +58,20 @@ 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]);
// 테이블명
const tableName = config.useCustomTable
// 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용
const tableName = config.useCustomTable && config.customTableName
? config.customTableName
: config.selectedTable;
: SCHEDULE_TABLE;
const resourceTableName = config.resourceTable;
@ -116,6 +124,16 @@ 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`,
{
@ -127,36 +145,75 @@ export function useTimelineData(
const responseData =
response.data?.data?.data || response.data?.data || [];
const rawData = Array.isArray(responseData) ? responseData : [];
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;
// 데이터를 ScheduleItem 형태로 변환
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,
}));
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;
}
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);
}
// fieldMappingKey를 의존성으로 사용하여 객체 참조 변경 방지
// viewStartDate, viewEndDate는 API 호출에 사용되지 않으므로 제거
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, externalSchedules, fieldMappingKey]);
}, [tableName, externalSchedules, fieldMappingKey, config.scheduleType]);
// 리소스 데이터 로드
const fetchResources = useCallback(async () => {
@ -211,6 +268,91 @@ 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,16 +103,58 @@ 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,7 +30,8 @@ export type ButtonActionType =
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
| "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
| "event"; // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
/**
*
@ -251,6 +252,12 @@ export interface ButtonActionConfig {
successMessage?: string; // 성공 메시지
};
};
// 이벤트 버스 발송 관련 (event 액션용)
eventConfig?: {
eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키)
eventPayload?: Record<string, any>; // 이벤트 페이로드 (requestId는 자동 생성)
};
}
/**
@ -416,6 +423,9 @@ 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;
@ -7010,6 +7020,52 @@ 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;
}
}
}
/**
@ -7131,4 +7187,7 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
successMessage: "저장되었습니다.",
errorMessage: "저장 중 오류가 발생했습니다.",
},
event: {
type: "event",
},
};

View File

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

View File

@ -0,0 +1,208 @@
/**
*
*
* .
*/
"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

@ -0,0 +1,346 @@
/**
*
*
* .
* - 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

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