Compare commits
6 Commits
copilot/cr
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc3ad148fc | ||
|
|
1eb96877ff | ||
|
|
76dc94dd78 | ||
|
|
c3dd0cfa69 | ||
|
|
40371b43d1 | ||
|
|
19953dff55 |
21
README.md
21
README.md
@@ -51,6 +51,27 @@ npm run dev
|
|||||||
|
|
||||||
Open http://localhost:5173 in your browser.
|
Open http://localhost:5173 in your browser.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
#### Backend URL (development proxy)
|
||||||
|
|
||||||
|
By default the Vite dev server proxies `/api` requests to `http://localhost:3001`.
|
||||||
|
To point to a different backend server, set the `BACKEND_URL` environment variable when starting the frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BACKEND_URL=http://192.168.1.10:3001 npm run dev:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Backend URL (production build)
|
||||||
|
|
||||||
|
When the frontend is deployed separately from the backend, set `VITE_API_BASE_URL` to the backend server's origin before building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_BASE_URL=https://api.example.com npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes the built frontend send API requests to `https://api.example.com/api`.
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const path = require('path');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
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(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@@ -7,7 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ScheduleView from './components/ScheduleView.jsx';
|
import ScheduleView from './components/ScheduleView.jsx';
|
||||||
import CarManagement from './components/CarManagement.jsx';
|
import CarManagement from './components/CarManagement.jsx';
|
||||||
|
import TimelineView from './components/TimelineView.jsx';
|
||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -17,6 +18,12 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
📅 スケジュール
|
📅 スケジュール
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.navBtn} ${page === 'timeline' ? styles.active : ''}`}
|
||||||
|
onClick={() => setPage('timeline')}
|
||||||
|
>
|
||||||
|
📊 タイムライン
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`${styles.navBtn} ${page === 'cars' ? styles.active : ''}`}
|
className={`${styles.navBtn} ${page === 'cars' ? styles.active : ''}`}
|
||||||
onClick={() => setPage('cars')}
|
onClick={() => setPage('cars')}
|
||||||
@@ -26,7 +33,9 @@ export default function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
{page === 'schedule' ? <ScheduleView /> : <CarManagement />}
|
{page === 'schedule' && <ScheduleView />}
|
||||||
|
{page === 'timeline' && <TimelineView />}
|
||||||
|
{page === 'cars' && <CarManagement />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = '/api';
|
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api';
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import styles from './CarManagement.module.css';
|
|||||||
|
|
||||||
export default function CarManagement() {
|
export default function CarManagement() {
|
||||||
const [cars, setCars] = useState([]);
|
const [cars, setCars] = useState([]);
|
||||||
|
const [reservations, setReservations] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [newCarName, setNewCarName] = useState('');
|
const [newCarName, setNewCarName] = useState('');
|
||||||
@@ -16,8 +17,9 @@ export default function CarManagement() {
|
|||||||
const loadCars = async () => {
|
const loadCars = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await api.getCars();
|
const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]);
|
||||||
setCars(data);
|
setCars(carsData);
|
||||||
|
setReservations(resData);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
@@ -47,7 +49,11 @@ export default function CarManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id, name) => {
|
const handleDelete = async (id, name) => {
|
||||||
if (!confirm(`「${name}」を削除しますか?\n関連する予約もすべて削除されます。`)) return;
|
const carReservations = reservations.filter((r) => r.car_id === id);
|
||||||
|
const message = carReservations.length > 0
|
||||||
|
? `「${name}」を削除しますか?\n⚠ この代車には ${carReservations.length} 件の予約があります。削除するとこれらの予約もすべて削除されます。`
|
||||||
|
: `「${name}」を削除しますか?\n関連する予約もすべて削除されます。`;
|
||||||
|
if (!confirm(message)) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteCar(id);
|
await api.deleteCar(id);
|
||||||
await loadCars();
|
await loadCars();
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const LABEL_WIDTH = 140; // px for car name column
|
|||||||
const HEADER_HEIGHT = 72; // px for the date header row
|
const HEADER_HEIGHT = 72; // px for the date header row
|
||||||
const DAYS_SHOWN = 21; // number of days to show
|
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)
|
// Palette for reservation colors (cycle through them by car index)
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
{ bg: '#dbeafe', border: '#3b82f6', text: '#1e3a8a' },
|
{ bg: '#dbeafe', border: '#3b82f6', text: '#1e3a8a' },
|
||||||
@@ -54,6 +58,10 @@ export default function ScheduleView() {
|
|||||||
const [modal, setModal] = useState(null);
|
const [modal, setModal] = useState(null);
|
||||||
// null | { mode: 'create', prefill: {...} } | { mode: 'edit', reservation: {...} }
|
// 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 gridRef = useRef(null);
|
||||||
const movingRef = useRef(null); // keeps latest moving state for event handlers
|
const movingRef = useRef(null); // keeps latest moving state for event handlers
|
||||||
|
|
||||||
@@ -106,11 +114,25 @@ export default function ScheduleView() {
|
|||||||
|
|
||||||
// --- Cell drag to create ---
|
// --- Cell drag to create ---
|
||||||
const handleCellMouseDown = (e, carId, dateStr) => {
|
const handleCellMouseDown = (e, carId, dateStr) => {
|
||||||
|
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, 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) => {
|
const handleGridMouseMove = useCallback((e) => {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
const col = getColFromX(e.clientX);
|
const col = getColFromX(e.clientX);
|
||||||
@@ -220,9 +242,23 @@ export default function ScheduleView() {
|
|||||||
};
|
};
|
||||||
}, [handleGridMouseMove, 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 ---
|
// --- Reservation drag to move ---
|
||||||
const handleReservationMouseDown = (e, reservation) => {
|
const handleReservationMouseDown = (e, reservation) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (isTouchDevice) return; // drag-to-move is mouse-only
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -413,6 +449,7 @@ export default function ScheduleView() {
|
|||||||
<div
|
<div
|
||||||
className={styles.cellArea}
|
className={styles.cellArea}
|
||||||
style={{ width: DAYS_SHOWN * CELL_WIDTH, height: ROW_HEIGHT }}
|
style={{ width: DAYS_SHOWN * CELL_WIDTH, height: ROW_HEIGHT }}
|
||||||
|
onClick={(e) => handleCellClick(e, car.id)}
|
||||||
>
|
>
|
||||||
{dates.map((date) => {
|
{dates.map((date) => {
|
||||||
const ds = dateToStr(date);
|
const ds = dateToStr(date);
|
||||||
@@ -422,7 +459,7 @@ export default function ScheduleView() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ds}
|
key={ds}
|
||||||
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''}`}
|
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''}`}
|
||||||
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, ds)}
|
||||||
/>
|
/>
|
||||||
@@ -479,7 +516,7 @@ export default function ScheduleView() {
|
|||||||
background: color.bg,
|
background: color.bg,
|
||||||
borderColor: color.border,
|
borderColor: color.border,
|
||||||
color: color.text,
|
color: color.text,
|
||||||
cursor: 'grab',
|
cursor: isTouchDevice ? 'pointer' : 'grab',
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => handleReservationMouseDown(e, r)}
|
onMouseDown={(e) => handleReservationMouseDown(e, r)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -488,6 +525,11 @@ export default function ScheduleView() {
|
|||||||
setModal({ mode: 'edit', reservation: r });
|
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 : ''}`}
|
title={`${r.customer_name || '予約'}\n${r.start_date} 〜 ${r.end_date}${r.notes ? '\n' + r.notes : ''}`}
|
||||||
>
|
>
|
||||||
<span className={styles.blockText}>
|
<span className={styles.blockText}>
|
||||||
@@ -524,6 +566,34 @@ export default function ScheduleView() {
|
|||||||
onClose={() => setModal(null)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,10 @@
|
|||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cellTouch {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.cell:hover {
|
.cell:hover {
|
||||||
background: rgba(26, 86, 219, 0.04);
|
background: rgba(26, 86, 219, 0.04);
|
||||||
}
|
}
|
||||||
@@ -264,3 +268,41 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 14px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
347
frontend/src/components/TimelineView.jsx
Normal file
347
frontend/src/components/TimelineView.jsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div className={styles.navGroup}>
|
||||||
|
<button className={styles.toolBtn} onClick={prevMonth}>‹ 前月</button>
|
||||||
|
<button className={styles.toolBtn} onClick={goThisMonth}>今月</button>
|
||||||
|
<button className={styles.toolBtn} onClick={nextMonth}>次月 ›</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.monthLabel}>
|
||||||
|
{format(viewStart, 'yyyy年M月', { locale: ja })}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.addBtn}
|
||||||
|
disabled={cars.length === 0}
|
||||||
|
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>}
|
||||||
|
|
||||||
|
{/* Timeline grid */}
|
||||||
|
<div className={styles.gridWrapper} ref={gridRef}>
|
||||||
|
<div className={styles.grid} style={{ width: totalWidth }}>
|
||||||
|
{/* Sticky header: month/day labels */}
|
||||||
|
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
|
||||||
|
{/* Corner */}
|
||||||
|
<div
|
||||||
|
className={styles.cornerCell}
|
||||||
|
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
|
||||||
|
>
|
||||||
|
<span className={styles.cornerText}>予約一覧</span>
|
||||||
|
</div>
|
||||||
|
{/* 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 (
|
||||||
|
<div
|
||||||
|
key={ds}
|
||||||
|
className={`${styles.dayHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
|
||||||
|
style={{ width: DAY_WIDTH, height: HEADER_HEIGHT }}
|
||||||
|
>
|
||||||
|
<span className={styles.dayNum}>{format(date, 'd')}</span>
|
||||||
|
<span className={`${styles.dayDow} ${isSun ? styles.sunDow : ''} ${isSat ? styles.satDow : ''}`}>{dow}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div key={r.id} className={styles.resRow} style={{ height: ROW_HEIGHT }}>
|
||||||
|
{/* Label: car + customer */}
|
||||||
|
<div
|
||||||
|
className={styles.resLabel}
|
||||||
|
style={{ width: LABEL_WIDTH, height: ROW_HEIGHT }}
|
||||||
|
>
|
||||||
|
<span className={styles.carDot} style={{ background: color.border }} />
|
||||||
|
<div className={styles.labelText}>
|
||||||
|
<span className={styles.labelCar}>{car?.name ?? '—'}</span>
|
||||||
|
<span className={styles.labelCustomer}>{r.customer_name || '(名前なし)'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day cells (background) */}
|
||||||
|
<div
|
||||||
|
className={styles.cellArea}
|
||||||
|
style={{ width: days.length * DAY_WIDTH, height: ROW_HEIGHT, position: 'relative' }}
|
||||||
|
>
|
||||||
|
{days.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: DAY_WIDTH, height: ROW_HEIGHT }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Bar */}
|
||||||
|
<div
|
||||||
|
className={styles.bar}
|
||||||
|
style={{
|
||||||
|
left,
|
||||||
|
width: width - BAR_PADDING,
|
||||||
|
background: color.bg,
|
||||||
|
borderColor: color.border,
|
||||||
|
color: color.text,
|
||||||
|
}}
|
||||||
|
onClick={() => 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.barText}>
|
||||||
|
{r.customer_name || '予約'}
|
||||||
|
</span>
|
||||||
|
{width > 80 && (
|
||||||
|
<span className={styles.barDates}>
|
||||||
|
{r.start_date.slice(5)} 〜 {r.end_date.slice(5)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{visibleReservations.length === 0 && !loading && (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
この月には予約がありません。
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
frontend/src/components/TimelineView.module.css
Normal file
324
frontend/src/components/TimelineView.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: process.env.BACKEND_URL || 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user