import { useState, useEffect, useRef, useCallback } from 'react'; import { format, addDays, startOfWeek, parseISO, differenceInDays, isSameDay } from 'date-fns'; import { ja } from 'date-fns/locale'; import { api } from '../api.js'; import ReservationModal from './ReservationModal.jsx'; import styles from './ScheduleView.module.css'; const CELL_WIDTH = 52; // px per day column const ROW_HEIGHT = 52; // px per car row const LABEL_WIDTH = 140; // px for car name column const HEADER_HEIGHT = 72; // px for the date header row const DAYS_SHOWN = 21; // number of days to show // Detect touch-primary device to disable mouse-only drag & drop const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0); // Palette for reservation colors (cycle through them by car index) 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 ScheduleView() { const [cars, setCars] = useState([]); const [reservations, setReservations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // The first date shown in the grid const [viewStart, setViewStart] = useState(() => startOfWeek(new Date(), { weekStartsOn: 1 }) ); // Drag-to-create state const [creating, setCreating] = useState(null); // { carId, startDateStr, endDateStr } // Drag-to-move state const [moving, setMoving] = useState(null); // { reservation, grabDayOffset, currentCarId, currentStartDate } // Modal state const [modal, setModal] = useState(null); // null | { mode: 'create', prefill: {...} } | { mode: 'edit', reservation: {...} } // Context menu state (right-click on reservation) const [contextMenu, setContextMenu] = useState(null); // null | { x, y, reservation } const gridRef = useRef(null); const movingRef = useRef(null); // keeps latest moving state for event handlers // Generate the array of dates shown const dates = Array.from({ length: DAYS_SHOWN }, (_, i) => addDays(viewStart, i)); // --- 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]); // --- Navigation --- const prevWeek = () => setViewStart((d) => addDays(d, -7)); const nextWeek = () => setViewStart((d) => addDays(d, 7)); const goToday = () => setViewStart(startOfWeek(new Date(), { weekStartsOn: 1 })); // --- Grid position helpers --- // Given a mouse clientX within the grid scroll area, get the day index (0-based) const getColFromX = useCallback((clientX) => { if (!gridRef.current) return -1; const rect = gridRef.current.getBoundingClientRect(); const scrollLeft = gridRef.current.scrollLeft; const x = clientX - rect.left + scrollLeft - LABEL_WIDTH; if (x < 0) return -1; return Math.floor(x / CELL_WIDTH); }, []); const getRowFromY = useCallback((clientY) => { if (!gridRef.current) return -1; const rect = gridRef.current.getBoundingClientRect(); const scrollTop = gridRef.current.scrollTop; const y = clientY - rect.top + scrollTop - HEADER_HEIGHT; if (y < 0) return -1; return Math.floor(y / ROW_HEIGHT); }, []); // --- Cell drag to create --- const handleCellMouseDown = (e, carId, dateStr) => { if (isTouchDevice) return; // drag-to-create is mouse-only if (e.button !== 0) return; e.preventDefault(); setCreating({ carId, startDateStr: dateStr, endDateStr: dateStr }); }; // --- Cell tap to create (touch devices) --- const handleCellClick = useCallback((e, carId) => { if (!isTouchDevice) return; const col = getColFromX(e.clientX); if (col >= 0 && col < DAYS_SHOWN) { const dateStr = dateToStr(dates[col]); setModal({ mode: 'create', prefill: { car_id: carId, start_date: dateStr, end_date: dateStr }, }); } }, [dates, getColFromX]); const handleGridMouseMove = useCallback((e) => { if (creating) { const col = getColFromX(e.clientX); if (col >= 0 && col < DAYS_SHOWN) { const hoveredDate = dateToStr(dates[col]); setCreating((prev) => { if (!prev) return null; // Ensure start <= end const s = prev.startDateStr; const h = hoveredDate; return { ...prev, endDateStr: h >= s ? h : s, startDateStr: h < s ? h : prev.startDateStr, }; }); } } if (moving) { const col = getColFromX(e.clientX); const row = getRowFromY(e.clientY); movingRef.current = { ...movingRef.current }; if (col >= 0 && col < DAYS_SHOWN) { const newStartCol = Math.max(0, col - moving.grabDayOffset); const duration = differenceInDays( parseISO(moving.reservation.end_date), parseISO(moving.reservation.start_date) ); const clampedStartCol = Math.min(newStartCol, DAYS_SHOWN - 1 - duration); const newStartDate = dateToStr(dates[Math.max(0, clampedStartCol)]); const newEndDate = dateToStr(addDays(dates[Math.max(0, clampedStartCol)], duration)); let newCarId = moving.currentCarId; if (row >= 0 && row < cars.length) { newCarId = cars[row].id; } setMoving((prev) => prev ? { ...prev, currentCarId: newCarId, currentStartDate: newStartDate, currentEndDate: newEndDate, col: clampedStartCol, row: row >= 0 && row < cars.length ? row : prev.row, } : null); } } }, [creating, moving, dates, cars, getColFromX, getRowFromY]); const handleGridMouseUp = useCallback(async (e) => { if (creating) { const { carId, startDateStr, endDateStr } = creating; setCreating(null); // Open modal to confirm/fill details setModal({ mode: 'create', prefill: { car_id: carId, start_date: startDateStr, end_date: endDateStr, }, }); } if (moving) { const { reservation, currentCarId, currentStartDate, currentEndDate } = moving; setMoving(null); movingRef.current = null; const duration = differenceInDays( parseISO(reservation.end_date), parseISO(reservation.start_date) ); const newEndDate = currentEndDate || dateToStr(addDays(parseISO(currentStartDate), duration)); // Only update if something changed if ( currentCarId !== reservation.car_id || currentStartDate !== reservation.start_date ) { try { await api.updateReservation(reservation.id, { car_id: currentCarId, start_date: currentStartDate, end_date: newEndDate, customer_name: reservation.customer_name, notes: reservation.notes, }); await loadData(); } catch (err) { setError(`予約の移動に失敗しました: ${err.message}`); await loadData(); } } } }, [creating, moving, loadData]); useEffect(() => { window.addEventListener('mousemove', handleGridMouseMove); window.addEventListener('mouseup', handleGridMouseUp); return () => { window.removeEventListener('mousemove', handleGridMouseMove); window.removeEventListener('mouseup', handleGridMouseUp); }; }, [handleGridMouseMove, handleGridMouseUp]); // Close context menu on any click or 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]); // --- Reservation drag to move --- const handleReservationMouseDown = (e, reservation) => { e.stopPropagation(); if (isTouchDevice) return; // drag-to-move is mouse-only if (e.button !== 0) return; e.preventDefault(); const col = getColFromX(e.clientX); const startCol = Math.max(0, dates.findIndex((d) => dateToStr(d) === reservation.start_date)); const grabOffset = col >= 0 ? col - startCol : 0; const carRow = cars.findIndex((c) => c.id === reservation.car_id); setMoving({ reservation, grabDayOffset: Math.max(0, grabOffset), currentCarId: reservation.car_id, currentStartDate: reservation.start_date, currentEndDate: reservation.end_date, col: startCol, row: carRow, }); }; // --- Modal save/delete 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}`); } }; // --- Rendering --- // Build a map of reservations visible in the date range const viewStartStr = dateToStr(viewStart); const viewEndStr = dateToStr(dates[dates.length - 1]); const visibleReservations = reservations.filter( (r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr ); // For each reservation, calculate its left/width in the grid function getReservationLayout(r) { const rStart = r.start_date < viewStartStr ? viewStartStr : r.start_date; const rEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date; const startCol = differenceInDays(parseISO(rStart), viewStart); const endCol = differenceInDays(parseISO(rEnd), viewStart); const left = startCol * CELL_WIDTH; const width = (endCol - startCol + 1) * CELL_WIDTH; return { left, width, startCol, endCol }; } // Create ghost for currently moving reservation const movingGhost = moving ? (() => { const duration = differenceInDays( parseISO(moving.reservation.end_date), parseISO(moving.reservation.start_date) ); const col = moving.col ?? 0; const left = col * CELL_WIDTH; const width = (duration + 1) * CELL_WIDTH; return { col, left, width, row: moving.row }; })() : null; // Today column const todayStr = dateToStr(new Date()); const carColorMap = {}; cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); }); return (
{/* Toolbar */}
{format(viewStart, 'yyyy年M月d日', { locale: ja })} 〜{' '} {format(dates[dates.length - 1], 'yyyy年M月d日', { locale: ja })}
{error &&
エラー: {error}
} {loading &&
読み込み中...
} {/* Grid */}
{ // don't cancel on leave — handled by global events }} >
{/* Header row */}
{/* Corner cell */}
{/* Date headers */} {dates.map((date) => { const ds = dateToStr(date); const isToday = ds === todayStr; const dow = format(date, 'E', { locale: ja }); const isWeekend = dow === '土' || dow === '日'; return (
{format(date, 'd')} {dow}
); })}
{/* Car rows */} {cars.map((car, carIdx) => { const color = carColorMap[car.id]; const carReservations = visibleReservations.filter((r) => { if (moving && r.id === moving.reservation.id) return false; // hide while moving return r.car_id === car.id; }); // Creating highlight for this row const isCreatingRow = creating && creating.carId === car.id; let creatingLeft = 0, creatingWidth = 0; if (isCreatingRow) { const startCol = differenceInDays(parseISO(creating.startDateStr), viewStart); const endCol = differenceInDays(parseISO(creating.endDateStr), viewStart); creatingLeft = Math.max(0, startCol) * CELL_WIDTH; creatingWidth = (Math.min(endCol, DAYS_SHOWN - 1) - Math.max(0, startCol) + 1) * CELL_WIDTH; } // Ghost reservation for this row const showGhost = moving && moving.row === carIdx && movingGhost; return (
{/* Car label */}
{car.name}
{/* Day cells */}
handleCellClick(e, car.id)} > {dates.map((date) => { const ds = dateToStr(date); const isToday = ds === todayStr; const dow = format(date, 'E', { locale: ja }); const isWeekend = dow === '土' || dow === '日'; return (
handleCellMouseDown(e, car.id, ds)} /> ); })} {/* Creating highlight */} {isCreatingRow && creatingWidth > 0 && (
)} {/* Ghost while moving */} {showGhost && (
{moving.reservation.customer_name || '予約'}
)} {/* Reservation blocks */} {carReservations.map((r) => { const { left, width } = getReservationLayout(r); return (
handleReservationMouseDown(e, r)} onClick={(e) => { if (!moving) { e.stopPropagation(); setModal({ mode: 'edit', reservation: r }); } }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, reservation: r }); }} title={`${r.customer_name || '予約'}\n${r.start_date} 〜 ${r.end_date}${r.notes ? '\n' + r.notes : ''}`} > {r.customer_name || '予約'} {width > 80 && ( {r.start_date.slice(5)} 〜 {r.end_date.slice(5)} )}
); })}
); })} {cars.length === 0 && !loading && (
代車が登録されていません。「代車管理」から追加してください。
)}
{/* Reservation Modal */} {modal && ( setModal(null)} /> )} {/* Right-click context menu */} {contextMenu && (
e.stopPropagation()} >
)}
); }