Implement car reservation schedule management system

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/1d8c6b05-0e8d-4484-a2d8-8d427dfad9cb
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 18:03:33 +00:00
parent 3458e4d376
commit 50d3803610
22 changed files with 4500 additions and 0 deletions

View File

@@ -0,0 +1,529 @@
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
// 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: {...} }
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 (e.button !== 0) return;
e.preventDefault();
setCreating({ carId, startDateStr: dateStr, endDateStr: dateStr });
};
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]);
// --- Reservation drag to move ---
const handleReservationMouseDown = (e, reservation) => {
e.stopPropagation();
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 (
<div className={styles.container}>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.navGroup}>
<button className={styles.toolBtn} onClick={prevWeek}> 前週</button>
<button className={styles.toolBtn} onClick={goToday}>今日</button>
<button className={styles.toolBtn} onClick={nextWeek}>次週 </button>
</div>
<div className={styles.dateRange}>
{format(viewStart, 'yyyy年M月d日', { locale: ja })} {' '}
{format(dates[dates.length - 1], 'yyyy年M月d日', { locale: ja })}
</div>
<button
className={styles.addBtn}
onClick={() =>
setModal({
mode: 'create',
prefill: {
car_id: cars[0]?.id,
start_date: todayStr,
end_date: todayStr,
},
})
}
>
+ 予約を追加
</button>
</div>
{error && <div className={styles.error}>エラー: {error}</div>}
{loading && <div className={styles.loading}>読み込み中...</div>}
{/* Grid */}
<div
className={styles.gridWrapper}
ref={gridRef}
onMouseLeave={() => {
// don't cancel on leave — handled by global events
}}
>
<div
className={styles.grid}
style={{ width: LABEL_WIDTH + DAYS_SHOWN * CELL_WIDTH }}
>
{/* Header row */}
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
{/* Corner cell */}
<div
className={styles.cornerCell}
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
/>
{/* 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 (
<div
key={ds}
className={`${styles.dateHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
style={{ width: CELL_WIDTH, height: HEADER_HEIGHT }}
>
<span className={styles.dateDay}>{format(date, 'd')}</span>
<span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span>
</div>
);
})}
</div>
{/* 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 (
<div key={car.id} className={styles.carRow} style={{ height: ROW_HEIGHT }}>
{/* Car label */}
<div
className={styles.carLabel}
style={{ width: LABEL_WIDTH, height: ROW_HEIGHT }}
title={car.description || car.name}
>
<span className={styles.carDot} style={{ background: color.border }} />
<span className={styles.carName}>{car.name}</span>
</div>
{/* Day cells */}
<div
className={styles.cellArea}
style={{ width: DAYS_SHOWN * CELL_WIDTH, height: ROW_HEIGHT }}
>
{dates.map((date) => {
const ds = dateToStr(date);
const isToday = ds === todayStr;
const dow = format(date, 'E', { locale: ja });
const isWeekend = dow === '土' || dow === '日';
return (
<div
key={ds}
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''}`}
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
onMouseDown={(e) => handleCellMouseDown(e, car.id, ds)}
/>
);
})}
{/* Creating highlight */}
{isCreatingRow && creatingWidth > 0 && (
<div
className={styles.creatingHighlight}
style={{
left: creatingLeft,
width: creatingWidth,
top: 4,
height: ROW_HEIGHT - 8,
}}
/>
)}
{/* Ghost while moving */}
{showGhost && (
<div
className={styles.reservationBlock}
style={{
left: movingGhost.left,
width: movingGhost.width,
top: 5,
height: ROW_HEIGHT - 10,
background: color.bg,
borderColor: color.border,
color: color.text,
opacity: 0.6,
cursor: 'grabbing',
}}
>
<span className={styles.blockText}>
{moving.reservation.customer_name || '予約'}
</span>
</div>
)}
{/* Reservation blocks */}
{carReservations.map((r) => {
const { left, width } = getReservationLayout(r);
return (
<div
key={r.id}
className={styles.reservationBlock}
style={{
left,
width: width - 4,
top: 5,
height: ROW_HEIGHT - 10,
background: color.bg,
borderColor: color.border,
color: color.text,
cursor: 'grab',
}}
onMouseDown={(e) => handleReservationMouseDown(e, r)}
onClick={(e) => {
if (!moving) {
e.stopPropagation();
setModal({ mode: 'edit', reservation: r });
}
}}
title={`${r.customer_name || '予約'}\n${r.start_date}${r.end_date}${r.notes ? '\n' + r.notes : ''}`}
>
<span className={styles.blockText}>
{r.customer_name || '予約'}
</span>
{width > 80 && (
<span className={styles.blockDates}>
{r.start_date.slice(5)} {r.end_date.slice(5)}
</span>
)}
</div>
);
})}
</div>
</div>
);
})}
{cars.length === 0 && !loading && (
<div className={styles.noCars}>
代車が登録されていません代車管理から追加してください
</div>
)}
</div>
</div>
{/* Reservation Modal */}
{modal && (
<ReservationModal
cars={cars}
reservation={modal.mode === 'edit' ? modal.reservation : modal.prefill}
onSave={handleModalSave}
onDelete={handleModalDelete}
onClose={() => setModal(null)}
/>
)}
</div>
);
}