Files
vexplor/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts
kjs 17a5d2ff9b feat: implement production plan management functionality
- Added production plan management routes and controller to handle various operations including order summary retrieval, stock shortage checks, and CRUD operations for production plans.
- Introduced service layer for production plan management, encapsulating business logic for handling production-related data.
- Created API client for production plan management, enabling frontend interaction with the new backend endpoints.
- Enhanced button actions to support API calls for production scheduling and management tasks.

These changes aim to improve the management of production plans, enhancing usability and functionality within the ERP system.

Made-with: Cursor
2026-03-16 09:28:22 +09:00

458 lines
16 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types";
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
const SCHEDULE_TABLE = "schedule_mng";
/**
* 날짜를 ISO 문자열로 변환 (시간 제외)
*/
const toDateString = (date: Date): string => {
return date.toISOString().split("T")[0];
};
/**
* 날짜 더하기
*/
const addDays = (date: Date, days: number): Date => {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
};
/**
* 타임라인 데이터를 관리하는 훅
*/
export function useTimelineData(
config: TimelineSchedulerConfig,
externalSchedules?: ScheduleItem[],
externalResources?: Resource[],
): UseTimelineDataResult {
// 상태
const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
const [resources, setResources] = useState<Resource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(config.defaultZoomLevel || "day");
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
if (config.initialDate) {
return new Date(config.initialDate);
}
// 오늘 기준 1주일 전부터 시작
const today = new Date();
today.setDate(today.getDate() - 7);
today.setHours(0, 0, 0, 0);
return today;
});
// 선택된 품목 코드 (좌측 테이블에서 선택된 데이터 기준)
const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
const selectedSourceKeysRef = useRef<string[]>([]);
// 표시 종료일 계산
const viewEndDate = useMemo(() => {
const days = zoomLevelDays[zoomLevel];
return addDays(viewStartDate, days);
}, [viewStartDate, zoomLevel]);
// 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용
const tableName = config.useCustomTable && config.customTableName ? config.customTableName : SCHEDULE_TABLE;
const resourceTableName = config.resourceTable;
// 필드 매핑을 JSON 문자열로 안정화 (객체 참조 변경 방지)
const fieldMappingKey = useMemo(() => {
return JSON.stringify(config.fieldMapping || {});
}, [config.fieldMapping]);
const resourceFieldMappingKey = useMemo(() => {
return JSON.stringify(config.resourceFieldMapping || {});
}, [config.resourceFieldMapping]);
// 🆕 필드 매핑 정규화 (이전 형식 → 새 형식 변환) - useMemo로 메모이제이션
const fieldMapping = useMemo(() => {
const mapping = config.fieldMapping;
if (!mapping) return defaultTimelineSchedulerConfig.fieldMapping!;
return {
id: mapping.id || mapping.idField || "id",
resourceId: mapping.resourceId || mapping.resourceIdField || "resource_id",
title: mapping.title || mapping.titleField || "title",
startDate: mapping.startDate || mapping.startDateField || "start_date",
endDate: mapping.endDate || mapping.endDateField || "end_date",
status: mapping.status || mapping.statusField || undefined,
progress: mapping.progress || mapping.progressField || undefined,
color: mapping.color || mapping.colorField || undefined,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldMappingKey]);
// 리소스 필드 매핑 - useMemo로 메모이제이션
const resourceFieldMapping = useMemo(() => {
return config.resourceFieldMapping || defaultTimelineSchedulerConfig.resourceFieldMapping!;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resourceFieldMappingKey]);
// 스케줄 데이터 로드
const fetchSchedules = useCallback(async () => {
if (externalSchedules) {
setSchedules(externalSchedules);
return;
}
if (!tableName) {
setSchedules([]);
return;
}
setIsLoading(true);
setError(null);
try {
// schedule_mng 테이블 사용 시 필터 조건 구성
const isScheduleMng = tableName === SCHEDULE_TABLE;
const currentSourceKeys = selectedSourceKeysRef.current;
console.log("[useTimelineData] 스케줄 조회:", {
tableName,
scheduleType: config.scheduleType,
sourceKeys: currentSourceKeys,
});
const searchParams: Record<string, any> = {};
if (!isScheduleMng && config.staticFilters) {
Object.assign(searchParams, config.staticFilters);
}
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
page: 1,
size: 10000,
autoFilter: true,
...(Object.keys(searchParams).length > 0 ? { search: searchParams } : {}),
});
const responseData = response.data?.data?.data || response.data?.data || [];
let rawData = Array.isArray(responseData) ? responseData : [];
// 클라이언트 측 필터링 적용 (schedule_mng 테이블인 경우)
if (isScheduleMng) {
// 스케줄 타입 필터
if (config.scheduleType) {
rawData = rawData.filter((row: any) => row.schedule_type === config.scheduleType);
}
// 선택된 품목 필터 (source_group_key 기준)
if (currentSourceKeys.length > 0) {
rawData = rawData.filter((row: any) => currentSourceKeys.includes(row.source_group_key));
}
console.log("[useTimelineData] 필터링 후 스케줄:", rawData.length, "건");
}
// schedule_mng 테이블용 필드 매핑 (고정)
const scheduleMngFieldMapping = {
id: "schedule_id",
resourceId: "resource_id",
title: "schedule_name",
startDate: "start_date",
endDate: "end_date",
status: "status",
progress: undefined, // actual_qty / plan_qty로 계산 가능
};
// 사용할 필드 매핑 결정
const effectiveMapping = isScheduleMng ? scheduleMngFieldMapping : fieldMapping;
// 데이터를 ScheduleItem 형태로 변환
const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => {
// 진행률 계산 (schedule_mng일 경우)
let progress: number | undefined;
if (isScheduleMng && row.plan_qty && row.plan_qty > 0) {
progress = Math.round(((row.actual_qty || 0) / row.plan_qty) * 100);
} else if (effectiveMapping.progress) {
progress = Number(row[effectiveMapping.progress]) || 0;
}
return {
id: String(row[effectiveMapping.id] || ""),
resourceId: String(row[effectiveMapping.resourceId] || ""),
title: String(row[effectiveMapping.title] || ""),
startDate: row[effectiveMapping.startDate] || "",
endDate: row[effectiveMapping.endDate] || "",
status: effectiveMapping.status ? row[effectiveMapping.status] || "planned" : "planned",
progress,
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
data: row,
};
});
console.log("[useTimelineData] 스케줄 로드 완료:", mappedSchedules.length, "건");
setSchedules(mappedSchedules);
} catch (err: any) {
console.error("[useTimelineData] 스케줄 로드 오류:", err);
setError(err.message || "스케줄 데이터 로드 중 오류 발생");
setSchedules([]);
} finally {
setIsLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, externalSchedules, fieldMappingKey, config.scheduleType, JSON.stringify(config.staticFilters)]);
// 리소스 데이터 로드
const fetchResources = useCallback(async () => {
if (externalResources) {
setResources(externalResources);
return;
}
if (!resourceTableName) {
setResources([]);
return;
}
try {
const response = await apiClient.post(`/table-management/tables/${resourceTableName}/data`, {
page: 1,
size: 1000,
autoFilter: true,
});
const responseData = response.data?.data?.data || response.data?.data || [];
const rawData = Array.isArray(responseData) ? responseData : [];
// 데이터를 Resource 형태로 변환
const mappedResources: Resource[] = rawData.map((row: any) => ({
id: String(row[resourceFieldMapping.id] || ""),
name: String(row[resourceFieldMapping.name] || ""),
group: resourceFieldMapping.group ? row[resourceFieldMapping.group] : undefined,
}));
setResources(mappedResources);
} catch (err: any) {
console.error("리소스 로드 오류:", err);
setResources([]);
}
// resourceFieldMappingKey를 의존성으로 사용하여 객체 참조 변경 방지
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resourceTableName, externalResources, resourceFieldMappingKey]);
// 초기 로드
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
useEffect(() => {
fetchResources();
}, [fetchResources]);
// 이벤트 버스 리스너 - 테이블 선택 변경 (품목 선택 시 해당 스케줄만 표시)
useEffect(() => {
const unsubscribeSelection = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", {
tableName: payload.tableName,
selectedCount: payload.selectedCount,
});
// 설정된 그룹 필드명 사용 (없으면 기본값들 fallback)
const groupByField = config.sourceConfig?.groupByField;
// 선택된 데이터에서 source_group_key 추출
const sourceKeys: string[] = [];
for (const row of payload.selectedRows || []) {
// 설정된 필드명 우선, 없으면 일반적인 필드명 fallback
let key: string | undefined;
if (groupByField && row[groupByField]) {
key = row[groupByField];
} else {
// fallback: 일반적으로 사용되는 필드명들
key = row.part_code || row.source_group_key || row.item_code;
}
if (key && !sourceKeys.includes(key)) {
sourceKeys.push(key);
}
}
console.log("[useTimelineData] 선택된 그룹 키:", {
groupByField,
keys: sourceKeys,
});
// 상태 업데이트 및 ref 동기화
selectedSourceKeysRef.current = sourceKeys;
setSelectedSourceKeys(sourceKeys);
});
return () => {
unsubscribeSelection();
};
}, [config.sourceConfig?.groupByField]);
// 선택된 품목이 변경되면 스케줄 다시 로드
useEffect(() => {
if (tableName === SCHEDULE_TABLE) {
console.log("[useTimelineData] 선택 품목 변경으로 스케줄 새로고침:", selectedSourceKeys);
fetchSchedules();
}
}, [selectedSourceKeys, tableName, fetchSchedules]);
// 이벤트 버스 리스너 - 스케줄 생성 완료 및 테이블 새로고침
useEffect(() => {
// TABLE_REFRESH 이벤트 수신 - 스케줄 새로고침
const unsubscribeRefresh = v2EventBus.subscribe(V2_EVENTS.TABLE_REFRESH, (payload) => {
// schedule_mng 또는 해당 테이블에 대한 새로고침
if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) {
console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
});
// SCHEDULE_GENERATE_COMPLETE 이벤트 수신 - 스케줄 자동 생성 완료 시 새로고침
const unsubscribeComplete = v2EventBus.subscribe(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, (payload) => {
if (payload.success) {
console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
});
return () => {
unsubscribeRefresh();
unsubscribeComplete();
};
}, [tableName, fetchSchedules]);
// 네비게이션 함수들
const goToPrevious = useCallback(() => {
const days = zoomLevelDays[zoomLevel];
setViewStartDate((prev) => addDays(prev, -days));
}, [zoomLevel]);
const goToNext = useCallback(() => {
const days = zoomLevelDays[zoomLevel];
setViewStartDate((prev) => addDays(prev, days));
}, [zoomLevel]);
const goToToday = useCallback(() => {
const today = new Date();
today.setDate(today.getDate() - 7);
today.setHours(0, 0, 0, 0);
setViewStartDate(today);
}, []);
const goToDate = useCallback((date: Date) => {
const newDate = new Date(date);
newDate.setDate(newDate.getDate() - 7);
newDate.setHours(0, 0, 0, 0);
setViewStartDate(newDate);
}, []);
// 스케줄 업데이트
const updateSchedule = useCallback(
async (id: string, updates: Partial<ScheduleItem>) => {
if (!tableName || !config.editable) return;
try {
// 필드 매핑 역변환
const updateData: Record<string, any> = {};
if (updates.startDate) updateData[fieldMapping.startDate] = updates.startDate;
if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate;
if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId;
if (updates.title) updateData[fieldMapping.title] = updates.title;
if (updates.status && fieldMapping.status) updateData[fieldMapping.status] = updates.status;
if (updates.progress !== undefined && fieldMapping.progress)
updateData[fieldMapping.progress] = updates.progress;
await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData);
// 로컬 상태 업데이트
setSchedules((prev) => prev.map((s) => (s.id === id ? { ...s, ...updates } : s)));
} catch (err: any) {
console.error("스케줄 업데이트 오류:", err);
throw err;
}
},
[tableName, fieldMapping, config.editable],
);
// 스케줄 추가
const addSchedule = useCallback(
async (schedule: Omit<ScheduleItem, "id">) => {
if (!tableName || !config.editable) return;
try {
// 필드 매핑 역변환
const insertData: Record<string, any> = {
[fieldMapping.resourceId]: schedule.resourceId,
[fieldMapping.title]: schedule.title,
[fieldMapping.startDate]: schedule.startDate,
[fieldMapping.endDate]: schedule.endDate,
};
if (fieldMapping.status) insertData[fieldMapping.status] = schedule.status;
if (fieldMapping.progress && schedule.progress !== undefined)
insertData[fieldMapping.progress] = schedule.progress;
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, insertData);
const newId = response.data?.data?.id || Date.now().toString();
// 로컬 상태 업데이트
setSchedules((prev) => [...prev, { ...schedule, id: newId }]);
} catch (err: any) {
console.error("스케줄 추가 오류:", err);
throw err;
}
},
[tableName, fieldMapping, config.editable],
);
// 스케줄 삭제
const deleteSchedule = useCallback(
async (id: string) => {
if (!tableName || !config.editable) return;
try {
await apiClient.delete(`/table-management/tables/${tableName}/data/${id}`);
// 로컬 상태 업데이트
setSchedules((prev) => prev.filter((s) => s.id !== id));
} catch (err: any) {
console.error("스케줄 삭제 오류:", err);
throw err;
}
},
[tableName, config.editable],
);
// 새로고침
const refresh = useCallback(() => {
fetchSchedules();
fetchResources();
}, [fetchSchedules, fetchResources]);
return {
schedules,
resources,
isLoading,
error,
zoomLevel,
setZoomLevel,
viewStartDate,
viewEndDate,
goToPrevious,
goToNext,
goToToday,
goToDate,
updateSchedule,
addSchedule,
deleteSchedule,
refresh,
};
}