feat: split schedule view horizontal axis into AM/PM half-day slots

Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/b0f3486c-edf5-4c08-b1e8-161b380aa0a0

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-06 08:09:33 +00:00
committed by GitHub
parent b80c15e186
commit ad96d38863
2 changed files with 147 additions and 83 deletions

View File

@@ -1,16 +1,17 @@
import { useState, useEffect, useRef, useCallback } from 'react'; 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 { ja } from 'date-fns/locale';
import { api } from '../api.js'; import { api } from '../api.js';
import { isInspectionExpirySoon, formatDateRange, formatReservationTooltip } from '../utils/carUtils.js'; import { isInspectionExpirySoon, formatDateRange, formatReservationTooltip } from '../utils/carUtils.js';
import ReservationModal from './ReservationModal.jsx'; import ReservationModal from './ReservationModal.jsx';
import styles from './ScheduleView.module.css'; 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 ROW_HEIGHT = 64; // px per car row
const LABEL_WIDTH = 140; // px for car name column 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 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 // Detect touch-primary device to disable mouse-only drag & drop
const isTouchDevice = typeof window !== 'undefined' && const isTouchDevice = typeof window !== 'undefined' &&
@@ -94,14 +95,15 @@ export default function ScheduleView({ reloadKey = 0 }) {
const goToday = () => setViewStart(startOfWeek(new Date(), { weekStartsOn: 1 })); const goToday = () => setViewStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
// --- Grid position helpers --- // --- Grid position helpers ---
// Given a mouse clientX within the grid scroll area, get the day index (0-based) // Given a mouse clientX within the grid scroll area, get the half-day slot index (0-based)
const getColFromX = useCallback((clientX) => { // Each slot is CELL_WIDTH wide; even slots = 午前, odd slots = 午後
const getSlotFromX = useCallback((clientX) => {
if (!gridRef.current) return -1; if (!gridRef.current) return -1;
const rect = gridRef.current.getBoundingClientRect(); const rect = gridRef.current.getBoundingClientRect();
const scrollLeft = gridRef.current.scrollLeft; const scrollLeft = gridRef.current.scrollLeft;
const x = clientX - rect.left + scrollLeft - LABEL_WIDTH; const x = clientX - rect.left + scrollLeft - LABEL_WIDTH;
if (x < 0) return -1; 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) => { const getRowFromY = useCallback((clientY) => {
@@ -114,59 +116,62 @@ export default function ScheduleView({ reloadKey = 0 }) {
}, []); }, []);
// --- Cell drag to create --- // --- Cell drag to create ---
const handleCellMouseDown = (e, carId, dateStr) => { const handleCellMouseDown = (e, carId, slot) => {
if (isTouchDevice) return; // drag-to-create is mouse-only if (isTouchDevice) return; // drag-to-create is mouse-only
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
setCreating({ carId, startDateStr: dateStr, endDateStr: dateStr }); setCreating({ carId, startSlot: slot, endSlot: slot });
}; };
// --- Cell tap to create (touch devices) --- // --- Cell tap to create (touch devices) ---
const handleCellClick = useCallback((e, carId) => { const handleCellClick = useCallback((e, carId) => {
if (!isTouchDevice) return; if (!isTouchDevice) return;
const col = getColFromX(e.clientX); const slot = getSlotFromX(e.clientX);
if (col >= 0 && col < DAYS_SHOWN) { if (slot >= 0 && slot < HALF_SLOTS) {
const dateStr = dateToStr(dates[col]); const dayIdx = Math.floor(slot / 2);
const dateStr = dateToStr(dates[dayIdx]);
const period = slot % 2 === 0 ? '午前' : '午後';
setModal({ setModal({
mode: 'create', 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) => { const handleGridMouseMove = useCallback((e) => {
if (creating) { if (creating) {
const col = getColFromX(e.clientX); const slot = getSlotFromX(e.clientX);
if (col >= 0 && col < DAYS_SHOWN) { if (slot >= 0 && slot < HALF_SLOTS) {
const hoveredDate = dateToStr(dates[col]);
setCreating((prev) => { setCreating((prev) => {
if (!prev) return null; if (!prev) return null;
// Ensure start <= end const s = prev.startSlot;
const s = prev.startDateStr; const h = slot;
const h = hoveredDate;
return { return {
...prev, ...prev,
endDateStr: h >= s ? h : s, endSlot: h >= s ? h : s,
startDateStr: h < s ? h : prev.startDateStr, startSlot: h < s ? h : prev.startSlot,
}; };
}); });
} }
} }
if (moving) { if (moving) {
const col = getColFromX(e.clientX); const slot = getSlotFromX(e.clientX);
const row = getRowFromY(e.clientY); const row = getRowFromY(e.clientY);
movingRef.current = { ...movingRef.current }; movingRef.current = { ...movingRef.current };
if (col >= 0 && col < DAYS_SHOWN) { if (slot >= 0 && slot < HALF_SLOTS) {
const newStartCol = Math.max(0, col - moving.grabDayOffset); const durationSlots = moving.durationSlots;
const duration = differenceInDays( const newStartSlot = Math.max(0, slot - moving.grabSlotOffset);
parseISO(moving.reservation.end_date), const clampedStartSlot = Math.min(newStartSlot, HALF_SLOTS - 1 - durationSlots);
parseISO(moving.reservation.start_date) const clampedEndSlot = clampedStartSlot + durationSlots;
);
const clampedStartCol = Math.min(newStartCol, DAYS_SHOWN - 1 - duration); const newStartDayIdx = Math.max(0, Math.min(Math.floor(clampedStartSlot / 2), DAYS_SHOWN - 1));
const newStartDate = dateToStr(dates[Math.max(0, clampedStartCol)]); const newEndDayIdx = Math.max(0, Math.min(Math.floor(clampedEndSlot / 2), DAYS_SHOWN - 1));
const newEndDate = dateToStr(addDays(dates[Math.max(0, clampedStartCol)], duration)); const newStartDate = dateToStr(dates[newStartDayIdx]);
const newStartPeriod = clampedStartSlot % 2 === 0 ? '午前' : '午後';
const newEndDate = dateToStr(dates[newEndDayIdx]);
const newEndPeriod = clampedEndSlot % 2 === 0 ? '午前' : '午後';
let newCarId = moving.currentCarId; let newCarId = moving.currentCarId;
if (row >= 0 && row < cars.length) { if (row >= 0 && row < cars.length) {
@@ -176,56 +181,56 @@ export default function ScheduleView({ reloadKey = 0 }) {
setMoving((prev) => prev ? { setMoving((prev) => prev ? {
...prev, ...prev,
currentCarId: newCarId, currentCarId: newCarId,
currentStartSlot: clampedStartSlot,
currentStartDate: newStartDate, currentStartDate: newStartDate,
currentStartPeriod: newStartPeriod,
currentEndDate: newEndDate, currentEndDate: newEndDate,
col: clampedStartCol, currentEndPeriod: newEndPeriod,
col: clampedStartSlot,
row: row >= 0 && row < cars.length ? row : prev.row, row: row >= 0 && row < cars.length ? row : prev.row,
} : null); } : null);
} }
} }
}, [creating, moving, dates, cars, getColFromX, getRowFromY]); }, [creating, moving, dates, cars, getSlotFromX, getRowFromY]);
const handleGridMouseUp = useCallback(async (e) => { const handleGridMouseUp = useCallback(async (e) => {
if (creating) { if (creating) {
const { carId, startDateStr, endDateStr } = creating; const { carId, startSlot, endSlot } = creating;
setCreating(null); 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 // Open modal to confirm/fill details
setModal({ setModal({
mode: 'create', mode: 'create',
prefill: { prefill: {
car_id: carId, car_id: carId,
start_date: startDateStr, start_date: dateToStr(dates[startDayIdx]),
end_date: endDateStr, start_period: startSlot % 2 === 0 ? '午前' : '午後',
end_date: dateToStr(dates[endDayIdx]),
end_period: endSlot % 2 === 0 ? '午前' : '午後',
}, },
}); });
} }
if (moving) { if (moving) {
const { reservation, currentCarId, currentStartDate, currentEndDate } = moving; const { reservation, currentCarId, origStartSlot, currentStartSlot, currentStartDate, currentStartPeriod, currentEndDate, currentEndPeriod } = moving;
setMoving(null); setMoving(null);
movingRef.current = 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 // Only update if something changed
if ( if (
currentCarId !== reservation.car_id || currentCarId !== reservation.car_id ||
currentStartDate !== reservation.start_date currentStartSlot !== origStartSlot
) { ) {
try { try {
await api.updateReservation(reservation.id, { await api.updateReservation(reservation.id, {
car_id: currentCarId, car_id: currentCarId,
start_date: currentStartDate, start_date: currentStartDate,
end_date: newEndDate, end_date: currentEndDate,
customer_name: reservation.customer_name, customer_name: reservation.customer_name,
notes: reservation.notes, notes: reservation.notes,
start_period: reservation.start_period, start_period: currentStartPeriod,
end_period: reservation.end_period, end_period: currentEndPeriod,
}); });
await loadData(); await loadData();
} catch (err) { } catch (err) {
@@ -234,7 +239,7 @@ export default function ScheduleView({ reloadKey = 0 }) {
} }
} }
} }
}, [creating, moving, loadData]); }, [creating, moving, dates, loadData]);
useEffect(() => { useEffect(() => {
window.addEventListener('mousemove', handleGridMouseMove); window.addEventListener('mousemove', handleGridMouseMove);
@@ -265,18 +270,28 @@ export default function ScheduleView({ reloadKey = 0 }) {
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
const col = getColFromX(e.clientX); const slot = getSlotFromX(e.clientX);
const startCol = Math.max(0, dates.findIndex((d) => dateToStr(d) === reservation.start_date)); // Compute the reservation's start/end slot within the current view
const grabOffset = col >= 0 ? col - startCol : 0; 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); const carRow = cars.findIndex((c) => c.id === reservation.car_id);
setMoving({ setMoving({
reservation, reservation,
grabDayOffset: Math.max(0, grabOffset), grabSlotOffset: Math.max(0, grabOffset),
durationSlots,
origStartSlot: startSlot,
currentCarId: reservation.car_id, currentCarId: reservation.car_id,
currentStartSlot: startSlot,
currentStartDate: reservation.start_date, currentStartDate: reservation.start_date,
currentStartPeriod: reservation.start_period || '午前',
currentEndDate: reservation.end_date, currentEndDate: reservation.end_date,
col: startCol, currentEndPeriod: reservation.end_period || '午後',
col: startSlot,
row: carRow, row: carRow,
}); });
}; };
@@ -315,26 +330,26 @@ export default function ScheduleView({ reloadKey = 0 }) {
(r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr (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) { function getReservationLayout(r) {
const rStart = r.start_date < viewStartStr ? viewStartStr : r.start_date; const rStart = r.start_date < viewStartStr ? viewStartStr : r.start_date;
const rEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date; const rEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date;
const startCol = differenceInDays(parseISO(rStart), viewStart); const startDayCol = differenceInDays(parseISO(rStart), viewStart);
const endCol = differenceInDays(parseISO(rEnd), viewStart); const endDayCol = differenceInDays(parseISO(rEnd), viewStart);
const left = startCol * CELL_WIDTH; // Even slot = 午前, odd slot = 午後; empty period treated as 午前 for start, 午後 for end
const width = (endCol - startCol + 1) * CELL_WIDTH; const startSlot = startDayCol * 2 + (r.start_period === '午後' ? 1 : 0);
return { left, width, startCol, endCol }; 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 // Create ghost for currently moving reservation
const movingGhost = moving ? (() => { const movingGhost = moving ? (() => {
const duration = differenceInDays(
parseISO(moving.reservation.end_date),
parseISO(moving.reservation.start_date)
);
const col = moving.col ?? 0; const col = moving.col ?? 0;
const durationSlots = moving.durationSlots;
const left = col * CELL_WIDTH; const left = col * CELL_WIDTH;
const width = (duration + 1) * CELL_WIDTH; const width = (durationSlots + 1) * CELL_WIDTH;
return { col, left, width, row: moving.row }; return { col, left, width, row: moving.row };
})() : null; })() : null;
@@ -394,7 +409,7 @@ export default function ScheduleView({ reloadKey = 0 }) {
)} )}
<div <div
className={styles.grid} className={styles.grid}
style={{ width: LABEL_WIDTH + DAYS_SHOWN * CELL_WIDTH }} style={{ width: LABEL_WIDTH + DAYS_SHOWN * 2 * CELL_WIDTH }}
> >
{/* Header row */} {/* Header row */}
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}> <div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
@@ -403,7 +418,7 @@ export default function ScheduleView({ reloadKey = 0 }) {
className={styles.cornerCell} className={styles.cornerCell}
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }} style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
/> />
{/* Date headers */} {/* Date headers — each day spans two half-day slots (午前 + 午後) */}
{dates.map((date) => { {dates.map((date) => {
const ds = dateToStr(date); const ds = dateToStr(date);
const isToday = ds === todayStr; const isToday = ds === todayStr;
@@ -412,12 +427,18 @@ export default function ScheduleView({ reloadKey = 0 }) {
return ( return (
<div <div
key={ds} key={ds}
className={`${styles.dateHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`} className={`${styles.dateHeaderGroup} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
style={{ width: CELL_WIDTH, height: HEADER_HEIGHT }} style={{ width: CELL_WIDTH * 2, height: HEADER_HEIGHT }}
> >
<div className={styles.dateHeaderTop}>
<span className={styles.dateDay}>{format(date, 'd')}</span> <span className={styles.dateDay}>{format(date, 'd')}</span>
<span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span> <span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span>
</div> </div>
<div className={styles.dateHeaderAmPm}>
<div className={styles.periodLabel}>午前</div>
<div className={styles.periodLabel}>午後</div>
</div>
</div>
); );
})} })}
</div> </div>
@@ -434,10 +455,8 @@ export default function ScheduleView({ reloadKey = 0 }) {
const isCreatingRow = creating && creating.carId === car.id; const isCreatingRow = creating && creating.carId === car.id;
let creatingLeft = 0, creatingWidth = 0; let creatingLeft = 0, creatingWidth = 0;
if (isCreatingRow) { if (isCreatingRow) {
const startCol = differenceInDays(parseISO(creating.startDateStr), viewStart); creatingLeft = Math.max(0, creating.startSlot) * CELL_WIDTH;
const endCol = differenceInDays(parseISO(creating.endDateStr), viewStart); creatingWidth = (Math.min(creating.endSlot, HALF_SLOTS - 1) - Math.max(0, creating.startSlot) + 1) * CELL_WIDTH;
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 // Ghost reservation for this row
@@ -469,25 +488,25 @@ export default function ScheduleView({ reloadKey = 0 }) {
</span> </span>
</div> </div>
{/* Day cells */} {/* Day cells — two per day (午前 then 午後) */}
<div <div
className={styles.cellArea} className={styles.cellArea}
style={{ width: DAYS_SHOWN * CELL_WIDTH, height: ROW_HEIGHT }} style={{ width: DAYS_SHOWN * 2 * CELL_WIDTH, height: ROW_HEIGHT }}
onClick={(e) => handleCellClick(e, car.id)} onClick={(e) => handleCellClick(e, car.id)}
> >
{dates.map((date) => { {dates.flatMap((date, dayIdx) => {
const ds = dateToStr(date); const ds = dateToStr(date);
const isToday = ds === todayStr; const isToday = ds === todayStr;
const dow = format(date, 'E', { locale: ja }); const dow = format(date, 'E', { locale: ja });
const isWeekend = dow === '土' || dow === '日'; const isWeekend = dow === '土' || dow === '日';
return ( return ['午前', '午後'].map((period, pIdx) => (
<div <div
key={ds} key={`${ds}-${period}`}
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''}`} className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''} ${pIdx === 1 ? styles.cellDayEnd : ''}`}
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }} style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
onMouseDown={(e) => handleCellMouseDown(e, car.id, ds)} onMouseDown={(e) => handleCellMouseDown(e, car.id, dayIdx * 2 + pIdx)}
/> />
); ));
})} })}
{/* Creating highlight */} {/* Creating highlight */}

View File

@@ -124,6 +124,47 @@
z-index: 30; 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 { .dateHeader {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -281,6 +322,10 @@
background: rgba(26, 86, 219, 0.04); background: rgba(26, 86, 219, 0.04);
} }
.cellDayEnd {
border-right: 1px solid #d1d5db;
}
.todayCell { .todayCell {
background: rgba(59, 130, 246, 0.06); background: rgba(59, 130, 246, 0.06);
} }