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:
copilot-swe-agent[bot]
2026-03-22 03:58:07 +00:00
parent 09872737b7
commit 8e9db971d3
13 changed files with 451 additions and 26 deletions

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 ? '🛣️ あり' : 'なし'}</td>
<td>{car.tire_type === 'スタットレス' ? '❄️ スタットレス' : 'ノーマル'}</td>
<td className={styles.actions}>
<button className={styles.btnEdit} onClick={() => startEdit(car)}>
編集

View File

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

View File

@@ -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 */}

View File

@@ -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 */

View File

@@ -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(() => {