- 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
600 lines
21 KiB
JavaScript
600 lines
21 KiB
JavaScript
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>
|
||
);
|
||
}
|