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
This commit is contained in:
@@ -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 (
|
||||
<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 : ''}`}
|
||||
@@ -32,10 +52,17 @@ export default function App() {
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{wsStatus === 'error' && (
|
||||
<div className={styles.wsError}>
|
||||
⚠️ サーバーとの接続が切断されました。ページを再読み込みしてください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className={styles.main}>
|
||||
{page === 'schedule' && <ScheduleView />}
|
||||
{page === 'timeline' && <TimelineView />}
|
||||
{page === 'cars' && <CarManagement />}
|
||||
{page === 'schedule' && <ScheduleView reloadKey={reloadKey} />}
|
||||
{page === 'timeline' && <TimelineView reloadKey={reloadKey} />}
|
||||
{page === 'cars' && <CarManagement reloadKey={reloadKey} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? '🛣️ あり' : 'なし'}</td>
|
||||
<td>{car.tire_type === 'スタットレス' ? '❄️ スタットレス' : 'ノーマル'}</td>
|
||||
<td className={styles.actions}>
|
||||
<button className={styles.btnEdit} onClick={() => startEdit(car)}>
|
||||
編集
|
||||
|
||||
@@ -199,3 +199,22 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
@@ -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);
|
||||
@@ -85,7 +86,7 @@ export default function ScheduleView() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
}, [loadData, reloadKey]);
|
||||
|
||||
// --- Navigation ---
|
||||
const prevWeek = () => setViewStart((d) => addDays(d, -7));
|
||||
@@ -443,6 +444,13 @@ export default function ScheduleView() {
|
||||
>
|
||||
<span className={styles.carDot} style={{ background: color.border }} />
|
||||
<span className={styles.carName}>{car.name}</span>
|
||||
<span className={styles.carIcons}>
|
||||
{car.has_etc ? <span title="ETC あり">🛣️</span> : null}
|
||||
{car.tire_type === 'スタットレス' ? <span title="スタットレスタイヤ">❄️</span> : null}
|
||||
{isInspectionExpirySoon(car.inspection_expiry) ? (
|
||||
<span title={`車検満了日: ${car.inspection_expiry}(まもなく期限切れ)`}>⚠️</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
|
||||
@@ -182,6 +182,15 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.carIcons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Cell area */
|
||||
|
||||
@@ -31,7 +31,7 @@ function dateToStr(date) {
|
||||
return format(date, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
export default function TimelineView() {
|
||||
export default function TimelineView({ reloadKey = 0 }) {
|
||||
const [cars, setCars] = useState([]);
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -75,7 +75,7 @@ export default function TimelineView() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
}, [loadData, reloadKey]);
|
||||
|
||||
// Close context menu on click / Escape
|
||||
useEffect(() => {
|
||||
|
||||
83
frontend/src/hooks/useWebSocket.js
Normal file
83
frontend/src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,83 @@
|
||||
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 /ws to the backend, and in production nginx does the same.
|
||||
return `${proto}//${loc.host}/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(() => {
|
||||
connect();
|
||||
return () => {
|
||||
unmountedRef.current = true;
|
||||
clearTimeout(retryTimerRef.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { status };
|
||||
}
|
||||
13
frontend/src/utils/carUtils.js
Normal file
13
frontend/src/utils/carUtils.js
Normal 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;
|
||||
}
|
||||
@@ -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 /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 /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 !== '/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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user