7 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
761c7f1971 Changes before error encountered
Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/a42d4e36-a3cf-4ff7-b1cb-f076e601b1b8
2026-03-22 04:25:00 +00:00
h
0bd5efde2c Merge pull request #4 from pdf114514/copilot/add-field-and-sync-features
Implementing vehicle details and real-time editing synchronization
2026-03-22 13:00:47 +09:00
copilot-swe-agent[bot]
8e9db971d3 feat: add inspection_expiry/has_etc/tire_type fields, icons in schedule view, and WebSocket real-time sync
Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/6d0f25ae-6db4-4937-ae2b-6674456a5ca1
2026-03-22 03:58:07 +00:00
h
09872737b7 Merge pull request #3 from pdf114514/copilot/add-popup-for-edit-delete-reservations
Add timeline view, right-click context menu on reservations, fix trust proxy
2026-03-21 03:52:02 +09:00
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
copilot-swe-agent[bot]
1eb96877ff Initial plan 2026-03-20 18:38:28 +00:00
h
76dc94dd78 Merge pull request #2 from pdf114514/copilot/disable-drag-drop-for-touch
Disable touch drag & drop, warn on car delete with reservations, support configurable backend URL
2026-03-21 03:21:50 +09:00
14 changed files with 1305 additions and 25 deletions

View File

@@ -12,7 +12,8 @@
"better-sqlite3": "^12.8.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-rate-limit": "^8.3.1"
"express-rate-limit": "^8.3.1",
"ws": "^8.20.0"
}
},
"node_modules/accepts": {
@@ -1309,6 +1310,27 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -16,6 +16,7 @@
"better-sqlite3": "^12.8.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-rate-limit": "^8.3.1"
"express-rate-limit": "^8.3.1",
"ws": "^8.20.0"
}
}

View File

