import { useState, useEffect, useRef, useCallback } from 'react'; import { format, addDays, addMonths, startOfMonth, endOfMonth, parseISO, differenceInDays } from 'date-fns'; import { ja } from 'date-fns/locale'; import { api } from '../api.js'; import { formatDateRange, formatReservationTooltip } from '../utils/carUtils.js'; import ReservationModal from './ReservationModal.jsx'; import styles from './TimelineView.module.css'; const ROW_HEIGHT = 48; // px per reservation row const LABEL_WIDTH = 180; // px for reservation info column const HEADER_HEIGHT = 60; // px for date header const DAY_WIDTH = 36; // px per day column const BAR_PADDING = 4; // px gap between bar and row edge // Same colour palette as ScheduleView const COLORS = [ { bg: '#dbeafe', border: '#3b82f6', text: '#1e3a8a' }, { bg: '#dcfce7', border: '#22c55e', text: '#14532d' }, { bg: '#fef9c3', border: '#eab308', text: '#713f12' }, { bg: '#fce7f3', border: '#ec4899', text: '#831843' }, { bg: '#ede9fe', border: '#8b5cf6', text: '#3b0764' }, { bg: '#ffedd5', border: '#f97316', text: '#7c2d12' }, { bg: '#e0f2fe', border: '#0ea5e9', text: '#0c4a6e' }, { bg: '#f0fdf4', border: '#16a34a', text: '#14532d' }, ]; function getColor(index) { return COLORS[index % COLORS.length]; } function dateToStr(date) { return format(date, 'yyyy-MM-dd'); } export default function TimelineView({ reloadKey = 0 }) { const [cars, setCars] = useState([]); const [reservations, setReservations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [modal, setModal] = useState(null); const [contextMenu, setContextMenu] = useState(null); // View window: show the current month by default const [viewStart, setViewStart] = useState(() => startOfMonth(new Date())); const [viewEnd, setViewEnd] = useState(() => endOfMonth(new Date())); const gridRef = useRef(null); const days = (() => { const result = []; let d = viewStart; while (d <= viewEnd) { result.push(d); d = addDays(d, 1); } return result; })(); const totalWidth = LABEL_WIDTH + days.length * DAY_WIDTH; const todayStr = dateToStr(new Date()); // --- Data loading --- const loadData = useCallback(async () => { try { setLoading(true); const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]); setCars(carsData); setReservations(resData); setError(null); } catch (e) { setError(e.message); } finally { setLoading(false); } }, []); useEffect(() => { loadData(); }, [loadData, reloadKey]); // Close context menu on click / Escape useEffect(() => { if (!contextMenu) return; const close = () => setContextMenu(null); const onKey = (e) => { if (e.key === 'Escape') close(); }; window.addEventListener('click', close); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('click', close); window.removeEventListener('keydown', onKey); }; }, [contextMenu]); // --- Navigation --- const prevMonth = () => { const start = addMonths(viewStart, -1); setViewStart(startOfMonth(start)); setViewEnd(endOfMonth(start)); }; const nextMonth = () => { const start = addMonths(viewStart, 1); setViewStart(startOfMonth(start)); setViewEnd(endOfMonth(start)); }; const goThisMonth = () => { setViewStart(startOfMonth(new Date())); setViewEnd(endOfMonth(new Date())); }; // --- Handlers --- const handleModalSave = async (data) => { try { if (modal.mode === 'edit') { await api.updateReservation(modal.reservation.id, data); } else { await api.createReservation(data); } setModal(null); await loadData(); } catch (e) { setError(`予約の保存に失敗しました: ${e.message}`); } }; const handleModalDelete = async (id) => { try { await api.deleteReservation(id); setModal(null); await loadData(); } catch (e) { setError(`予約の削除に失敗しました: ${e.message}`); } }; // Build car colour map const carColorMap = {}; cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); }); // Sort reservations by start_date then car const sortedReservations = [...reservations].sort((a, b) => { if (a.start_date !== b.start_date) return a.start_date < b.start_date ? -1 : 1; return a.car_id - b.car_id; }); const viewStartStr = dateToStr(viewStart); const viewEndStr = dateToStr(viewEnd); // Filter to reservations that overlap the view window const visibleReservations = sortedReservations.filter( (r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr ); function getBarLayout(r) { const clampedStart = r.start_date < viewStartStr ? viewStartStr : r.start_date; const clampedEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date; const startOffset = differenceInDays(parseISO(clampedStart), viewStart); const endOffset = differenceInDays(parseISO(clampedEnd), viewStart); const left = startOffset * DAY_WIDTH; const width = (endOffset - startOffset + 1) * DAY_WIDTH; return { left, width }; } return (