Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/c5f8f1a2-8a8b-4951-8442-76ce37d906ae Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
370 lines
13 KiB
JavaScript
370 lines
13 KiB
JavaScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { api } from '../api.js';
|
||
import { isInspectionExpirySoon } from '../utils/carUtils.js';
|
||
import styles from './CarManagement.module.css';
|
||
|
||
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 [dragOverIdx, setDragOverIdx] = useState(null);
|
||
const dragSrcIdx = useRef(null);
|
||
|
||
const loadCars = 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(() => {
|
||
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(),
|
||
inspection_expiry: newCarExpiry,
|
||
has_etc: newCarEtc,
|
||
tire_type: newCarTire,
|
||
});
|
||
setNewCarName('');
|
||
setNewCarDesc('');
|
||
setNewCarExpiry('');
|
||
setNewCarEtc(false);
|
||
setNewCarTire('ノーマル');
|
||
await loadCars();
|
||
} catch (e) {
|
||
setError(e.message);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id, name) => {
|
||
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 {
|
||
await api.deleteCar(id);
|
||
await loadCars();
|
||
} catch (e) {
|
||
setError(e.message);
|
||
}
|
||
};
|
||
|
||
const startEdit = (car) => {
|
||
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(),
|
||
inspection_expiry: editExpiry,
|
||
has_etc: editEtc,
|
||
tire_type: editTire,
|
||
});
|
||
cancelEdit();
|
||
await loadCars();
|
||
} catch (e) {
|
||
setError(e.message);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const applyReorder = async (newCars) => {
|
||
setCars(newCars);
|
||
try {
|
||
await api.reorderCars(newCars.map((c) => c.id));
|
||
} catch (e) {
|
||
setError(e.message);
|
||
await loadCars();
|
||
}
|
||
};
|
||
|
||
const handleReorder = async (index, direction) => {
|
||
const swapIndex = index + direction;
|
||
if (swapIndex < 0 || swapIndex >= cars.length) return;
|
||
const newCars = [...cars];
|
||
[newCars[index], newCars[swapIndex]] = [newCars[swapIndex], newCars[index]];
|
||
await applyReorder(newCars);
|
||
};
|
||
|
||
const handleDragStart = (index) => {
|
||
dragSrcIdx.current = index;
|
||
};
|
||
|
||
const handleDragOver = (e, index) => {
|
||
e.preventDefault();
|
||
setDragOverIdx(index);
|
||
};
|
||
|
||
const handleDragEnd = () => {
|
||
setDragOverIdx(null);
|
||
dragSrcIdx.current = null;
|
||
};
|
||
|
||
const handleDrop = async (e, dropIndex) => {
|
||
e.preventDefault();
|
||
const srcIndex = dragSrcIdx.current;
|
||
setDragOverIdx(null);
|
||
dragSrcIdx.current = null;
|
||
if (srcIndex === null || srcIndex === dropIndex) return;
|
||
const newCars = [...cars];
|
||
const [moved] = newCars.splice(srcIndex, 1);
|
||
newCars.splice(dropIndex, 0, moved);
|
||
await applyReorder(newCars);
|
||
};
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<h2 className={styles.heading}>代車管理</h2>
|
||
|
||
<div className={styles.addCard}>
|
||
<h3 className={styles.subHeading}>代車を追加</h3>
|
||
<form className={styles.form} onSubmit={handleAdd}>
|
||
<div className={styles.formRow}>
|
||
<input
|
||
type="text"
|
||
className={styles.input}
|
||
placeholder="車名(例:プリウス A)"
|
||
value={newCarName}
|
||
onChange={(e) => setNewCarName(e.target.value)}
|
||
required
|
||
/>
|
||
<input
|
||
type="text"
|
||
className={styles.input}
|
||
placeholder="備考(任意)"
|
||
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>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
{loading && <p className={styles.message}>読み込み中...</p>}
|
||
{error && <p className={styles.error}>エラー: {error}</p>}
|
||
|
||
{!loading && !error && (
|
||
<div className={styles.tableWrapper}>
|
||
<table className={styles.table}>
|
||
<thead>
|
||
<tr>
|
||
<th>順番</th>
|
||
<th>車名</th>
|
||
<th>備考</th>
|
||
<th>車検満了日</th>
|
||
<th>ETC</th>
|
||
<th>タイヤ</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{cars.length === 0 && (
|
||
<tr>
|
||
<td colSpan={7} className={styles.empty}>代車がありません</td>
|
||
</tr>
|
||
)}
|
||
{cars.map((car, carIdx) => (
|
||
<tr
|
||
key={car.id}
|
||
draggable
|
||
onDragStart={() => handleDragStart(carIdx)}
|
||
onDragOver={(e) => handleDragOver(e, carIdx)}
|
||
onDragEnd={handleDragEnd}
|
||
onDrop={(e) => handleDrop(e, carIdx)}
|
||
className={dragOverIdx === carIdx ? styles.dragOver : ''}
|
||
>
|
||
<td className={styles.idCell}>
|
||
<div className={styles.orderBtns}>
|
||
<button
|
||
className={styles.btnOrder}
|
||
onClick={() => handleReorder(carIdx, -1)}
|
||
disabled={carIdx === 0}
|
||
title="上に移動"
|
||
aria-label="上に移動"
|
||
>▲</button>
|
||
<button
|
||
className={styles.btnOrder}
|
||
onClick={() => handleReorder(carIdx, 1)}
|
||
disabled={carIdx === cars.length - 1}
|
||
title="下に移動"
|
||
aria-label="下に移動"
|
||
>▼</button>
|
||
</div>
|
||
</td>
|
||
{editingId === car.id ? (
|
||
<>
|
||
<td>
|
||
<input
|
||
type="text"
|
||
className={styles.input}
|
||
value={editName}
|
||
onChange={(e) => setEditName(e.target.value)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="text"
|
||
className={styles.input}
|
||
value={editDesc}
|
||
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}
|
||
onClick={() => handleUpdate(car.id)}
|
||
disabled={submitting}
|
||
>
|
||
保存
|
||
</button>
|
||
<button className={styles.btnCancel} onClick={cancelEdit}>
|
||
キャンセル
|
||
</button>
|
||
</td>
|
||
</>
|
||
) : (
|
||
<>
|
||
<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)}>
|
||
編集
|
||
</button>
|
||
<button className={styles.btnDelete} onClick={() => handleDelete(car.id, car.name)}>
|
||
削除
|
||
</button>
|
||
</td>
|
||
</>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|