From f845dadc5da1f598b29cb46a69e225738518271a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 3 Feb 2026 09:34:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=EC=86=A1=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스케줄 자동 생성 관련 라우트를 추가하여 API 연동을 구현하였습니다. - 버튼 설정 패널에 이벤트 발송 옵션을 추가하여 사용자가 이벤트를 설정할 수 있도록 하였습니다. - 타임라인 스케줄러 컴포넌트에서 스케줄 데이터 필터링 및 선택된 품목에 따른 스케줄 로드 기능을 개선하였습니다. - 이벤트 버스를 통해 다른 컴포넌트와의 상호작용을 강화하였습니다. - 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다. --- backend-node/src/app.ts | 2 + .../src/controllers/scheduleController.ts | 223 ++++++++ backend-node/src/routes/scheduleRoutes.ts | 33 ++ backend-node/src/services/scheduleService.ts | 520 ++++++++++++++++++ .../src/services/tableManagementService.ts | 116 ++-- .../app/(main)/screens/[screenId]/page.tsx | 13 + .../config-panels/ButtonConfigPanel.tsx | 96 ++++ .../components/screen/widgets/TabsWidget.tsx | 1 + .../TableGroupedComponent.tsx | 20 +- .../TimelineSchedulerComponent.tsx | 13 +- .../TimelineSchedulerConfigPanel.tsx | 381 ++++++------- .../v2-timeline-scheduler/config.ts | 39 +- .../hooks/useTimelineData.ts | 188 ++++++- .../components/v2-timeline-scheduler/types.ts | 46 +- frontend/lib/utils/buttonActions.ts | 61 +- frontend/lib/v2-core/index.ts | 3 + .../services/ScheduleConfirmDialog.tsx | 208 +++++++ .../services/ScheduleGeneratorService.ts | 346 ++++++++++++ frontend/lib/v2-core/services/index.ts | 14 + 19 files changed, 2026 insertions(+), 297 deletions(-) create mode 100644 backend-node/src/controllers/scheduleController.ts create mode 100644 backend-node/src/routes/scheduleRoutes.ts create mode 100644 backend-node/src/services/scheduleService.ts create mode 100644 frontend/lib/v2-core/services/ScheduleConfirmDialog.tsx create mode 100644 frontend/lib/v2-core/services/ScheduleGeneratorService.ts create mode 100644 frontend/lib/v2-core/services/index.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 771ab80d..35e4182d 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -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); // 권한 그룹 관리 diff --git a/backend-node/src/controllers/scheduleController.ts b/backend-node/src/controllers/scheduleController.ts new file mode 100644 index 00000000..15012053 --- /dev/null +++ b/backend-node/src/controllers/scheduleController.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 || "스케줄 삭제 중 오류가 발생했습니다.", + }); + } + }; +} diff --git a/backend-node/src/routes/scheduleRoutes.ts b/backend-node/src/routes/scheduleRoutes.ts new file mode 100644 index 00000000..98dbc771 --- /dev/null +++ b/backend-node/src/routes/scheduleRoutes.ts @@ -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; diff --git a/backend-node/src/services/scheduleService.ts b/backend-node/src/services/scheduleService.ts new file mode 100644 index 00000000..62eecb59 --- /dev/null +++ b/backend-node/src/services/scheduleService.ts @@ -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 { + 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 { + 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 { + const grouped: Record = {}; + 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 { + 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; + } +} diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index c8196235..1d569db1 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -289,29 +289,46 @@ export class TableManagementService { companyCode, }); - const mappings = await query( - `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( + `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( + `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( - `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( + `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( + `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, diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index b86facfd..828d1aca 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -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() { }); }} /> + + {/* 스케줄 생성 확인 다이얼로그 */} + !open && closeDialog()} + preview={previewResult} + onConfirm={() => handleConfirm(true)} + onCancel={closeDialog} + isLoading={scheduleLoading} + /> diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index bade282b..b822aeee 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -831,6 +831,9 @@ export const ButtonConfigPanel: React.FC = ({ 바코드 스캔 운행알림 및 종료 + {/* 이벤트 버스 */} + 이벤트 발송 + {/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김 복사 (품목코드 초기화) 연관 데이터 버튼 모달 열기 @@ -3536,6 +3539,99 @@ export const ButtonConfigPanel: React.FC = ({ /> )} + {/* 🆕 이벤트 발송 액션 설정 */} + {localInputs.actionType === "event" && ( +
+

이벤트 발송 설정

+

