Files
car/frontend/src/components/ScheduleView.jsx
copilot-swe-agent[bot] cc3ad148fc Add timeline view, right-click context menu, and fix Express trust proxy
- backend/server.js: Add app.set('trust proxy', 1) to fix express-rate-limit
  ValidationError when app runs behind nginx reverse proxy
- ScheduleView.jsx: Add right-click context menu on reservation blocks with
  Edit and Delete options; closes on click-outside or Escape
- ScheduleView.module.css: Add context menu styles
- TimelineView.jsx: New Gantt-style monthly timeline view showing all
  reservations sorted by date, with month navigation and right-click menu
- TimelineView.module.css: Styles for the timeline view
- App.jsx: Add 'タイムライン' tab to navigation

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/d03ca12c-21ce-45a0-881f-919d6635e7fb
2026-03-20 18:50:51 +00:00

600 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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 }}
onClick={(e) => 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 (
<div
key={ds}
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''}`}
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: isTouchDevice ? 'pointer' : 'grab',
}}
onMouseDown={(e) => 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 : ''}`}
>
<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)}
/>
)}
{/* Right-click context menu */}
{contextMenu && (
<div
className={styles.contextMenu}
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className={styles.contextMenuItem}
onClick={() => {
setModal({ mode: 'edit', reservation: contextMenu.reservation });
setContextMenu(null);
}}
>
編集
</button>
<button
className={`${styles.contextMenuItem} ${styles.contextMenuItemDelete}`}
onClick={async () => {
setContextMenu(null);
await handleModalDelete(contextMenu.reservation.id);
}}
>
🗑 削除
</button>
</div>
)}
</div>
);
}