"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([]); const [resources, setResources] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [zoomLevel, setZoomLevel] = useState(config.defaultZoomLevel || "day"); const [viewStartDate, setViewStartDate] = useState(() => { 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([]); const selectedSourceKeysRef = useRef([]); // 표시 종료일 계산 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 response = await apiClient.post(`/table-management/tables/${tableName}/data`, { page: 1, size: 10000, autoFilter: true, }); 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 }, [tableName, externalSchedules, fieldMappingKey, config.scheduleType]); // 리소스 데이터 로드 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) => { if (!tableName || !config.editable) return; try { // 필드 매핑 역변환 const updateData: Record = {}; 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) => { if (!tableName || !config.editable) return; try { // 필드 매핑 역변환 const insertData: Record = { [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, }; }