diff --git a/backend/server.js b/backend/server.js index 1327d36..c984672 100644 --- a/backend/server.js +++ b/backend/server.js @@ -64,15 +64,28 @@ if (!carCols.includes('has_etc')) { if (!carCols.includes('tire_type')) { db.exec("ALTER TABLE cars ADD COLUMN tire_type TEXT DEFAULT 'ノーマル'"); } +if (!carCols.includes('sort_order')) { + db.exec('ALTER TABLE cars ADD COLUMN sort_order INTEGER DEFAULT 0'); + db.exec('UPDATE cars SET sort_order = id'); +} db.prepare("UPDATE cars SET tire_type = 'スタッドレス' WHERE tire_type = 'スタットレス'").run(); +// Migrate: add period fields to reservations if they don't exist yet +const resCols = db.prepare("PRAGMA table_info(reservations)").all().map((c) => c.name); +if (!resCols.includes('start_period')) { + db.exec("ALTER TABLE reservations ADD COLUMN start_period TEXT DEFAULT ''"); +} +if (!resCols.includes('end_period')) { + db.exec("ALTER TABLE reservations ADD COLUMN end_period TEXT DEFAULT ''"); +} + // Seed some initial cars if none exist const carCount = db.prepare('SELECT COUNT(*) as cnt FROM cars').get(); if (carCount.cnt === 0) { - const insertCar = db.prepare('INSERT INTO cars (name, description) VALUES (?, ?)'); - insertCar.run('代車 A', ''); - insertCar.run('代車 B', ''); - insertCar.run('代車 C', ''); + const insertCar = db.prepare('INSERT INTO cars (name, description, sort_order) VALUES (?, ?, ?)'); + insertCar.run('代車 A', '', 1); + insertCar.run('代車 B', '', 2); + insertCar.run('代車 C', '', 3); } // --- WebSocket Server --- @@ -107,7 +120,7 @@ wss.on('connection', (ws) => { // --- Cars API --- app.get('/api/cars', (req, res) => { - const cars = db.prepare('SELECT * FROM cars ORDER BY id').all().map(normalizeCar); + const cars = db.prepare('SELECT * FROM cars ORDER BY sort_order, id').all().map(normalizeCar); res.json(cars); }); @@ -116,14 +129,31 @@ app.post('/api/cars', (req, res) => { if (!name || !name.trim()) { return res.status(400).json({ error: '車名は必須です' }); } + const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM cars').get().m ?? 0; 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); + 'INSERT INTO cars (name, description, inspection_expiry, has_etc, tire_type, sort_order) VALUES (?, ?, ?, ?, ?, ?)' + ).run(name.trim(), description, inspection_expiry, has_etc ? 1 : 0, tire_type, maxOrder + 1); const car = normalizeCar(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/reorder', (req, res) => { + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: 'ids は配列で指定してください' }); + } + const updateOrder = db.prepare('UPDATE cars SET sort_order = ? WHERE id = ?'); + const transaction = db.transaction((orderedIds) => { + orderedIds.forEach((id, index) => { + updateOrder.run(index + 1, id); + }); + }); + transaction(ids); + broadcast({ type: 'data_changed', entity: 'cars' }); + res.json({ success: true }); +}); + app.put('/api/cars/:id', (req, res) => { const { name, description, inspection_expiry, has_etc, tire_type } = req.body; if (!name || !name.trim()) { @@ -156,7 +186,7 @@ app.get('/api/reservations', (req, res) => { }); app.post('/api/reservations', (req, res) => { - const { car_id, start_date, end_date, customer_name = '', notes = '' } = req.body; + const { car_id, start_date, end_date, customer_name = '', notes = '', start_period = '', end_period = '' } = req.body; if (!car_id || !start_date || !end_date) { return res.status(400).json({ error: 'car_id, start_date, end_date は必須です' }); } @@ -164,15 +194,15 @@ app.post('/api/reservations', (req, res) => { return res.status(400).json({ error: '開始日は終了日以前である必要があります' }); } const result = db.prepare( - 'INSERT INTO reservations (car_id, start_date, end_date, customer_name, notes) VALUES (?, ?, ?, ?, ?)' - ).run(car_id, start_date, end_date, customer_name, notes); + 'INSERT INTO reservations (car_id, start_date, end_date, customer_name, notes, start_period, end_period) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(car_id, start_date, end_date, customer_name, notes, start_period, end_period); const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid); broadcast({ type: 'data_changed', entity: 'reservations' }); res.status(201).json(reservation); }); app.put('/api/reservations/:id', (req, res) => { - const { car_id, start_date, end_date, customer_name, notes } = req.body; + const { car_id, start_date, end_date, customer_name, notes, start_period, end_period } = req.body; if (!car_id || !start_date || !end_date) { return res.status(400).json({ error: 'car_id, start_date, end_date は必須です' }); } @@ -180,8 +210,8 @@ app.put('/api/reservations/:id', (req, res) => { return res.status(400).json({ error: '開始日は終了日以前である必要があります' }); } const result = db.prepare( - 'UPDATE reservations SET car_id = ?, start_date = ?, end_date = ?, customer_name = ?, notes = ? WHERE id = ?' - ).run(car_id, start_date, end_date, customer_name ?? '', notes ?? '', req.params.id); + 'UPDATE reservations SET car_id = ?, start_date = ?, end_date = ?, customer_name = ?, notes = ?, start_period = ?, end_period = ? WHERE id = ?' + ).run(car_id, start_date, end_date, customer_name ?? '', notes ?? '', start_period ?? '', end_period ?? '', req.params.id); if (result.changes === 0) { return res.status(404).json({ error: '予約が見つかりません' }); } diff --git a/frontend/src/api.js b/frontend/src/api.js index 4ceadf7..b7fab86 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -23,6 +23,7 @@ export const api = { createCar: (data) => request('/cars', { method: 'POST', body: JSON.stringify(data) }), updateCar: (id, data) => request(`/cars/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteCar: (id) => request(`/cars/${id}`, { method: 'DELETE' }), + reorderCars: (ids) => request('/cars/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }), // Reservations getReservations: () => request('/reservations'), diff --git a/frontend/src/components/CarManagement.jsx b/frontend/src/components/CarManagement.jsx index a4ba5ee..977883a 100644 --- a/frontend/src/components/CarManagement.jsx +++ b/frontend/src/components/CarManagement.jsx @@ -116,6 +116,20 @@ export default function CarManagement({ reloadKey = 0 }) { } }; + const handleReorder = async (index, direction) => { + const newCars = [...cars]; + const swapIndex = index + direction; + if (swapIndex < 0 || swapIndex >= newCars.length) return; + [newCars[index], newCars[swapIndex]] = [newCars[swapIndex], newCars[index]]; + setCars(newCars); + try { + await api.reorderCars(newCars.map((c) => c.id)); + } catch (e) { + setError(e.message); + await loadCars(); + } + }; + return (

代車管理

@@ -187,7 +201,7 @@ export default function CarManagement({ reloadKey = 0 }) { - + @@ -202,9 +216,26 @@ export default function CarManagement({ reloadKey = 0 }) { )} - {cars.map((car) => ( + {cars.map((car, carIdx) => ( - + {editingId === car.id ? ( <>
ID順番 車名 備考 車検満了日代車がありません
{car.id} +
+ + +
+
diff --git a/frontend/src/components/CarManagement.module.css b/frontend/src/components/CarManagement.module.css index 849113c..050d1ec 100644 --- a/frontend/src/components/CarManagement.module.css +++ b/frontend/src/components/CarManagement.module.css @@ -113,7 +113,34 @@ .idCell { color: #9ca3af; - width: 50px; + width: 80px; +} + +.orderBtns { + display: flex; + flex-direction: column; + gap: 2px; + align-items: center; +} + +.btnOrder { + background: #f3f4f6; + border: 1px solid #d1d5db; + color: #374151; + padding: 1px 8px; + border-radius: 4px; + font-size: 10px; + line-height: 1.4; + transition: background 0.15s; +} + +.btnOrder:hover:not(:disabled) { + background: #e5e7eb; +} + +.btnOrder:disabled { + opacity: 0.3; + cursor: not-allowed; } .descCell { diff --git a/frontend/src/components/ReservationModal.jsx b/frontend/src/components/ReservationModal.jsx index eeef0a1..ba04e7a 100644 --- a/frontend/src/components/ReservationModal.jsx +++ b/frontend/src/components/ReservationModal.jsx @@ -2,12 +2,20 @@ import { useState, useEffect } from 'react'; import { format } from 'date-fns'; import styles from './ReservationModal.module.css'; +const PERIOD_OPTIONS = [ + { value: '', label: '指定なし' }, + { value: '午前', label: '午前' }, + { value: '午後', label: '午後' }, +]; + export default function ReservationModal({ cars, reservation, onSave, onDelete, onClose }) { const isEdit = !!reservation?.id; const [carId, setCarId] = useState(''); const [startDate, setStartDate] = useState(''); + const [startPeriod, setStartPeriod] = useState(''); const [endDate, setEndDate] = useState(''); + const [endPeriod, setEndPeriod] = useState(''); const [customerName, setCustomerName] = useState(''); const [notes, setNotes] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -16,7 +24,9 @@ export default function ReservationModal({ cars, reservation, onSave, onDelete, if (reservation) { setCarId(String(reservation.car_id || (cars[0]?.id ?? ''))); setStartDate(reservation.start_date || format(new Date(), 'yyyy-MM-dd')); + setStartPeriod(reservation.start_period || ''); setEndDate(reservation.end_date || format(new Date(), 'yyyy-MM-dd')); + setEndPeriod(reservation.end_period || ''); setCustomerName(reservation.customer_name || ''); setNotes(reservation.notes || ''); } @@ -34,7 +44,9 @@ export default function ReservationModal({ cars, reservation, onSave, onDelete, await onSave({ car_id: Number(carId), start_date: startDate, + start_period: startPeriod, end_date: endDate, + end_period: endPeriod, customer_name: customerName, notes, }); @@ -90,6 +102,21 @@ export default function ReservationModal({ cars, reservation, onSave, onDelete, required /> +
+ + +
+ + +
+
+ + +
diff --git a/frontend/src/components/ScheduleView.jsx b/frontend/src/components/ScheduleView.jsx index 9345141..587d74a 100644 --- a/frontend/src/components/ScheduleView.jsx +++ b/frontend/src/components/ScheduleView.jsx @@ -2,7 +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 { isInspectionExpirySoon, formatDateRange, formatReservationTooltip } from '../utils/carUtils.js'; import ReservationModal from './ReservationModal.jsx'; import styles from './ScheduleView.module.css'; @@ -552,14 +552,14 @@ export default function ScheduleView({ reloadKey = 0 }) { 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 : ''}`} + title={formatReservationTooltip(r)} > {r.customer_name || '予約'} {width > 80 && ( - {r.start_date.slice(5)} 〜 {r.end_date.slice(5)} + {formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)} )}
diff --git a/frontend/src/components/TimelineView.jsx b/frontend/src/components/TimelineView.jsx index 5624bed..2c366e6 100644 --- a/frontend/src/components/TimelineView.jsx +++ b/frontend/src/components/TimelineView.jsx @@ -2,6 +2,7 @@ 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 { formatDateRange, formatReservationTooltip } from '../utils/carUtils.js'; import ReservationModal from './ReservationModal.jsx'; import styles from './TimelineView.module.css'; @@ -287,14 +288,14 @@ export default function TimelineView({ reloadKey = 0 }) { 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 : ''}`} + title={formatReservationTooltip(r)} > {r.customer_name || '予約'} {width > 80 && ( - {r.start_date.slice(5)} 〜 {r.end_date.slice(5)} + {formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)} )} diff --git a/frontend/src/utils/carUtils.js b/frontend/src/utils/carUtils.js index cab7d41..a6fe4ab 100644 --- a/frontend/src/utils/carUtils.js +++ b/frontend/src/utils/carUtils.js @@ -11,3 +11,28 @@ export function isInspectionExpirySoon(inspectionExpiry) { oneMonthLater.setMonth(oneMonthLater.getMonth() + 1); return expiry <= oneMonthLater; } + +/** + * Formats a date range with optional AM/PM periods into a display string. + * @param {string} startDate - ISO date string (YYYY-MM-DD) + * @param {string} startPeriod - '午前', '午後', or '' + * @param {string} endDate - ISO date string (YYYY-MM-DD) + * @param {string} endPeriod - '午前', '午後', or '' + * @returns {string} + */ +export function formatDateRange(startDate, startPeriod, endDate, endPeriod) { + const start = startDate.slice(5) + (startPeriod ? ' ' + startPeriod : ''); + const end = endDate.slice(5) + (endPeriod ? ' ' + endPeriod : ''); + return `${start} 〜 ${end}`; +} + +/** + * Formats a reservation tooltip string with full dates and optional periods. + * @param {object} r - reservation object + * @returns {string} + */ +export function formatReservationTooltip(r) { + const start = r.start_date + (r.start_period ? ' ' + r.start_period : ''); + const end = r.end_date + (r.end_period ? ' ' + r.end_period : ''); + return `${r.customer_name || '予約'}\n${start} 〜 ${end}${r.notes ? '\n' + r.notes : ''}`; +}