+ 버튼 클릭 시 V2 이벤트 버스를 통해 이벤트를 발송합니다. + 다른 컴포넌트나 서비스에서 이 이벤트를 수신하여 처리할 수 있습니다. +

+ +
+ + +
+ + {component.componentConfig?.action?.eventConfig?.eventName === "SCHEDULE_GENERATE_REQUEST" && ( +
+
+ + +
+ +
+ + { + onUpdateProperty( + "componentConfig.action.eventConfig.eventPayload.config.scheduling.leadTimeDays", + parseInt(e.target.value) || 3 + ); + }} + /> +
+ +
+ + { + onUpdateProperty( + "componentConfig.action.eventConfig.eventPayload.config.scheduling.maxDailyCapacity", + parseInt(e.target.value) || 100 + ); + }} + /> +
+ +
+

+ 동작 방식: 테이블에서 선택된 데이터를 기반으로 스케줄을 자동 생성합니다. + 생성 전 미리보기 확인 다이얼로그가 표시됩니다. +

+
+
+ )} +
+ )} + {/* 🆕 행 선택 시에만 활성화 설정 */}

행 선택 활성화 조건

diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 14e8c1be..baf047ec 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -321,6 +321,7 @@ export function TabsWidget({ onFormDataChange={onFormDataChange} menuObjid={menuObjid} isDesignMode={isDesignMode} + isInteractive={!isDesignMode} />
); diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx index 5d33187a..ddfbdc18 100644 --- a/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx +++ b/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx @@ -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( diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index 47cf7c95..f6fbaea2 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -270,17 +270,20 @@ export function TimelineSchedulerComponent({ ); } - // 리소스 없음 (스케줄도 없는 경우에만 표시) - if (effectiveResources.length === 0) { + // 스케줄 데이터 없음 + if (schedules.length === 0) { return (
- +

스케줄 데이터가 없습니다

-

스케줄 테이블에 데이터를 추가하세요

+

+ 좌측 테이블에서 품목을 선택하거나,
+ 스케줄 생성 버튼을 눌러 스케줄을 생성하세요 +

); diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx index f62c3b34..d297f860 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx @@ -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([]); - const [tableColumns, setTableColumns] = useState([]); + const [sourceColumns, setSourceColumns] = useState([]); const [resourceColumns, setResourceColumns] = useState([]); 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 | undefined; - if (!mapping) return ""; - return mapping[newKey] || mapping[oldKey] || ""; - }; - - // 필드 매핑 업데이트 (새 형식으로 저장하고, 이전 형식 키 삭제) - const updateFieldMapping = (field: string, value: string) => { - const currentMapping = { ...config.fieldMapping } as Record; - - // 이전 형식 키 매핑 - const oldKeyMap: Record = { - 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) => { updateConfig({ - fieldMapping: currentMapping, + sourceConfig: { + ...config.sourceConfig, + ...updates, + }, }); }; @@ -199,35 +165,54 @@ export function TimelineSchedulerConfigPanel({ return (
- - {/* 테이블 설정 */} - + + {/* 소스 데이터 설정 (스케줄 생성 기준) */} + - 테이블 설정 + 스케줄 생성 설정 - {/* 스케줄 테이블 선택 */} +

+ 스케줄 자동 생성 시 참조할 원본 데이터 설정 (저장: schedule_mng) +

+ + {/* 스케줄 타입 */}
- - + + +
+ + {/* 소스 테이블 선택 */} +
+ + @@ -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" >
+ {/* 소스 필드 매핑 */} + {config.sourceConfig?.tableName && ( +
+ +
+ {/* 기준일 필드 */} +
+ + +

+ 스케줄 종료일로 사용됩니다 +

+
+ + {/* 수량 필드 */} +
+ + +
+ + {/* 그룹화 필드 */} +
+ + +
+ + {/* 그룹명 필드 */} +
+ + +
+
+
+ )} +
+
+ + {/* 리소스 설정 */} + + + 리소스 설정 (설비/작업자) + + +

+ 타임라인 Y축에 표시할 리소스 (설비, 작업자 등) +

