diff --git a/frontend/src/components/ScheduleView.jsx b/frontend/src/components/ScheduleView.jsx index 6823ef6..f585e50 100644 --- a/frontend/src/components/ScheduleView.jsx +++ b/frontend/src/components/ScheduleView.jsx @@ -1,16 +1,17 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { format, addDays, startOfWeek, parseISO, differenceInDays, isSameDay } from 'date-fns'; +import { format, addDays, startOfWeek, parseISO, differenceInDays } from 'date-fns'; import { ja } from 'date-fns/locale'; import { api } from '../api.js'; import { isInspectionExpirySoon, formatDateRange, formatReservationTooltip } from '../utils/carUtils.js'; import ReservationModal from './ReservationModal.jsx'; import styles from './ScheduleView.module.css'; -const CELL_WIDTH = 52; // px per day column +const CELL_WIDTH = 52; // px per half-day slot (午前 or 午後) const ROW_HEIGHT = 64; // px per car row const LABEL_WIDTH = 140; // px for car name column -const HEADER_HEIGHT = 72; // px for the date header row +const HEADER_HEIGHT = 80; // px for the date header row (top: date+dow, bottom: 午前/午後) const DAYS_SHOWN = 21; // number of days to show +const HALF_SLOTS = DAYS_SHOWN * 2; // total half-day slot columns (AM + PM per day) // Detect touch-primary device to disable mouse-only drag & drop const isTouchDevice = typeof window !== 'undefined' && @@ -94,14 +95,15 @@ export default function ScheduleView({ reloadKey = 0 }) { 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) => { + // Given a mouse clientX within the grid scroll area, get the half-day slot index (0-based) + // Each slot is CELL_WIDTH wide; even slots = 午前, odd slots = 午後 + const getSlotFromX = 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); + return Math.min(Math.floor(x / CELL_WIDTH), HALF_SLOTS - 1); }, []); const getRowFromY = useCallback((clientY) => { @@ -114,59 +116,62 @@ export default function ScheduleView({ reloadKey = 0 }) { }, []); // --- Cell drag to create --- - const handleCellMouseDown = (e, carId, dateStr) => { + const handleCellMouseDown = (e, carId, slot) => { if (isTouchDevice) return; // drag-to-create is mouse-only if (e.button !== 0) return; e.preventDefault(); - setCreating({ carId, startDateStr: dateStr, endDateStr: dateStr }); + setCreating({ carId, startSlot: slot, endSlot: slot }); }; // --- 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]); + const slot = getSlotFromX(e.clientX); + if (slot >= 0 && slot < HALF_SLOTS) { + const dayIdx = Math.floor(slot / 2); + const dateStr = dateToStr(dates[dayIdx]); + const period = slot % 2 === 0 ? '午前' : '午後'; setModal({ mode: 'create', - prefill: { car_id: carId, start_date: dateStr, end_date: dateStr }, + prefill: { car_id: carId, start_date: dateStr, start_period: period, end_date: dateStr, end_period: period }, }); } - }, [dates, getColFromX]); + }, [dates, getSlotFromX]); const handleGridMouseMove = useCallback((e) => { if (creating) { - const col = getColFromX(e.clientX); - if (col >= 0 && col < DAYS_SHOWN) { - const hoveredDate = dateToStr(dates[col]); + const slot = getSlotFromX(e.clientX); + if (slot >= 0 && slot < HALF_SLOTS) { setCreating((prev) => { if (!prev) return null; - // Ensure start <= end - const s = prev.startDateStr; - const h = hoveredDate; + const s = prev.startSlot; + const h = slot; return { ...prev, - endDateStr: h >= s ? h : s, - startDateStr: h < s ? h : prev.startDateStr, + endSlot: h >= s ? h : s, + startSlot: h < s ? h : prev.startSlot, }; }); } } if (moving) { - const col = getColFromX(e.clientX); + const slot = getSlotFromX(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)); + if (slot >= 0 && slot < HALF_SLOTS) { + const durationSlots = moving.durationSlots; + const newStartSlot = Math.max(0, slot - moving.grabSlotOffset); + const clampedStartSlot = Math.min(newStartSlot, HALF_SLOTS - 1 - durationSlots); + const clampedEndSlot = clampedStartSlot + durationSlots; + + const newStartDayIdx = Math.max(0, Math.min(Math.floor(clampedStartSlot / 2), DAYS_SHOWN - 1)); + const newEndDayIdx = Math.max(0, Math.min(Math.floor(clampedEndSlot / 2), DAYS_SHOWN - 1)); + const newStartDate = dateToStr(dates[newStartDayIdx]); + const newStartPeriod = clampedStartSlot % 2 === 0 ? '午前' : '午後'; + const newEndDate = dateToStr(dates[newEndDayIdx]); + const newEndPeriod = clampedEndSlot % 2 === 0 ? '午前' : '午後'; let newCarId = moving.currentCarId; if (row >= 0 && row < cars.length) { @@ -176,56 +181,56 @@ export default function ScheduleView({ reloadKey = 0 }) { setMoving((prev) => prev ? { ...prev, currentCarId: newCarId, + currentStartSlot: clampedStartSlot, currentStartDate: newStartDate, + currentStartPeriod: newStartPeriod, currentEndDate: newEndDate, - col: clampedStartCol, + currentEndPeriod: newEndPeriod, + col: clampedStartSlot, row: row >= 0 && row < cars.length ? row : prev.row, } : null); } } - }, [creating, moving, dates, cars, getColFromX, getRowFromY]); + }, [creating, moving, dates, cars, getSlotFromX, getRowFromY]); const handleGridMouseUp = useCallback(async (e) => { if (creating) { - const { carId, startDateStr, endDateStr } = creating; + const { carId, startSlot, endSlot } = creating; setCreating(null); + const startDayIdx = Math.max(0, Math.min(Math.floor(startSlot / 2), DAYS_SHOWN - 1)); + const endDayIdx = Math.max(0, Math.min(Math.floor(endSlot / 2), DAYS_SHOWN - 1)); // Open modal to confirm/fill details setModal({ mode: 'create', prefill: { car_id: carId, - start_date: startDateStr, - end_date: endDateStr, + start_date: dateToStr(dates[startDayIdx]), + start_period: startSlot % 2 === 0 ? '午前' : '午後', + end_date: dateToStr(dates[endDayIdx]), + end_period: endSlot % 2 === 0 ? '午前' : '午後', }, }); } if (moving) { - const { reservation, currentCarId, currentStartDate, currentEndDate } = moving; + const { reservation, currentCarId, origStartSlot, currentStartSlot, currentStartDate, currentStartPeriod, currentEndDate, currentEndPeriod } = 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 + currentStartSlot !== origStartSlot ) { try { await api.updateReservation(reservation.id, { car_id: currentCarId, start_date: currentStartDate, - end_date: newEndDate, + end_date: currentEndDate, customer_name: reservation.customer_name, notes: reservation.notes, - start_period: reservation.start_period, - end_period: reservation.end_period, + start_period: currentStartPeriod, + end_period: currentEndPeriod, }); await loadData(); } catch (err) { @@ -234,7 +239,7 @@ export default function ScheduleView({ reloadKey = 0 }) { } } } - }, [creating, moving, loadData]); + }, [creating, moving, dates, loadData]); useEffect(() => { window.addEventListener('mousemove', handleGridMouseMove); @@ -265,18 +270,28 @@ export default function ScheduleView({ reloadKey = 0 }) { 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 slot = getSlotFromX(e.clientX); + // Compute the reservation's start/end slot within the current view + const startDayCol = Math.max(0, differenceInDays(parseISO(reservation.start_date), viewStart)); + const endDayCol = Math.max(0, differenceInDays(parseISO(reservation.end_date), viewStart)); + const startSlot = startDayCol * 2 + (reservation.start_period === '午後' ? 1 : 0); + const endSlot = endDayCol * 2 + (reservation.end_period === '午前' ? 0 : 1); + const durationSlots = Math.max(0, endSlot - startSlot); + const grabOffset = slot >= 0 ? slot - startSlot : 0; const carRow = cars.findIndex((c) => c.id === reservation.car_id); setMoving({ reservation, - grabDayOffset: Math.max(0, grabOffset), + grabSlotOffset: Math.max(0, grabOffset), + durationSlots, + origStartSlot: startSlot, currentCarId: reservation.car_id, + currentStartSlot: startSlot, currentStartDate: reservation.start_date, + currentStartPeriod: reservation.start_period || '午前', currentEndDate: reservation.end_date, - col: startCol, + currentEndPeriod: reservation.end_period || '午後', + col: startSlot, row: carRow, }); }; @@ -315,26 +330,26 @@ export default function ScheduleView({ reloadKey = 0 }) { (r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr ); - // For each reservation, calculate its left/width in the grid + // For each reservation, calculate its left/width in the grid (accounting for 午前/午後 periods) 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 }; + const startDayCol = differenceInDays(parseISO(rStart), viewStart); + const endDayCol = differenceInDays(parseISO(rEnd), viewStart); + // Even slot = 午前, odd slot = 午後; empty period treated as 午前 for start, 午後 for end + const startSlot = startDayCol * 2 + (r.start_period === '午後' ? 1 : 0); + const endSlot = endDayCol * 2 + (r.end_period === '午前' ? 0 : 1); + const left = startSlot * CELL_WIDTH; + const width = (endSlot - startSlot + 1) * CELL_WIDTH; + return { left, width, startSlot, endSlot }; } // 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 durationSlots = moving.durationSlots; const left = col * CELL_WIDTH; - const width = (duration + 1) * CELL_WIDTH; + const width = (durationSlots + 1) * CELL_WIDTH; return { col, left, width, row: moving.row }; })() : null; @@ -394,7 +409,7 @@ export default function ScheduleView({ reloadKey = 0 }) { )}
{/* Header row */}
@@ -403,7 +418,7 @@ export default function ScheduleView({ reloadKey = 0 }) { className={styles.cornerCell} style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }} /> - {/* Date headers */} + {/* Date headers — each day spans two half-day slots (午前 + 午後) */} {dates.map((date) => { const ds = dateToStr(date); const isToday = ds === todayStr; @@ -412,11 +427,17 @@ export default function ScheduleView({ reloadKey = 0 }) { return (
- {format(date, 'd')} - {dow} +
+ {format(date, 'd')} + {dow} +
+
+
午前
+
午後
+
); })} @@ -434,10 +455,8 @@ export default function ScheduleView({ reloadKey = 0 }) { 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; + creatingLeft = Math.max(0, creating.startSlot) * CELL_WIDTH; + creatingWidth = (Math.min(creating.endSlot, HALF_SLOTS - 1) - Math.max(0, creating.startSlot) + 1) * CELL_WIDTH; } // Ghost reservation for this row @@ -469,25 +488,25 @@ export default function ScheduleView({ reloadKey = 0 }) {
- {/* Day cells */} + {/* Day cells — two per day (午前 then 午後) */}
handleCellClick(e, car.id)} > - {dates.map((date) => { + {dates.flatMap((date, dayIdx) => { const ds = dateToStr(date); const isToday = ds === todayStr; const dow = format(date, 'E', { locale: ja }); const isWeekend = dow === '土' || dow === '日'; - return ( + return ['午前', '午後'].map((period, pIdx) => (
handleCellMouseDown(e, car.id, ds)} + onMouseDown={(e) => handleCellMouseDown(e, car.id, dayIdx * 2 + pIdx)} /> - ); + )); })} {/* Creating highlight */} diff --git a/frontend/src/components/ScheduleView.module.css b/frontend/src/components/ScheduleView.module.css index c4d9e80..502c512 100644 --- a/frontend/src/components/ScheduleView.module.css +++ b/frontend/src/components/ScheduleView.module.css @@ -124,6 +124,47 @@ z-index: 30; } +.dateHeaderGroup { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: stretch; + border-right: 2px solid #d1d5db; + background: white; + overflow: hidden; +} + +.dateHeaderTop { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + flex: 1; + border-bottom: 1px solid #e5e7eb; +} + +.dateHeaderAmPm { + display: flex; + height: 22px; + flex-shrink: 0; +} + +.periodLabel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 500; + color: #9ca3af; + letter-spacing: 0.03em; +} + +.periodLabel:first-child { + border-right: 1px solid #f0f0f0; +} + .dateHeader { flex-shrink: 0; display: flex; @@ -281,6 +322,10 @@ background: rgba(26, 86, 219, 0.04); } +.cellDayEnd { + border-right: 1px solid #d1d5db; +} + .todayCell { background: rgba(59, 130, 246, 0.06); }