feat: 스케줄 자동 생성 기능 및 이벤트 발송 설정 추가
- 스케줄 자동 생성 관련 라우트를 추가하여 API 연동을 구현하였습니다. - 버튼 설정 패널에 이벤트 발송 옵션을 추가하여 사용자가 이벤트를 설정할 수 있도록 하였습니다. - 타임라인 스케줄러 컴포넌트에서 스케줄 데이터 필터링 및 선택된 품목에 따른 스케줄 로드 기능을 개선하였습니다. - 이벤트 버스를 통해 다른 컴포넌트와의 상호작용을 강화하였습니다. - 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다.
This commit is contained in:
parent
61b67c3619
commit
f845dadc5d
|
|
@ -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); // 권한 그룹 관리
|
||||
|
|
|
|||
|
|
@ -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 || "스케줄 삭제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -321,6 +321,7 @@ export function TabsWidget({
|
|||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
isDesignMode={isDesignMode}
|
||||
isInteractive={!isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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: "작업배정" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 줌 레벨별 표시 일수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ export * from "./components";
|
|||
// 어댑터
|
||||
export * from "./adapters";
|
||||
|
||||
// 서비스
|
||||
export * from "./services";
|
||||
|
||||
// 초기화
|
||||
export { initV2Core, cleanupV2Core } from "./init";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* V2 서비스 모듈
|
||||
*
|
||||
* 이벤트 버스 기반 서비스들을 export합니다.
|
||||
*/
|
||||
|
||||
export {
|
||||
useScheduleGenerator,
|
||||
type ScheduleGenerationConfig,
|
||||
type SchedulePreviewResult,
|
||||
type UseScheduleGeneratorReturn,
|
||||
} from "./ScheduleGeneratorService";
|
||||
|
||||
export { ScheduleConfirmDialog } from "./ScheduleConfirmDialog";
|
||||
Loading…
Reference in New Issue