+ {/* 리소스 테이블 선택 */}
- +
-
-
- - {/* 필드 매핑 */} - - - 필드 매핑 - - - {/* 스케줄 필드 매핑 */} - {config.selectedTable && ( -
- -
- {/* ID 필드 */} -
- - -
- - {/* 리소스 ID 필드 */} -
- - -
- - {/* 제목 필드 */} -
- - -
- - {/* 시작일 필드 */} -
- - -
- - {/* 종료일 필드 */} -
- - -
- - {/* 상태 필드 */} -
- - -
-
-
- )} {/* 리소스 필드 매핑 */} {config.resourceTable && ( -
+
{/* ID 필드 */}
- + updateResourceFieldMapping("name", v)} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts index f8b10f94..17c31991 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts @@ -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 = { + // schedule_mng 테이블 기본 사용 + useCustomTable: false, + scheduleType: "PRODUCTION", // 기본: 생산계획 + + // 표시 설정 defaultZoomLevel: "day", editable: true, draggable: true, @@ -26,6 +33,8 @@ export const defaultTimelineSchedulerConfig: Partial = showNavigation: true, showAddButton: true, height: 500, + + // 상태별 색상 statusColors: { planned: "#3b82f6", // blue-500 in_progress: "#f59e0b", // amber-500 @@ -33,20 +42,26 @@ export const defaultTimelineSchedulerConfig: Partial = 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: "작업배정" }, +]; + /** * 줌 레벨별 표시 일수 */ diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts index 2e56f5c2..7ce7a9d6 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts @@ -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([]); + const selectedSourceKeysRef = useRef([]); + // 표시 종료일 계산 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]; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts index eba6f4e3..b7a836a6 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -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; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 07ce0691..0798b00e 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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; // 이벤트 페이로드 (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 { + 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 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 ( + + + + + + 스케줄 생성 확인 + + + 다음과 같이 스케줄이 변경됩니다. 계속하시겠습니까? + + + + {/* 요약 정보 */} +
+
+ + + {summary.createCount} + + 생성 +
+
+ + + {summary.deleteCount} + + 삭제 +
+
+ + + {summary.updateCount} + + 수정 +
+
+ + {/* 상세 정보 */} + +
+ {/* 생성될 스케줄 */} + {toCreate.length > 0 && ( +
+

+ + 생성 + + {toCreate.length}건 +

+
+ {toCreate.slice(0, 5).map((item, index) => ( +
+ + {item.resource_name || item.resource_id} + + + {item.start_date} ~ {item.end_date} / {item.plan_qty}개 + +
+ ))} + {toCreate.length > 5 && ( +
+ ... 외 {toCreate.length - 5}건 +
+ )} +
+
+ )} + + {/* 삭제될 스케줄 */} + {toDelete.length > 0 && ( +
+

+ 삭제 + {toDelete.length}건 +

+
+ {toDelete.slice(0, 5).map((item, index) => ( +
+ + {item.resource_name || item.resource_id} + + + {item.start_date} ~ {item.end_date} + +
+ ))} + {toDelete.length > 5 && ( +
+ ... 외 {toDelete.length - 5}건 +
+ )} +
+
+ )} + + {/* 수정될 스케줄 */} + {toUpdate.length > 0 && ( +
+

+ 수정 + {toUpdate.length}건 +

+
+ {toUpdate.slice(0, 5).map((item, index) => ( +
+ + {item.resource_name || item.resource_id} + + + {item.start_date} ~ {item.end_date} + +
+ ))} + {toUpdate.length > 5 && ( +
+ ... 외 {toUpdate.length - 5}건 +
+ )} +
+
+ )} +
+
+ + {/* 총 수량 */} +
+ 총 계획 수량 + + {summary.totalQty.toLocaleString()}개 + +
+ + + + 취소 + + + {isLoading ? "처리 중..." : "확인 및 적용"} + + +
+
+ ); +} diff --git a/frontend/lib/v2-core/services/ScheduleGeneratorService.ts b/frontend/lib/v2-core/services/ScheduleGeneratorService.ts new file mode 100644 index 00000000..d73dd3a3 --- /dev/null +++ b/frontend/lib/v2-core/services/ScheduleGeneratorService.ts @@ -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([]); + const [previewResult, setPreviewResult] = + useState(null); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const currentRequestIdRef = useRef(""); + const currentConfigRef = useRef(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"; diff --git a/frontend/lib/v2-core/services/index.ts b/frontend/lib/v2-core/services/index.ts new file mode 100644 index 00000000..02be2a2f --- /dev/null +++ b/frontend/lib/v2-core/services/index.ts @@ -0,0 +1,14 @@ +/** + * V2 서비스 모듈 + * + * 이벤트 버스 기반 서비스들을 export합니다. + */ + +export { + useScheduleGenerator, + type ScheduleGenerationConfig, + type SchedulePreviewResult, + type UseScheduleGeneratorReturn, +} from "./ScheduleGeneratorService"; + +export { ScheduleConfirmDialog } from "./ScheduleConfirmDialog";