From cc3ad148fc81cc4e11a798e9c937983d0a002cb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:50:51 +0000 Subject: [PATCH] Add timeline view, right-click context menu, and fix Express trust proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/server.js | 4 + frontend/src/App.jsx | 11 +- frontend/src/components/ScheduleView.jsx | 50 +++ .../src/components/ScheduleView.module.css | 38 ++ frontend/src/components/TimelineView.jsx | 347 ++++++++++++++++++ .../src/components/TimelineView.module.css | 324 ++++++++++++++++ 6 files changed, 773 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/TimelineView.jsx create mode 100644 frontend/src/components/TimelineView.module.css diff --git a/backend/server.js b/backend/server.js index a59b617..e5e71af 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,6 +7,10 @@ const path = require('path'); const app = express(); const PORT = process.env.PORT || 3001; +// Trust the first proxy (nginx) so that express-rate-limit can correctly +// identify clients by their real IP from the X-Forwarded-For header. +app.set('trust proxy', 1); + app.use(cors()); app.use(express.json()); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c5b7721..8b729f3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import ScheduleView from './components/ScheduleView.jsx'; import CarManagement from './components/CarManagement.jsx'; +import TimelineView from './components/TimelineView.jsx'; import styles from './App.module.css'; export default function App() { @@ -17,6 +18,12 @@ export default function App() { > 📅 スケジュール + + + + )} ); } diff --git a/frontend/src/components/ScheduleView.module.css b/frontend/src/components/ScheduleView.module.css index 4ff720f..7aaffdd 100644 --- a/frontend/src/components/ScheduleView.module.css +++ b/frontend/src/components/ScheduleView.module.css @@ -268,3 +268,41 @@ color: #6b7280; font-size: 14px; } + +/* Right-click context menu */ +.contextMenu { + position: fixed; + background: white; + border: 1px solid #d1d5db; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + z-index: 1000; + min-width: 140px; + overflow: hidden; + padding: 4px 0; +} + +.contextMenuItem { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 9px 16px; + font-size: 13px; + color: #374151; + cursor: pointer; + transition: background 0.1s; +} + +.contextMenuItem:hover { + background: #f3f4f6; +} + +.contextMenuItemDelete { + color: #dc2626; +} + +.contextMenuItemDelete:hover { + background: #fee2e2; +} diff --git a/frontend/src/components/TimelineView.jsx b/frontend/src/components/TimelineView.jsx new file mode 100644 index 0000000..52b9417 --- /dev/null +++ b/frontend/src/components/TimelineView.jsx @@ -0,0 +1,347 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { format, addDays, addMonths, startOfMonth, endOfMonth, parseISO, differenceInDays } from 'date-fns'; +import { ja } from 'date-fns/locale'; +import { api } from '../api.js'; +import ReservationModal from './ReservationModal.jsx'; +import styles from './TimelineView.module.css'; + +const ROW_HEIGHT = 48; // px per reservation row +const LABEL_WIDTH = 180; // px for reservation info column +const HEADER_HEIGHT = 60; // px for date header +const DAY_WIDTH = 36; // px per day column +const BAR_PADDING = 4; // px gap between bar and row edge + +// Same colour palette as ScheduleView +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 TimelineView() { + const [cars, setCars] = useState([]); + const [reservations, setReservations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + + // View window: show the current month by default + const [viewStart, setViewStart] = useState(() => startOfMonth(new Date())); + const [viewEnd, setViewEnd] = useState(() => endOfMonth(new Date())); + + const gridRef = useRef(null); + + const days = (() => { + const result = []; + let d = viewStart; + while (d <= viewEnd) { + result.push(d); + d = addDays(d, 1); + } + return result; + })(); + + const totalWidth = LABEL_WIDTH + days.length * DAY_WIDTH; + const todayStr = dateToStr(new Date()); + + // --- 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]); + + // Close context menu on click / 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]); + + // --- Navigation --- + const prevMonth = () => { + const start = addMonths(viewStart, -1); + setViewStart(startOfMonth(start)); + setViewEnd(endOfMonth(start)); + }; + const nextMonth = () => { + const start = addMonths(viewStart, 1); + setViewStart(startOfMonth(start)); + setViewEnd(endOfMonth(start)); + }; + const goThisMonth = () => { + setViewStart(startOfMonth(new Date())); + setViewEnd(endOfMonth(new Date())); + }; + + // --- 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}`); + } + }; + + // Build car colour map + const carColorMap = {}; + cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); }); + + // Sort reservations by start_date then car + const sortedReservations = [...reservations].sort((a, b) => { + if (a.start_date !== b.start_date) return a.start_date < b.start_date ? -1 : 1; + return a.car_id - b.car_id; + }); + + const viewStartStr = dateToStr(viewStart); + const viewEndStr = dateToStr(viewEnd); + + // Filter to reservations that overlap the view window + const visibleReservations = sortedReservations.filter( + (r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr + ); + + function getBarLayout(r) { + const clampedStart = r.start_date < viewStartStr ? viewStartStr : r.start_date; + const clampedEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date; + const startOffset = differenceInDays(parseISO(clampedStart), viewStart); + const endOffset = differenceInDays(parseISO(clampedEnd), viewStart); + const left = startOffset * DAY_WIDTH; + const width = (endOffset - startOffset + 1) * DAY_WIDTH; + return { left, width }; + } + + return ( +
+ {/* Toolbar */} +
+
+ + + +
+
+ {format(viewStart, 'yyyy年M月', { locale: ja })} +
+ +
+ + {error &&
エラー: {error}
} + {loading &&
読み込み中...
} + + {/* Timeline grid */} +
+
+ {/* Sticky header: month/day labels */} +
+ {/* Corner */} +
+ 予約一覧 +
+ {/* Day columns */} + {days.map((date) => { + const ds = dateToStr(date); + const isToday = ds === todayStr; + const dow = format(date, 'E', { locale: ja }); + const isWeekend = dow === '土' || dow === '日'; + const isSun = dow === '日'; + const isSat = dow === '土'; + return ( +
+ {format(date, 'd')} + {dow} +
+ ); + })} +
+ + {/* Reservation rows */} + {visibleReservations.map((r) => { + const car = cars.find((c) => c.id === r.car_id); + const color = carColorMap[r.car_id] || COLORS[0]; + const { left, width } = getBarLayout(r); + + return ( +
+ {/* Label: car + customer */} +
+ +
+ {car?.name ?? '—'} + {r.customer_name || '(名前なし)'} +
+
+ + {/* Day cells (background) */} +
+ {days.map((date) => { + const ds = dateToStr(date); + const isToday = ds === todayStr; + const dow = format(date, 'E', { locale: ja }); + const isWeekend = dow === '土' || dow === '日'; + return ( +
+ ); + })} + + {/* Bar */} +
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 : ''}`} + > + + {r.customer_name || '予約'} + + {width > 80 && ( + + {r.start_date.slice(5)} 〜 {r.end_date.slice(5)} + + )} +
+
+
+ ); + })} + + {visibleReservations.length === 0 && !loading && ( +
+ この月には予約がありません。 +
+ )} +
+
+ + {/* Reservation Modal */} + {modal && ( + setModal(null)} + /> + )} + + {/* Right-click context menu */} + {contextMenu && ( +
e.stopPropagation()} + > + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/TimelineView.module.css b/frontend/src/components/TimelineView.module.css new file mode 100644 index 0000000..6c4455c --- /dev/null +++ b/frontend/src/components/TimelineView.module.css @@ -0,0 +1,324 @@ +.container { + display: flex; + flex-direction: column; + height: calc(100vh - 56px); + overflow: hidden; +} + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + background: white; + border-bottom: 1px solid #e5e7eb; + flex-shrink: 0; + flex-wrap: wrap; +} + +.navGroup { + display: flex; + gap: 4px; +} + +.toolBtn { + background: #f3f4f6; + border: 1.5px solid #d1d5db; + color: #374151; + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + transition: background 0.15s; + cursor: pointer; +} + +.toolBtn:hover { + background: #e5e7eb; +} + +.monthLabel { + font-size: 15px; + font-weight: 700; + color: #111827; + flex: 1; + text-align: center; +} + +.addBtn { + background: #1a56db; + color: white; + border: none; + padding: 7px 18px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + transition: background 0.15s; + white-space: nowrap; + cursor: pointer; +} + +.addBtn:hover { + background: #1447c0; +} + +.addBtn:disabled { + background: #9ca3af; + cursor: not-allowed; +} + +.error { + background: #fee2e2; + color: #dc2626; + padding: 10px 20px; + font-size: 14px; +} + +.loading { + padding: 20px; + text-align: center; + color: #6b7280; + font-size: 14px; +} + +/* Grid */ +.gridWrapper { + flex: 1; + overflow: auto; + position: relative; + user-select: none; +} + +.grid { + position: relative; + min-height: 100%; +} + +/* Header row */ +.headerRow { + display: flex; + position: sticky; + top: 0; + z-index: 20; + background: white; + border-bottom: 2px solid #d1d5db; +} + +.cornerCell { + flex-shrink: 0; + background: #f9fafb; + border-right: 2px solid #d1d5db; + position: sticky; + left: 0; + z-index: 30; + display: flex; + align-items: center; + padding: 0 16px; +} + +.cornerText { + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.dayHeader { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-right: 1px solid #e5e7eb; + gap: 2px; + background: white; +} + +.todayHeader { + background: #eff6ff; +} + +.weekendHeader { + background: #fafafa; +} + +.dayNum { + font-size: 13px; + font-weight: 700; + color: #111827; + line-height: 1; +} + +.dayDow { + font-size: 10px; + color: #9ca3af; + line-height: 1; +} + +.sunDow { + color: #ef4444; +} + +.satDow { + color: #3b82f6; +} + +/* Reservation rows */ +.resRow { + display: flex; + border-bottom: 1px solid #e5e7eb; +} + +.resRow:hover .cellArea { + background: #fafafa; +} + +.resLabel { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + border-right: 2px solid #d1d5db; + background: white; + position: sticky; + left: 0; + z-index: 10; + box-shadow: 2px 0 4px rgba(0,0,0,0.04); +} + +.carDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.labelText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.labelCar { + font-size: 11px; + color: #6b7280; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.labelCustomer { + font-size: 13px; + font-weight: 600; + color: #111827; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Cell area */ +.cellArea { + display: flex; + position: relative; + flex-shrink: 0; +} + +.cell { + flex-shrink: 0; + border-right: 1px solid #f0f0f0; +} + +.todayCell { + background: rgba(59, 130, 246, 0.06); +} + +.weekendCell { + background: rgba(0,0,0,0.015); +} + +/* Bar */ +.bar { + position: absolute; + top: 6px; + height: calc(100% - 12px); + border-radius: 6px; + border: 1.5px solid; + display: flex; + flex-direction: column; + justify-content: center; + padding: 2px 8px; + cursor: pointer; + z-index: 5; + overflow: hidden; + transition: box-shadow 0.1s; +} + +.bar:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + z-index: 6; +} + +.barText { + font-size: 12px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.barDates { + font-size: 10px; + opacity: 0.75; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; +} + +.empty { + padding: 40px 20px; + text-align: center; + color: #6b7280; + font-size: 14px; +} + +/* Right-click context menu */ +.contextMenu { + position: fixed; + background: white; + border: 1px solid #d1d5db; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + z-index: 1000; + min-width: 140px; + overflow: hidden; + padding: 4px 0; +} + +.contextMenuItem { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 9px 16px; + font-size: 13px; + color: #374151; + cursor: pointer; + transition: background 0.1s; +} + +.contextMenuItem:hover { + background: #f3f4f6; +} + +.contextMenuItemDelete { + color: #dc2626; +} + +.contextMenuItemDelete:hover { + background: #fee2e2; +}