@@ -3,13 +3,24 @@ const cors = require('cors');
const rateLimit = require('express-rate-limit');
const Database = require('better-sqlite3');
const path = require('path');
const http = require('http');
const { WebSocketServer } = require('ws');
const app = express();
const PORT = process.env.PORT || 3001;
const server = http.createServer(app);
// 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());
// Serve the built frontend (production mode)
const distPath = path.join(__dirname, '..', 'frontend', 'dist');
app.use(express.static(distPath));
// Apply rate limiting to all API routes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
@@ -42,6 +53,18 @@ db.exec(`
);
`);
// Migrate: add new car fields if they don't exist yet
const carCols = db.prepare("PRAGMA table_info(cars)").all().map((c) => c.name);
if (!carCols.includes('inspection_expiry')) {
db.exec("ALTER TABLE cars ADD COLUMN inspection_expiry TEXT DEFAULT ''");
}
if (!carCols.includes('has_etc')) {
db.exec('ALTER TABLE cars ADD COLUMN has_etc INTEGER DEFAULT 0');
}
if (!carCols.includes('tire_type')) {
db.exec("ALTER TABLE cars ADD COLUMN tire_type TEXT DEFAULT 'ノーマル'");
}
// Seed some initial cars if none exist
const carCount = db.prepare('SELECT COUNT(*) as cnt FROM cars').get();
if (carCount.cnt === 0) {
@@ -51,6 +74,22 @@ if (carCount.cnt === 0) {
insertCar.run('代車 C', '');
}
// --- WebSocket Server ---
const wss = new WebSocketServer({ server, path: '/api/ws' });
function broadcast(message) {
const data = JSON.stringify(message);
wss.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
client.send(data);
}
});
}
wss.on('connection', (ws) => {
ws.on('error', () => {}); // suppress unhandled error events
});
// --- Cars API ---
app.get('/api/cars', (req, res) => {
const cars = db.prepare('SELECT * FROM cars ORDER BY id').all();
@@ -58,25 +97,31 @@ app.get('/api/cars', (req, res) => {
});
app.post('/api/cars', (req, res) => {
const { name, description = '' } = req.body;
const { name, description = '', inspection_expiry = '', has_etc = 0, tire_type = 'ノーマル' } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: '車名は必須です' });
}
const result = db.prepare('INSERT INTO cars (name, description) VALUES (?, ?)').run(name.trim(), description);
const result = db.prepare(
'INSERT INTO cars (name, description, inspection_expiry, has_etc, tire_type) VALUES (?, ?, ?, ?, ?)'
).run(name.trim(), description, inspection_expiry, has_etc ? 1 : 0, tire_type);
const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(result.lastInsertRowid);
broadcast({ type: 'data_changed', entity: 'cars' });
res.status(201).json(car);
});
app.put('/api/cars/:id', (req, res) => {
const { name, description } = req.body;
const { name, description, inspection_expiry, has_etc, tire_type } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: '車名は必須です' });
}
const result = db.prepare('UPDATE cars SET name = ?, description = ? WHERE id = ?').run(name.trim(), description ?? '', req.params.id);
const result = db.prepare(
'UPDATE cars SET name = ?, description = ?, inspection_expiry = ?, has_etc = ?, tire_type = ? WHERE id = ?'
).run(name.trim(), description ?? '', inspection_expiry ?? '', has_etc ? 1 : 0, tire_type ?? 'ノーマル', req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: '車が見つかりません' });
}
const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id);
broadcast({ type: 'data_changed', entity: 'cars' });
res.json(car);
});
@@ -85,6 +130,7 @@ app.delete('/api/cars/:id', (req, res) => {
if (result.changes === 0) {
return res.status(404).json({ error: '車が見つかりません' });
}
broadcast({ type: 'data_changed', entity: 'all' });
res.json({ success: true });
});
@@ -106,6 +152,7 @@ app.post('/api/reservations', (req, res) => {
'INSERT INTO reservations (car_id, start_date, end_date, customer_name, notes) VALUES (?, ?, ?, ?, ?)'
).run(car_id, start_date, end_date, customer_name, notes);
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid);
broadcast({ type: 'data_changed', entity: 'reservations' });
res.status(201).json(reservation);
});
@@ -124,6 +171,7 @@ app.put('/api/reservations/:id', (req, res) => {
return res.status(404).json({ error: '予約が見つかりません' });
}
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(req.params.id);
broadcast({ type: 'data_changed', entity: 'reservations' });
res.json(reservation);
});
@@ -132,9 +180,15 @@ app.delete('/api/reservations/:id', (req, res) => {
if (result.changes === 0) {
return res.status(404).json({ error: '予約が見つかりません' });
}
broadcast({ type: 'data_changed', entity: 'reservations' });
res.json({ success: true });
});
app.listen(PORT, () => {
// SPA fallback: serve index.html for any non-API route
app.get('/*path', apiLimiter, (req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

View File

@@ -1,15 +1,36 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import ScheduleView from './components/ScheduleView.jsx';
import CarManagement from './components/CarManagement.jsx';
import TimelineView from './components/TimelineView.jsx';
import useWebSocket from './hooks/useWebSocket.js';
import styles from './App.module.css';
export default function App() {
const [page, setPage] = useState('schedule');
const [reloadKey, setReloadKey] = useState(0);
const handleWsMessage = useCallback((msg) => {
if (msg.type === 'data_changed') {
setReloadKey((k) => k + 1);
}
}, []);
const { status: wsStatus } = useWebSocket(handleWsMessage);
return (
<div className={styles.app}>
<header className={styles.header}>
<h1 className={styles.title}>🚗 代車スケジュール管理</h1>
<div className={styles.headerLeft}>
<span
className={`${styles.wsIndicator} ${styles['wsIndicator_' + wsStatus]}`}
title={
wsStatus === 'connected' ? 'リアルタイム同期: 接続中' :
wsStatus === 'connecting' || wsStatus === 'disconnected' ? 'リアルタイム同期: 再接続中...' :
'リアルタイム同期: 接続失敗'
}
/>
<h1 className={styles.title}>🚗 代車スケジュール管理</h1>
</div>
<nav className={styles.nav}>
<button
className={`${styles.navBtn} ${page === 'schedule' ? styles.active : ''}`}
@@ -17,6 +38,12 @@ export default function App() {
>
📅 スケジュール
</button>
<button
className={`${styles.navBtn} ${page === 'timeline' ? styles.active : ''}`}
onClick={() => setPage('timeline')}
>
📊 タイムライン
</button>
<button
className={`${styles.navBtn} ${page === 'cars' ? styles.active : ''}`}
onClick={() => setPage('cars')}
@@ -25,8 +52,17 @@ export default function App() {
</button>
</nav>
</header>
{wsStatus === 'error' && (
<div className={styles.wsError}>
サーバーとの接続が切断されましたページを再読み込みしてください
</div>
)}
<main className={styles.main}>
{page === 'schedule' ? <ScheduleView /> : <CarManagement />}
{page === 'schedule' && <ScheduleView reloadKey={reloadKey} />}
{page === 'timeline' && <TimelineView reloadKey={reloadKey} />}
{page === 'cars' && <CarManagement reloadKey={reloadKey} />}
</main>
</div>
);

View File

@@ -18,12 +18,59 @@
z-index: 100;
}
.headerLeft {
display: flex;
align-items: center;
gap: 10px;
}
.title {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.5px;
}
/* WebSocket connection indicator dot */
.wsIndicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s;
}
.wsIndicator_connected {
background: #4ade80; /* green */
box-shadow: 0 0 6px #4ade80;
}
.wsIndicator_connecting,
.wsIndicator_disconnected {
background: #fbbf24; /* amber */
animation: pulse 1s infinite;
}
.wsIndicator_error {
background: #f87171; /* red */
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Disconnection warning banner */
.wsError {
background: #fef3c7;
border-bottom: 2px solid #f59e0b;
color: #92400e;
padding: 8px 24px;
font-size: 14px;
font-weight: 500;
text-align: center;
}
.nav {
display: flex;
gap: 8px;
@@ -54,3 +101,4 @@
flex: 1;
overflow: hidden;
}

View File

@@ -1,20 +1,27 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { api } from '../api.js';
import { isInspectionExpirySoon } from '../utils/carUtils.js';
import styles from './CarManagement.module.css';
export default function CarManagement() {
export default function CarManagement({ reloadKey = 0 }) {
const [cars, setCars] = useState([]);
const [reservations, setReservations] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [newCarName, setNewCarName] = useState('');
const [newCarDesc, setNewCarDesc] = useState('');
const [newCarExpiry, setNewCarExpiry] = useState('');
const [newCarEtc, setNewCarEtc] = useState(false);
const [newCarTire, setNewCarTire] = useState('ノーマル');
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editExpiry, setEditExpiry] = useState('');
const [editEtc, setEditEtc] = useState(false);
const [editTire, setEditTire] = useState('ノーマル');
const [submitting, setSubmitting] = useState(false);
const loadCars = async () => {
const loadCars = useCallback(async () => {
try {
setLoading(true);
const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]);
@@ -26,20 +33,29 @@ export default function CarManagement() {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
loadCars();
}, []);
}, [loadCars, reloadKey]);
const handleAdd = async (e) => {
e.preventDefault();
if (!newCarName.trim()) return;
try {
setSubmitting(true);
await api.createCar({ name: newCarName.trim(), description: newCarDesc.trim() });
await api.createCar({
name: newCarName.trim(),
description: newCarDesc.trim(),
inspection_expiry: newCarExpiry,
has_etc: newCarEtc,
tire_type: newCarTire,
});
setNewCarName('');
setNewCarDesc('');
setNewCarExpiry('');
setNewCarEtc(false);
setNewCarTire('ノーマル');
await loadCars();
} catch (e) {
setError(e.message);
@@ -66,19 +82,31 @@ export default function CarManagement() {
setEditingId(car.id);
setEditName(car.name);
setEditDesc(car.description || '');
setEditExpiry(car.inspection_expiry || '');
setEditEtc(!!car.has_etc);
setEditTire(car.tire_type || 'ノーマル');
};
const cancelEdit = () => {
setEditingId(null);
setEditName('');
setEditDesc('');
setEditExpiry('');
setEditEtc(false);
setEditTire('ノーマル');
};
const handleUpdate = async (id) => {
if (!editName.trim()) return;
try {
setSubmitting(true);
await api.updateCar(id, { name: editName.trim(), description: editDesc.trim() });
await api.updateCar(id, {
name: editName.trim(),
description: editDesc.trim(),
inspection_expiry: editExpiry,
has_etc: editEtc,
tire_type: editTire,
});
cancelEdit();
await loadCars();
} catch (e) {
@@ -111,6 +139,39 @@ export default function CarManagement() {
value={newCarDesc}
onChange={(e) => setNewCarDesc(e.target.value)}
/>
</div>
<div className={styles.formRow}>
<label className={styles.fieldLabel}>
車検満了日
<input
type="date"
className={styles.input}
value={newCarExpiry}
onChange={(e) => setNewCarExpiry(e.target.value)}
/>
</label>
<label className={styles.fieldLabel}>
ETC
<select
className={styles.input}
value={newCarEtc ? 'あり' : 'なし'}
onChange={(e) => setNewCarEtc(e.target.value === 'あり')}
>
<option value="なし">なし</option>
<option value="あり">あり</option>
</select>
</label>
<label className={styles.fieldLabel}>
タイヤ
<select
className={styles.input}
value={newCarTire}
onChange={(e) => setNewCarTire(e.target.value)}
>
<option value="ノーマル">ノーマル</option>
<option value="スタットレス">スタットレス</option>
</select>
</label>
<button type="submit" className={styles.btnPrimary} disabled={submitting || !newCarName.trim()}>
+ 追加
</button>
@@ -129,13 +190,16 @@ export default function CarManagement() {
<th>ID</th>
<th>車名</th>
<th>備考</th>
<th>車検満了日</th>
<th>ETC</th>
<th>タイヤ</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{cars.length === 0 && (
<tr>
<td colSpan={4} className={styles.empty}>代車がありません</td>
<td colSpan={7} className={styles.empty}>代車がありません</td>
</tr>
)}
{cars.map((car) => (
@@ -159,6 +223,34 @@ export default function CarManagement() {
onChange={(e) => setEditDesc(e.target.value)}
/>
</td>
<td>
<input
type="date"
className={styles.input}
value={editExpiry}
onChange={(e) => setEditExpiry(e.target.value)}
/>
</td>
<td>
<select
className={styles.input}
value={editEtc ? 'あり' : 'なし'}
onChange={(e) => setEditEtc(e.target.value === 'あり')}
>
<option value="なし">なし</option>
<option value="あり">あり</option>
</select>
</td>
<td>
<select
className={styles.input}
value={editTire}
onChange={(e) => setEditTire(e.target.value)}
>
<option value="ノーマル">ノーマル</option>
<option value="スタットレス">スタットレス</option>
</select>
</td>
<td className={styles.actions}>
<button
className={styles.btnSave}
@@ -176,6 +268,15 @@ export default function CarManagement() {
<>
<td>{car.name}</td>
<td className={styles.descCell}>{car.description || '-'}</td>
<td className={styles.descCell}>
{car.inspection_expiry
? isInspectionExpirySoon(car.inspection_expiry)
? <span className={styles.expiryWarn}> {car.inspection_expiry}</span>
: car.inspection_expiry
: '-'}
</td>
<td>{car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : 'なし'}</td>
<td>{car.tire_type === 'スタットレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : 'ノーマル'}</td>
<td className={styles.actions}>
<button className={styles.btnEdit} onClick={() => startEdit(car)}>
編集

View File

@@ -79,7 +79,7 @@
background: white;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
overflow: hidden;
overflow-x: auto;
}
.table {
@@ -199,3 +199,44 @@
color: #9ca3af;
padding: 32px;
}
.fieldLabel {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #6b7280;
font-weight: 500;
}
.fieldLabel .input {
min-width: 130px;
}
.expiryWarn {
color: #b45309;
font-weight: 600;
}
.badgeEtc {
display: inline-block;
background: #7c3aed;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
letter-spacing: 0.04em;
}
.badgeStudless {
display: inline-block;
background: #0ea5e9;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
letter-spacing: 0.03em;
}

View File

@@ -2,11 +2,12 @@ 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 { isInspectionExpirySoon } from '../utils/carUtils.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 ROW_HEIGHT = 64; // 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
@@ -35,7 +36,7 @@ function dateToStr(date) {
return format(date, 'yyyy-MM-dd');
}
export default function ScheduleView() {
export default function ScheduleView({ reloadKey = 0 }) {
const [cars, setCars] = useState([]);
const [reservations, setReservations] = useState([]);
const [loading, setLoading] = useState(true);
@@ -58,6 +59,10 @@ export default function ScheduleView() {
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
@@ -81,7 +86,7 @@ export default function ScheduleView() {
useEffect(() => {
loadData();
}, [loadData]);
}, [loadData, reloadKey]);
// --- Navigation ---
const prevWeek = () => setViewStart((d) => addDays(d, -7));
@@ -238,6 +243,19 @@ export default function ScheduleView() {
};
}, [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();
@@ -425,7 +443,16 @@ export default function ScheduleView() {
title={car.description || car.name}
>
<span className={styles.carDot} style={{ background: color.border }} />
<span className={styles.carName}>{car.name}</span>
<span className={styles.carLabelContent}>
<span className={styles.carName}>{car.name}</span>
<span className={styles.carBadges}>
{car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : null}
{car.tire_type === 'スタッドレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : null}
{isInspectionExpirySoon(car.inspection_expiry) ? (
<span className={styles.badgeWarn} title={`車検満了日: ${car.inspection_expiry}(まもなく期限切れ)`}>車検</span>
) : null}
</span>
</span>
</div>
{/* Day cells */}
@@ -508,6 +535,11 @@ export default function ScheduleView() {
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}>
@@ -544,6 +576,34 @@ export default function ScheduleView() {
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>
);
}

View File

@@ -175,6 +175,14 @@
flex-shrink: 0;
}
.carLabelContent {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.carName {
font-size: 13px;
font-weight: 600;
@@ -184,6 +192,46 @@
text-overflow: ellipsis;
}
.carBadges {
display: flex;
gap: 4px;
flex-wrap: nowrap;
}
.badgeEtc {
background: #7c3aed;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
letter-spacing: 0.03em;
}
.badgeStudless {
background: #0ea5e9;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
letter-spacing: 0.03em;
}
.badgeWarn {
background: #d97706;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
letter-spacing: 0.03em;
cursor: default;
}
/* Cell area */
.cellArea {
display: flex;
@@ -268,3 +316,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;
}

View 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({ reloadKey = 0 }) {
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, reloadKey]);
// 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>
);
}

View 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;
}

View File

@@ -0,0 +1,103 @@
import { useEffect, useRef, useState, useCallback } from 'react';
const WS_BASE = (() => {
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
const loc = window.location;
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
// Use the same origin as the page; in development the Vite dev server
// proxies /api/ws to the backend, and in production nginx does the same
// via the existing location /api/ block (no extra nginx config needed).
return `${proto}//${loc.host}/api/ws`;
})();
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000, 30000]; // ms
/**
* Maintains a WebSocket connection with automatic reconnection.
*
* @param {(message: object) => void} onMessage - Called for each parsed JSON message.
* @returns {{ status: 'connecting'|'connected'|'disconnected'|'error' }}
*/
export default function useWebSocket(onMessage) {
const [status, setStatus] = useState('connecting');
const wsRef = useRef(null);
const retryCountRef = useRef(0);
const unmountedRef = useRef(false);
const retryTimerRef = useRef(null);
const onMessageRef = useRef(onMessage);
// Keep onMessage ref up-to-date without re-running the effect
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
const connect = useCallback(() => {
if (unmountedRef.current) return;
setStatus('connecting');
const ws = new WebSocket(WS_BASE);
wsRef.current = ws;
ws.onopen = () => {
if (unmountedRef.current) { ws.close(); return; }
retryCountRef.current = 0;
setStatus('connected');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
onMessageRef.current(msg);
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
if (unmountedRef.current) return;
const delay = RECONNECT_DELAYS[Math.min(retryCountRef.current, RECONNECT_DELAYS.length - 1)];
retryCountRef.current += 1;
// After exhausting all backoff levels, keep status as 'error'
if (retryCountRef.current > RECONNECT_DELAYS.length) {
setStatus('error');
} else {
setStatus('disconnected');
}
retryTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
// onclose will fire right after, which handles reconnect
};
}, []);
useEffect(() => {
// Reset on each mount so that React StrictMode's simulated unmount/remount
// does not leave unmountedRef permanently true and block reconnection.
unmountedRef.current = false;
connect();
return () => {
unmountedRef.current = true;
clearTimeout(retryTimerRef.current);
const ws = wsRef.current;
if (ws) {
// Null out all handlers first to prevent any reconnect attempts.
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
if (ws.readyState === WebSocket.CONNECTING) {
// Closing a CONNECTING socket triggers the browser warning
// "WebSocket is closed before the connection is established".
// Instead, schedule a close as soon as it opens so the server
// isn't left with a permanently idle connection.
ws.onopen = () => ws.close();
} else if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
};
}, [connect]);
return { status };
}

View File

@@ -0,0 +1,13 @@
/**
* Returns true if the given inspection expiry date string is within 1 month
* from today (or already past).
* @param {string} inspectionExpiry - ISO date string (YYYY-MM-DD) or empty
* @returns {boolean}
*/
export function isInspectionExpirySoon(inspectionExpiry) {
if (!inspectionExpiry) return false;
const expiry = new Date(inspectionExpiry);
const oneMonthLater = new Date();
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
return expiry <= oneMonthLater;
}

View File

@@ -1,13 +1,57 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import net from 'net';
const backendOrigin = process.env.BACKEND_URL || 'http://localhost:3001';
/**
* Custom Vite plugin that tunnels WebSocket upgrade requests at /api/ws to the
* backend via raw TCP. Vite's built-in proxy `ws: true` can silently drop
* upgrade events that Vite's own HMR handler intercepts first. This plugin
* hooks directly onto `httpServer.upgrade` and handles the /api/ws path before
* Vite gets a chance to claim it.
*/
function wsProxyPlugin() {
return {
name: 'ws-proxy',
configureServer(server) {
server.httpServer?.on('upgrade', (req, socket, head) => {
if (req.url !== '/api/ws') return;
const { hostname, port: rawPort } = new URL(backendOrigin);
const port = parseInt(rawPort) || 3001;
const conn = net.createConnection({ host: hostname, port });
conn.on('error', () => socket.destroy());
socket.on('error', () => conn.destroy());
conn.on('connect', () => {
// Replay the original HTTP upgrade request to the backend
const headers =
`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` +
Object.entries(req.headers)
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n') +
'\r\n\r\n';
conn.write(headers);
if (head && head.length) conn.write(head);
// Bidirectional pipe
conn.pipe(socket).pipe(conn);
});
});
},
};
}
export default defineConfig({
plugins: [react()],
plugins: [react(), wsProxyPlugin()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.BACKEND_URL || 'http://localhost:3001',
target: backendOrigin,
changeOrigin: true,
},
},