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 digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||||
|
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
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/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||||
|
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
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,6 +289,15 @@ export class TableManagementService {
|
||||||
companyCode,
|
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'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck.length > 0) {
|
||||||
|
// menu_objid 컬럼이 있는 경우
|
||||||
const mappings = await query<any>(
|
const mappings = await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
logical_column_name as "columnName",
|
logical_column_name as "columnName",
|
||||||
|
|
@ -303,7 +312,6 @@ export class TableManagementService {
|
||||||
tableName,
|
tableName,
|
||||||
companyCode,
|
companyCode,
|
||||||
mappingCount: mappings.length,
|
mappingCount: mappings.length,
|
||||||
mappings: mappings,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mappings.forEach((m: any) => {
|
mappings.forEach((m: any) => {
|
||||||
|
|
@ -312,6 +320,15 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
|
||||||
|
logger.info("⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
|
||||||
|
}
|
||||||
|
} catch (mappingError: any) {
|
||||||
|
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
|
||||||
|
error: mappingError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
|
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
|
||||||
size: categoryMappings.size,
|
size: categoryMappings.size,
|
||||||
|
|
@ -4163,6 +4180,14 @@ export class TableManagementService {
|
||||||
if (mappingTableExists) {
|
if (mappingTableExists) {
|
||||||
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// menu_objid 컬럼이 있는지 먼저 확인
|
||||||
|
const columnCheck = await query<any>(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck.length > 0) {
|
||||||
const mappings = await query<any>(
|
const mappings = await query<any>(
|
||||||
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
||||||
logical_column_name as "columnName",
|
logical_column_name as "columnName",
|
||||||
|
|
@ -4179,7 +4204,6 @@ export class TableManagementService {
|
||||||
tableName,
|
tableName,
|
||||||
companyCode,
|
companyCode,
|
||||||
mappingCount: mappings.length,
|
mappingCount: mappings.length,
|
||||||
mappings: mappings,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mappings.forEach((m: any) => {
|
mappings.forEach((m: any) => {
|
||||||
|
|
@ -4188,6 +4212,14 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.info("⚠️ menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
|
||||||
|
}
|
||||||
|
} catch (mappingError: any) {
|
||||||
|
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
|
||||||
|
error: mappingError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("categoryMappings Map 생성 완료", {
|
logger.info("categoryMappings Map 생성 완료", {
|
||||||
size: categoryMappings.size,
|
size: categoryMappings.size,
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,11 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭
|
||||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
||||||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
||||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
||||||
|
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
|
||||||
|
|
||||||
function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
|
// 스케줄 자동 생성 서비스 활성화
|
||||||
|
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
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>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
</ActiveTabProvider>
|
</ActiveTabProvider>
|
||||||
|
|
|
||||||
|
|
@ -831,6 +831,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||||
|
|
||||||
|
{/* 이벤트 버스 */}
|
||||||
|
<SelectItem value="event">이벤트 발송</SelectItem>
|
||||||
|
|
||||||
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
|
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
|
||||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||||
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</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">
|
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||||
<h4 className="text-foreground text-sm font-medium">행 선택 활성화 조건</h4>
|
<h4 className="text-foreground text-sm font-medium">행 선택 활성화 조건</h4>
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,7 @@ export function TabsWidget({
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
|
isInteractive={!isDesignMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { GroupHeader } from "./components/GroupHeader";
|
||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||||
|
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core/events";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v2-table-grouped 메인 컴포넌트
|
* v2-table-grouped 메인 컴포넌트
|
||||||
|
|
@ -267,8 +268,9 @@ export function TableGroupedComponent({
|
||||||
[columns]
|
[columns]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 선택 변경 시 콜백
|
// 선택 변경 시 콜백 및 이벤트 발송
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 기존 콜백 호출
|
||||||
if (onSelectionChange && selectedItems.length >= 0) {
|
if (onSelectionChange && selectedItems.length >= 0) {
|
||||||
onSelectionChange({
|
onSelectionChange({
|
||||||
selectedGroups: groups
|
selectedGroups: groups
|
||||||
|
|
@ -278,7 +280,21 @@ export function TableGroupedComponent({
|
||||||
isAllSelected,
|
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(
|
const handleGroupToggle = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -270,17 +270,20 @@ export function TimelineSchedulerComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리소스 없음 (스케줄도 없는 경우에만 표시)
|
// 스케줄 데이터 없음
|
||||||
if (effectiveResources.length === 0) {
|
if (schedules.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ height: config.height || 500 }}
|
||||||
>
|
>
|
||||||
<div className="text-center text-muted-foreground">
|
<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-sm font-medium">스케줄 데이터가 없습니다</p>
|
||||||
<p className="text-xs mt-1">스케줄 테이블에 데이터를 추가하세요</p>
|
<p className="text-xs mt-2 max-w-[200px]">
|
||||||
|
좌측 테이블에서 품목을 선택하거나,<br />
|
||||||
|
스케줄 생성 버튼을 눌러 스케줄을 생성하세요
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ import {
|
||||||
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { TimelineSchedulerConfig } from "./types";
|
import { TimelineSchedulerConfig, ScheduleType, SourceDataConfig } from "./types";
|
||||||
import { zoomLevelOptions, statusOptions } from "./config";
|
import { zoomLevelOptions, scheduleTypeOptions } from "./config";
|
||||||
|
|
||||||
interface TimelineSchedulerConfigPanelProps {
|
interface TimelineSchedulerConfigPanelProps {
|
||||||
config: TimelineSchedulerConfig;
|
config: TimelineSchedulerConfig;
|
||||||
|
|
@ -56,18 +56,11 @@ export function TimelineSchedulerConfigPanel({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
}: TimelineSchedulerConfigPanelProps) {
|
}: 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 [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
|
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
|
||||||
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
|
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
|
||||||
const [resourceTableSelectOpen, setResourceTableSelectOpen] = useState(false);
|
const [resourceTableSelectOpen, setResourceTableSelectOpen] = useState(false);
|
||||||
|
|
||||||
// 테이블 목록 로드
|
// 테이블 목록 로드
|
||||||
|
|
@ -93,17 +86,17 @@ export function TimelineSchedulerConfigPanel({
|
||||||
loadTables();
|
loadTables();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 스케줄 테이블 컬럼 로드
|
// 소스 테이블 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadColumns = async () => {
|
const loadSourceColumns = async () => {
|
||||||
if (!config.selectedTable) {
|
if (!config.sourceConfig?.tableName) {
|
||||||
setTableColumns([]);
|
setSourceColumns([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const columns = await tableTypeApi.getColumns(config.selectedTable);
|
const columns = await tableTypeApi.getColumns(config.sourceConfig.tableName);
|
||||||
if (Array.isArray(columns)) {
|
if (Array.isArray(columns)) {
|
||||||
setTableColumns(
|
setSourceColumns(
|
||||||
columns.map((col: any) => ({
|
columns.map((col: any) => ({
|
||||||
columnName: col.column_name || col.columnName,
|
columnName: col.column_name || col.columnName,
|
||||||
displayName: col.display_name || col.displayName || 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) {
|
} catch (err) {
|
||||||
console.error("컬럼 로드 오류:", err);
|
console.error("소스 컬럼 로드 오류:", err);
|
||||||
setTableColumns([]);
|
setSourceColumns([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadColumns();
|
loadSourceColumns();
|
||||||
}, [config.selectedTable]);
|
}, [config.sourceConfig?.tableName]);
|
||||||
|
|
||||||
// 리소스 테이블 컬럼 로드
|
// 리소스 테이블 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -148,40 +141,13 @@ export function TimelineSchedulerConfigPanel({
|
||||||
onChange({ ...config, ...updates });
|
onChange({ ...config, ...updates });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 이전 형식(idField)과 새 형식(id) 모두 지원하는 헬퍼 함수
|
// 소스 데이터 설정 업데이트
|
||||||
const getFieldMappingValue = (newKey: string, oldKey: string): string => {
|
const updateSourceConfig = (updates: Partial<SourceDataConfig>) => {
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfig({
|
updateConfig({
|
||||||
fieldMapping: currentMapping,
|
sourceConfig: {
|
||||||
|
...config.sourceConfig,
|
||||||
|
...updates,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -199,35 +165,54 @@ export function TimelineSchedulerConfigPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
<Accordion type="multiple" defaultValue={["table", "mapping", "display"]}>
|
<Accordion type="multiple" defaultValue={["source", "resource", "display"]}>
|
||||||
{/* 테이블 설정 */}
|
{/* 소스 데이터 설정 (스케줄 생성 기준) */}
|
||||||
<AccordionItem value="table">
|
<AccordionItem value="source">
|
||||||
<AccordionTrigger className="text-sm font-medium">
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
테이블 설정
|
스케줄 생성 설정
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-3 pt-2">
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
{/* 스케줄 테이블 선택 */}
|
<p className="text-[10px] text-muted-foreground mb-2">
|
||||||
|
스케줄 자동 생성 시 참조할 원본 데이터 설정 (저장: schedule_mng)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 스케줄 타입 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">스케줄 테이블</Label>
|
<Label className="text-xs">스케줄 타입</Label>
|
||||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
<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>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={tableSelectOpen}
|
aria-expanded={sourceTableSelectOpen}
|
||||||
className="h-8 w-full justify-between text-xs"
|
className="h-8 w-full justify-between text-xs"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{config.sourceConfig?.tableName ? (
|
||||||
<span className="flex items-center gap-2">
|
tables.find((t) => t.tableName === config.sourceConfig?.tableName)
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
?.displayName || config.sourceConfig.tableName
|
||||||
로딩 중...
|
|
||||||
</span>
|
|
||||||
) : config.selectedTable ? (
|
|
||||||
tables.find((t) => t.tableName === config.selectedTable)
|
|
||||||
?.displayName || config.selectedTable
|
|
||||||
) : (
|
) : (
|
||||||
"테이블 선택..."
|
"소스 테이블 선택..."
|
||||||
)}
|
)}
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -257,15 +242,15 @@ export function TimelineSchedulerConfigPanel({
|
||||||
key={table.tableName}
|
key={table.tableName}
|
||||||
value={`${table.displayName} ${table.tableName}`}
|
value={`${table.displayName} ${table.tableName}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateConfig({ selectedTable: table.tableName });
|
updateSourceConfig({ tableName: table.tableName });
|
||||||
setTableSelectOpen(false);
|
setSourceTableSelectOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-3 w-3",
|
"mr-2 h-3 w-3",
|
||||||
config.selectedTable === table.tableName
|
config.sourceConfig?.tableName === table.tableName
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
)}
|
)}
|
||||||
|
|
@ -285,9 +270,112 @@ export function TimelineSchedulerConfigPanel({
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">리소스 테이블 (설비/작업자)</Label>
|
<Label className="text-xs">리소스 테이블</Label>
|
||||||
<Popover
|
<Popover
|
||||||
open={resourceTableSelectOpen}
|
open={resourceTableSelectOpen}
|
||||||
onOpenChange={setResourceTableSelectOpen}
|
onOpenChange={setResourceTableSelectOpen}
|
||||||
|
|
@ -361,152 +449,15 @@ export function TimelineSchedulerConfigPanel({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</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 && (
|
{config.resourceTable && (
|
||||||
<div className="space-y-2 mt-3">
|
<div className="space-y-2 mt-2">
|
||||||
<Label className="text-xs font-medium">리소스 필드</Label>
|
<Label className="text-xs font-medium">리소스 필드</Label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{/* ID 필드 */}
|
{/* ID 필드 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">ID</Label>
|
<Label className="text-[10px]">ID 필드</Label>
|
||||||
<Select
|
<Select
|
||||||
value={config.resourceFieldMapping?.id || ""}
|
value={config.resourceFieldMapping?.id || ""}
|
||||||
onValueChange={(v) => updateResourceFieldMapping("id", v)}
|
onValueChange={(v) => updateResourceFieldMapping("id", v)}
|
||||||
|
|
@ -526,7 +477,7 @@ export function TimelineSchedulerConfigPanel({
|
||||||
|
|
||||||
{/* 이름 필드 */}
|
{/* 이름 필드 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">이름</Label>
|
<Label className="text-[10px]">이름 필드</Label>
|
||||||
<Select
|
<Select
|
||||||
value={config.resourceFieldMapping?.name || ""}
|
value={config.resourceFieldMapping?.name || ""}
|
||||||
onValueChange={(v) => updateResourceFieldMapping("name", v)}
|
onValueChange={(v) => updateResourceFieldMapping("name", v)}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TimelineSchedulerConfig, ZoomLevel } from "./types";
|
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 타임라인 스케줄러 설정
|
* 기본 타임라인 스케줄러 설정
|
||||||
|
* - 기본적으로 schedule_mng 테이블 사용 (공통 스케줄 테이블)
|
||||||
|
* - 필드 매핑은 schedule_mng 컬럼에 맞춤
|
||||||
*/
|
*/
|
||||||
export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> = {
|
export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> = {
|
||||||
|
// schedule_mng 테이블 기본 사용
|
||||||
|
useCustomTable: false,
|
||||||
|
scheduleType: "PRODUCTION", // 기본: 생산계획
|
||||||
|
|
||||||
|
// 표시 설정
|
||||||
defaultZoomLevel: "day",
|
defaultZoomLevel: "day",
|
||||||
editable: true,
|
editable: true,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
|
@ -26,6 +33,8 @@ export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> =
|
||||||
showNavigation: true,
|
showNavigation: true,
|
||||||
showAddButton: true,
|
showAddButton: true,
|
||||||
height: 500,
|
height: 500,
|
||||||
|
|
||||||
|
// 상태별 색상
|
||||||
statusColors: {
|
statusColors: {
|
||||||
planned: "#3b82f6", // blue-500
|
planned: "#3b82f6", // blue-500
|
||||||
in_progress: "#f59e0b", // amber-500
|
in_progress: "#f59e0b", // amber-500
|
||||||
|
|
@ -33,20 +42,26 @@ export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> =
|
||||||
delayed: "#ef4444", // red-500
|
delayed: "#ef4444", // red-500
|
||||||
cancelled: "#6b7280", // gray-500
|
cancelled: "#6b7280", // gray-500
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// schedule_mng 테이블 필드 매핑
|
||||||
fieldMapping: {
|
fieldMapping: {
|
||||||
id: "id",
|
id: "schedule_id",
|
||||||
resourceId: "resource_id",
|
resourceId: "resource_id",
|
||||||
title: "title",
|
title: "schedule_name",
|
||||||
startDate: "start_date",
|
startDate: "start_date",
|
||||||
endDate: "end_date",
|
endDate: "end_date",
|
||||||
status: "status",
|
status: "status",
|
||||||
progress: "progress",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 리소스 필드 매핑 (equipment_mng 기준)
|
||||||
resourceFieldMapping: {
|
resourceFieldMapping: {
|
||||||
id: "id",
|
id: "equipment_code",
|
||||||
name: "name",
|
name: "equipment_name",
|
||||||
group: "group",
|
group: "equipment_type",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 기본 리소스 테이블
|
||||||
|
resourceTable: "equipment_mng",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -69,6 +84,16 @@ export const statusOptions = [
|
||||||
{ value: "cancelled", label: "취소", color: "#6b7280" },
|
{ 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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||||
import {
|
import {
|
||||||
TimelineSchedulerConfig,
|
TimelineSchedulerConfig,
|
||||||
ScheduleItem,
|
ScheduleItem,
|
||||||
|
|
@ -11,6 +12,9 @@ import {
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
||||||
|
|
||||||
|
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
|
||||||
|
const SCHEDULE_TABLE = "schedule_mng";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 날짜를 ISO 문자열로 변환 (시간 제외)
|
* 날짜를 ISO 문자열로 변환 (시간 제외)
|
||||||
*/
|
*/
|
||||||
|
|
@ -54,16 +58,20 @@ export function useTimelineData(
|
||||||
return today;
|
return today;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 선택된 품목 코드 (좌측 테이블에서 선택된 데이터 기준)
|
||||||
|
const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
|
||||||
|
const selectedSourceKeysRef = useRef<string[]>([]);
|
||||||
|
|
||||||
// 표시 종료일 계산
|
// 표시 종료일 계산
|
||||||
const viewEndDate = useMemo(() => {
|
const viewEndDate = useMemo(() => {
|
||||||
const days = zoomLevelDays[zoomLevel];
|
const days = zoomLevelDays[zoomLevel];
|
||||||
return addDays(viewStartDate, days);
|
return addDays(viewStartDate, days);
|
||||||
}, [viewStartDate, zoomLevel]);
|
}, [viewStartDate, zoomLevel]);
|
||||||
|
|
||||||
// 테이블명
|
// 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용
|
||||||
const tableName = config.useCustomTable
|
const tableName = config.useCustomTable && config.customTableName
|
||||||
? config.customTableName
|
? config.customTableName
|
||||||
: config.selectedTable;
|
: SCHEDULE_TABLE;
|
||||||
|
|
||||||
const resourceTableName = config.resourceTable;
|
const resourceTableName = config.resourceTable;
|
||||||
|
|
||||||
|
|
@ -116,6 +124,16 @@ export function useTimelineData(
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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(
|
const response = await apiClient.post(
|
||||||
`/table-management/tables/${tableName}/data`,
|
`/table-management/tables/${tableName}/data`,
|
||||||
{
|
{
|
||||||
|
|
@ -127,36 +145,75 @@ export function useTimelineData(
|
||||||
|
|
||||||
const responseData =
|
const responseData =
|
||||||
response.data?.data?.data || response.data?.data || [];
|
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 형태로 변환
|
// 데이터를 ScheduleItem 형태로 변환
|
||||||
const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => ({
|
const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => {
|
||||||
id: String(row[fieldMapping.id] || ""),
|
// 진행률 계산 (schedule_mng일 경우)
|
||||||
resourceId: String(row[fieldMapping.resourceId] || ""),
|
let progress: number | undefined;
|
||||||
title: String(row[fieldMapping.title] || ""),
|
if (isScheduleMng && row.plan_qty && row.plan_qty > 0) {
|
||||||
startDate: row[fieldMapping.startDate] || "",
|
progress = Math.round(((row.actual_qty || 0) / row.plan_qty) * 100);
|
||||||
endDate: row[fieldMapping.endDate] || "",
|
} else if (effectiveMapping.progress) {
|
||||||
status: fieldMapping.status
|
progress = Number(row[effectiveMapping.progress]) || 0;
|
||||||
? row[fieldMapping.status] || "planned"
|
}
|
||||||
|
|
||||||
|
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",
|
: "planned",
|
||||||
progress: fieldMapping.progress
|
progress,
|
||||||
? Number(row[fieldMapping.progress]) || 0
|
|
||||||
: undefined,
|
|
||||||
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
|
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
|
||||||
data: row,
|
data: row,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[useTimelineData] 스케줄 로드 완료:", mappedSchedules.length, "건");
|
||||||
setSchedules(mappedSchedules);
|
setSchedules(mappedSchedules);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
console.error("[useTimelineData] 스케줄 로드 오류:", err);
|
||||||
setError(err.message || "스케줄 데이터 로드 중 오류 발생");
|
setError(err.message || "스케줄 데이터 로드 중 오류 발생");
|
||||||
setSchedules([]);
|
setSchedules([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
// fieldMappingKey를 의존성으로 사용하여 객체 참조 변경 방지
|
|
||||||
// viewStartDate, viewEndDate는 API 호출에 사용되지 않으므로 제거
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tableName, externalSchedules, fieldMappingKey]);
|
}, [tableName, externalSchedules, fieldMappingKey, config.scheduleType]);
|
||||||
|
|
||||||
// 리소스 데이터 로드
|
// 리소스 데이터 로드
|
||||||
const fetchResources = useCallback(async () => {
|
const fetchResources = useCallback(async () => {
|
||||||
|
|
@ -211,6 +268,91 @@ export function useTimelineData(
|
||||||
fetchResources();
|
fetchResources();
|
||||||
}, [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 goToPrevious = useCallback(() => {
|
||||||
const days = zoomLevelDays[zoomLevel];
|
const days = zoomLevelDays[zoomLevel];
|
||||||
|
|
|
||||||
|
|
@ -103,16 +103,58 @@ export interface ResourceFieldMapping {
|
||||||
group?: string;
|
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 {
|
export interface TimelineSchedulerConfig extends ComponentConfig {
|
||||||
/** 스케줄 데이터 테이블명 */
|
/** 스케줄 타입 (필터링 기준) - schedule_mng.schedule_type */
|
||||||
|
scheduleType?: ScheduleType;
|
||||||
|
|
||||||
|
/** 스케줄 데이터 테이블명 (기본: schedule_mng, 커스텀 테이블 사용 시) */
|
||||||
selectedTable?: string;
|
selectedTable?: string;
|
||||||
|
|
||||||
/** 리소스 테이블명 */
|
/** 커스텀 테이블 사용 여부 (false면 schedule_mng 사용) */
|
||||||
|
useCustomTable?: boolean;
|
||||||
|
|
||||||
|
/** 커스텀 테이블명 */
|
||||||
|
customTableName?: string;
|
||||||
|
|
||||||
|
/** 리소스 테이블명 (설비/작업자) */
|
||||||
resourceTable?: string;
|
resourceTable?: string;
|
||||||
|
|
||||||
|
/** 소스 데이터 설정 (스케줄 자동 생성 시 참조) */
|
||||||
|
sourceConfig?: SourceDataConfig;
|
||||||
|
|
||||||
/** 스케줄 필드 매핑 */
|
/** 스케줄 필드 매핑 */
|
||||||
fieldMapping: FieldMapping;
|
fieldMapping: FieldMapping;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ export type ButtonActionType =
|
||||||
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
||||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||||
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||||
| "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||||
|
| "event"; // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 설정
|
* 버튼 액션 설정
|
||||||
|
|
@ -251,6 +252,12 @@ export interface ButtonActionConfig {
|
||||||
successMessage?: string; // 성공 메시지
|
successMessage?: string; // 성공 메시지
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 이벤트 버스 발송 관련 (event 액션용)
|
||||||
|
eventConfig?: {
|
||||||
|
eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키)
|
||||||
|
eventPayload?: Record<string, any>; // 이벤트 페이로드 (requestId는 자동 생성)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -416,6 +423,9 @@ export class ButtonActionExecutor {
|
||||||
case "quickInsert":
|
case "quickInsert":
|
||||||
return await this.handleQuickInsert(config, context);
|
return await this.handleQuickInsert(config, context);
|
||||||
|
|
||||||
|
case "event":
|
||||||
|
return await this.handleEvent(config, context);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -7010,6 +7020,52 @@ export class ButtonActionExecutor {
|
||||||
errors,
|
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: "저장되었습니다.",
|
successMessage: "저장되었습니다.",
|
||||||
errorMessage: "저장 중 오류가 발생했습니다.",
|
errorMessage: "저장 중 오류가 발생했습니다.",
|
||||||
},
|
},
|
||||||
|
event: {
|
||||||
|
type: "event",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ export * from "./components";
|
||||||
// 어댑터
|
// 어댑터
|
||||||
export * from "./adapters";
|
export * from "./adapters";
|
||||||
|
|
||||||
|
// 서비스
|
||||||
|
export * from "./services";
|
||||||
|
|
||||||
// 초기화
|
// 초기화
|
||||||
export { initV2Core, cleanupV2Core } from "./init";
|
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