diff --git a/backend/package-lock.json b/backend/package-lock.json
index 3ec556e..ebff5fb 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -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
+ }
+ }
}
}
}
diff --git a/backend/package.json b/backend/package.json
index 0a93c08..189abef 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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"
}
}
diff --git a/backend/server.js b/backend/server.js
index e5e71af..392e090 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -3,9 +3,12 @@ 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.
@@ -14,6 +17,10 @@ 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
@@ -46,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) {
@@ -55,6 +74,22 @@ if (carCount.cnt === 0) {
insertCar.run('代車 C', '');
}
+// --- WebSocket Server ---
+const wss = new WebSocketServer({ server, path: '/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();
@@ -62,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);
});
@@ -89,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 });
});
@@ -110,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);
});
@@ -128,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);
});
@@ -136,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}`);
});
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8b729f3..76bf91c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,16 +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 (
- 🚗 代車スケジュール管理
+
+
+
🚗 代車スケジュール管理
+
+
+ {wsStatus === 'error' && (
+
+ ⚠️ サーバーとの接続が切断されました。ページを再読み込みしてください。
+
+ )}
+
- {page === 'schedule' && }
- {page === 'timeline' && }
- {page === 'cars' && }
+ {page === 'schedule' && }
+ {page === 'timeline' && }
+ {page === 'cars' && }
);
diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css
index 377a891..bfaba80 100644
--- a/frontend/src/App.module.css
+++ b/frontend/src/App.module.css
@@ -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;
}
+
diff --git a/frontend/src/components/CarManagement.jsx b/frontend/src/components/CarManagement.jsx
index 76f593f..96db33c 100644
--- a/frontend/src/components/CarManagement.jsx
+++ b/frontend/src/components/CarManagement.jsx
@@ -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)}
/>
+
+
+
+
+
@@ -129,13 +190,16 @@ export default function CarManagement() {
ID |
車名 |
備考 |
+ 車検満了日 |
+ ETC |
+ タイヤ |
操作 |
{cars.length === 0 && (
- | 代車がありません |
+ 代車がありません |
)}
{cars.map((car) => (
@@ -159,6 +223,34 @@ export default function CarManagement() {
onChange={(e) => setEditDesc(e.target.value)}
/>
+
+ setEditExpiry(e.target.value)}
+ />
+ |
+
+
+ |
+
+
+ |
|