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 (
{/* Toolbar */}
{format(viewStart, 'yyyy年M月', { locale: ja })}
{error &&
エラー: {error}
} {/* Timeline grid */}
{loading && (
読み込み中...
)}
{/* Sticky header: month/day labels */}
{/* Corner */}
予約一覧
{/* Day columns */} {days.map((date) => { const ds = dateToStr(date); const isToday = ds === todayStr; const dow = format(date, 'E', { locale: ja }); const isWeekend = dow === '土' || dow === '日'; const isSun = dow === '日'; const isSat = dow === '土'; return (
{format(date, 'd')} {dow}
); })}
{/* Reservation rows */} {visibleReservations.map((r) => { const car = cars.find((c) => c.id === r.car_id); const color = carColorMap[r.car_id] || COLORS[0]; const { left, width } = getBarLayout(r); return (
{/* Label: car + customer */}
{car?.name ?? '—'} {r.customer_name || '(名前なし)'}
{/* Day cells (background) */}
{days.map((date) => { const ds = dateToStr(date); const isToday = ds === todayStr; const dow = format(date, 'E', { locale: ja }); const isWeekend = dow === '土' || dow === '日'; return (
); })} {/* Bar */}
setModal({ mode: 'edit', reservation: r })} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, reservation: r }); }} title={formatReservationTooltip(r)} > {r.customer_name || '予約'} {width > 80 && ( {formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)} )}
); })} {visibleReservations.length === 0 && !loading && (
この月には予約がありません。
)}
{/* Reservation Modal */} {modal && ( setModal(null)} /> )} {/* Right-click context menu */} {contextMenu && (
e.stopPropagation()} >
)}
); }