11 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
761c7f1971 Changes before error encountered
Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/a42d4e36-a3cf-4ff7-b1cb-f076e601b1b8
2026-03-22 04:25:00 +00:00
h
0bd5efde2c Merge pull request #4 from pdf114514/copilot/add-field-and-sync-features
Implementing vehicle details and real-time editing synchronization
2026-03-22 13:00:47 +09:00
copilot-swe-agent[bot]
8e9db971d3 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
2026-03-22 03:58:07 +00:00
h
09872737b7 Merge pull request #3 from pdf114514/copilot/add-popup-for-edit-delete-reservations
Add timeline view, right-click context menu on reservations, fix trust proxy
2026-03-21 03:52:02 +09:00
copilot-swe-agent[bot]
cc3ad148fc Add timeline view, right-click context menu, and fix Express trust proxy
- backend/server.js: Add app.set('trust proxy', 1) to fix express-rate-limit
  ValidationError when app runs behind nginx reverse proxy
- ScheduleView.jsx: Add right-click context menu on reservation blocks with
  Edit and Delete options; closes on click-outside or Escape
- ScheduleView.module.css: Add context menu styles
- TimelineView.jsx: New Gantt-style monthly timeline view showing all
  reservations sorted by date, with month navigation and right-click menu
- TimelineView.module.css: Styles for the timeline view
- App.jsx: Add 'タイムライン' tab to navigation

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/d03ca12c-21ce-45a0-881f-919d6635e7fb
2026-03-20 18:50:51 +00:00
copilot-swe-agent[bot]
1eb96877ff Initial plan 2026-03-20 18:38:28 +00:00
h
76dc94dd78 Merge pull request #2 from pdf114514/copilot/disable-drag-drop-for-touch
Disable touch drag & drop, warn on car delete with reservations, support configurable backend URL
2026-03-21 03:21:50 +09:00
copilot-swe-agent[bot]
c3dd0cfa69 Disable touch drag & drop, warn on car delete with reservations, configure backend URL
Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/cd194ca1-b339-4f2f-b717-31a0ba193964
2026-03-20 18:19:53 +00:00
copilot-swe-agent[bot]
40371b43d1 Initial plan 2026-03-20 18:13:46 +00:00
h
19953dff55 Merge pull request #1 from pdf114514/copilot/create-car-rental-schedule-system
Add car rental schedule management system (React+Vite + Express+SQLite)
2026-03-21 03:07:15 +09:00
copilot-swe-agent[bot]
50d3803610 Implement car reservation schedule management system
Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/1d8c6b05-0e8d-4484-a2d8-8d427dfad9cb
2026-03-20 18:03:33 +00:00
13 changed files with 131 additions and 590 deletions

View File

@@ -64,27 +64,14 @@ if (!carCols.includes('has_etc')) {
if (!carCols.includes('tire_type')) { if (!carCols.includes('tire_type')) {
db.exec("ALTER TABLE cars ADD COLUMN tire_type TEXT DEFAULT 'ノーマル'"); 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');
}
// 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 // Seed some initial cars if none exist
const carCount = db.prepare('SELECT COUNT(*) as cnt FROM cars').get(); const carCount = db.prepare('SELECT COUNT(*) as cnt FROM cars').get();
if (carCount.cnt === 0) { if (carCount.cnt === 0) {
const insertCar = db.prepare('INSERT INTO cars (name, description, sort_order) VALUES (?, ?, ?)'); const insertCar = db.prepare('INSERT INTO cars (name, description) VALUES (?, ?)');
insertCar.run('代車 A', '', 1); insertCar.run('代車 A', '');
insertCar.run('代車 B', '', 2); insertCar.run('代車 B', '');
insertCar.run('代車 C', '', 3); insertCar.run('代車 C', '');
} }
// --- WebSocket Server --- // --- WebSocket Server ---
@@ -99,23 +86,13 @@ function broadcast(message) {
}); });
} }
// for future use
function normalizeCar(car) {
if (!car) {
return car;
}
return {
...car,
};
}
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
ws.on('error', () => {}); // suppress unhandled error events ws.on('error', () => {}); // suppress unhandled error events
}); });
// --- Cars API --- // --- Cars API ---
app.get('/api/cars', (req, res) => { app.get('/api/cars', (req, res) => {
const cars = db.prepare('SELECT * FROM cars ORDER BY sort_order, id').all().map(normalizeCar); const cars = db.prepare('SELECT * FROM cars ORDER BY id').all();
res.json(cars); res.json(cars);
}); });
@@ -124,31 +101,14 @@ app.post('/api/cars', (req, res) => {
if (!name || !name.trim()) { if (!name || !name.trim()) {
return res.status(400).json({ error: '車名は必須です' }); 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( const result = db.prepare(
'INSERT INTO cars (name, description, inspection_expiry, has_etc, tire_type, sort_order) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO cars (name, description, inspection_expiry, has_etc, tire_type) VALUES (?, ?, ?, ?, ?)'
).run(name.trim(), description, inspection_expiry, has_etc ? 1 : 0, tire_type, maxOrder + 1); ).run(name.trim(), description, inspection_expiry, has_etc ? 1 : 0, tire_type);
const car = normalizeCar(db.prepare('SELECT * FROM cars WHERE id = ?').get(result.lastInsertRowid)); const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(result.lastInsertRowid);
broadcast({ type: 'data_changed', entity: 'cars' }); broadcast({ type: 'data_changed', entity: 'cars' });
res.status(201).json(car); 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) => { app.put('/api/cars/:id', (req, res) => {
const { name, description, inspection_expiry, has_etc, tire_type } = req.body; const { name, description, inspection_expiry, has_etc, tire_type } = req.body;
if (!name || !name.trim()) { if (!name || !name.trim()) {
@@ -160,7 +120,7 @@ app.put('/api/cars/:id', (req, res) => {
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: '車が見つかりません' }); return res.status(404).json({ error: '車が見つかりません' });
} }
const car = normalizeCar(db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id)); const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id);
broadcast({ type: 'data_changed', entity: 'cars' }); broadcast({ type: 'data_changed', entity: 'cars' });
res.json(car); res.json(car);
}); });
@@ -181,7 +141,7 @@ app.get('/api/reservations', (req, res) => {
}); });
app.post('/api/reservations', (req, res) => { app.post('/api/reservations', (req, res) => {
const { car_id, start_date, end_date, customer_name = '', notes = '', start_period = '', end_period = '' } = req.body; const { car_id, start_date, end_date, customer_name = '', notes = '' } = req.body;
if (!car_id || !start_date || !end_date) { if (!car_id || !start_date || !end_date) {
return res.status(400).json({ error: 'car_id, start_date, end_date は必須です' }); return res.status(400).json({ error: 'car_id, start_date, end_date は必須です' });
} }
@@ -189,15 +149,15 @@ app.post('/api/reservations', (req, res) => {
return res.status(400).json({ error: '開始日は終了日以前である必要があります' }); return res.status(400).json({ error: '開始日は終了日以前である必要があります' });
} }
const result = db.prepare( const result = db.prepare(
'INSERT INTO reservations (car_id, start_date, end_date, customer_name, notes, start_period, end_period) VALUES (?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO reservations (car_id, start_date, end_date, customer_name, notes) VALUES (?, ?, ?, ?, ?)'
).run(car_id, start_date, end_date, customer_name, notes, start_period, end_period); ).run(car_id, start_date, end_date, customer_name, notes);
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid); const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid);
broadcast({ type: 'data_changed', entity: 'reservations' }); broadcast({ type: 'data_changed', entity: 'reservations' });
res.status(201).json(reservation); res.status(201).json(reservation);
}); });
app.put('/api/reservations/:id', (req, res) => { app.put('/api/reservations/:id', (req, res) => {
const { car_id, start_date, end_date, customer_name, notes, start_period, end_period } = req.body; const { car_id, start_date, end_date, customer_name, notes } = req.body;
if (!car_id || !start_date || !end_date) { if (!car_id || !start_date || !end_date) {
return res.status(400).json({ error: 'car_id, start_date, end_date は必須です' }); return res.status(400).json({ error: 'car_id, start_date, end_date は必須です' });
} }
@@ -205,8 +165,8 @@ app.put('/api/reservations/:id', (req, res) => {
return res.status(400).json({ error: '開始日は終了日以前である必要があります' }); return res.status(400).json({ error: '開始日は終了日以前である必要があります' });
} }
const result = db.prepare( const result = db.prepare(
'UPDATE reservations SET car_id = ?, start_date = ?, end_date = ?, customer_name = ?, notes = ?, start_period = ?, end_period = ? WHERE id = ?' 'UPDATE reservations SET car_id = ?, start_date = ?, end_date = ?, customer_name = ?, notes = ? WHERE id = ?'
).run(car_id, start_date, end_date, customer_name ?? '', notes ?? '', start_period ?? '', end_period ?? '', req.params.id); ).run(car_id, start_date, end_date, customer_name ?? '', notes ?? '', req.params.id);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: '予約が見つかりません' }); return res.status(404).json({ error: '予約が見つかりません' });
} }

View File

@@ -1,112 +0,0 @@
<!DOCTYPE html> <!-- Gemini ありがとう -->
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ログイン</title>
<style>
/* CSS見た目の設定 */
body {
font-family: 'Helvetica Neue', Arial, 'Hiragino Sans', sans-serif;
background-color: #f3f4f6;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.login-box {
background-color: #ffffff;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 320px;
text-align: center;
}
.login-box h2 {
margin: 0 0 20px;
color: #333;
}
.input-group {
margin-bottom: 20px;
text-align: left;
}
.input-group label {
display: block;
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.input-group input {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
font-size: 16px;
}
.login-btn {
width: 100%;
padding: 12px;
background-color: #2563eb;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
.login-btn:hover {
background-color: #1d4ed8;
}
.error-msg {
color: #ef4444;
font-size: 14px;
margin-top: 15px;
display: none; /* 初期状態は隠しておく */
}
</style>
</head>
<body>
<div class="login-box">
<h2>システムログイン</h2>
<form id="loginForm">
<div class="input-group">
<label for="password">パスワード</label>
<input type="password" id="password" required placeholder="パスワードを入力">
</div>
<button type="submit" class="login-btn">ログイン</button>
<div id="errorMsg" class="error-msg">パスワードが違います</div>
</form>
</div>
<script>
// 【おまけのUX向上】
// もしログイン画面を開いた時点で「site_auth」クッキーが残っている場合、
// それは「Nginxにパスワードが違うと弾かれて戻ってきた」証拠です。
// エラーメッセージを出して、間違ったクッキーはお掃除しておきます。
if (document.cookie.includes('site_auth=')) {
document.getElementById('errorMsg').style.display = 'block';
document.cookie = "site_auth=; path=/; max-age=0"; // クッキーを削除
}
// フォーム送信時の処理
document.getElementById('loginForm').addEventListener('submit', function(e) {
e.preventDefault();
// 入力されたパスワードを取得
const passwordInput = document.getElementById('password').value;
// 入力値をそのままCookiesite_authとしてセットする
// encodeURIComponent を挟むことで、記号などが入力されても安全にCookie化します
document.cookie = "site_auth=" + encodeURIComponent(passwordInput) + "; path=/; max-age=86400";
// Nginxトップページへリクエストを送る
window.location.href = "/";
});
</script>
</body>
</html>

View File

@@ -1,13 +1,8 @@
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api'; const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api';
async function request(path, options = {}) { async function request(path, options = {}) {
const hasBody = options.body !== undefined;
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include', headers: { 'Content-Type': 'application/json' },
headers: {
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...(options.headers ?? {}),
},
...options, ...options,
}); });
if (!res.ok) { if (!res.ok) {
@@ -23,7 +18,6 @@ export const api = {
createCar: (data) => request('/cars', { method: 'POST', body: JSON.stringify(data) }), createCar: (data) => request('/cars', { method: 'POST', body: JSON.stringify(data) }),
updateCar: (id, data) => request(`/cars/${id}`, { method: 'PUT', body: JSON.stringify(data) }), updateCar: (id, data) => request(`/cars/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteCar: (id) => request(`/cars/${id}`, { method: 'DELETE' }), deleteCar: (id) => request(`/cars/${id}`, { method: 'DELETE' }),
reorderCars: (ids) => request('/cars/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }),
// Reservations // Reservations
getReservations: () => request('/reservations'), getReservations: () => request('/reservations'),

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { isInspectionExpirySoon } from '../utils/carUtils.js'; import { isInspectionExpirySoon } from '../utils/carUtils.js';
import styles from './CarManagement.module.css'; import styles from './CarManagement.module.css';
@@ -20,8 +20,6 @@ export default function CarManagement({ reloadKey = 0 }) {
const [editEtc, setEditEtc] = useState(false); const [editEtc, setEditEtc] = useState(false);
const [editTire, setEditTire] = useState('ノーマル'); const [editTire, setEditTire] = useState('ノーマル');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [dragOverIdx, setDragOverIdx] = useState(null);
const dragSrcIdx = useRef(null);
const loadCars = useCallback(async () => { const loadCars = useCallback(async () => {
try { try {
@@ -118,50 +116,6 @@ export default function CarManagement({ reloadKey = 0 }) {
} }
}; };
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 ( return (
<div className={styles.container}> <div className={styles.container}>
<h2 className={styles.heading}>代車管理</h2> <h2 className={styles.heading}>代車管理</h2>
@@ -215,7 +169,7 @@ export default function CarManagement({ reloadKey = 0 }) {
onChange={(e) => setNewCarTire(e.target.value)} onChange={(e) => setNewCarTire(e.target.value)}
> >
<option value="ノーマル">ノーマル</option> <option value="ノーマル">ノーマル</option>
<option value="スタッレス">スタッレス</option> <option value="スタッレス">スタッレス</option>
</select> </select>
</label> </label>
<button type="submit" className={styles.btnPrimary} disabled={submitting || !newCarName.trim()}> <button type="submit" className={styles.btnPrimary} disabled={submitting || !newCarName.trim()}>
@@ -233,7 +187,7 @@ export default function CarManagement({ reloadKey = 0 }) {
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th>順番</th> <th>ID</th>
<th>車名</th> <th>車名</th>
<th>備考</th> <th>備考</th>
<th>車検満了日</th> <th>車検満了日</th>
@@ -248,34 +202,9 @@ export default function CarManagement({ reloadKey = 0 }) {
<td colSpan={7} className={styles.empty}>代車がありません</td> <td colSpan={7} className={styles.empty}>代車がありません</td>
</tr> </tr>
)} )}
{cars.map((car, carIdx) => ( {cars.map((car) => (
<tr <tr key={car.id}>
key={car.id} <td className={styles.idCell}>{car.id}</td>
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 ? ( {editingId === car.id ? (
<> <>
<td> <td>
@@ -319,7 +248,7 @@ export default function CarManagement({ reloadKey = 0 }) {
onChange={(e) => setEditTire(e.target.value)} onChange={(e) => setEditTire(e.target.value)}
> >
<option value="ノーマル">ノーマル</option> <option value="ノーマル">ノーマル</option>
<option value="スタッレス">スタッレス</option> <option value="スタッレス">スタッレス</option>
</select> </select>
</td> </td>
<td className={styles.actions}> <td className={styles.actions}>
@@ -347,7 +276,7 @@ export default function CarManagement({ reloadKey = 0 }) {
: '-'} : '-'}
</td> </td>
<td>{car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : 'なし'}</td> <td>{car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : 'なし'}</td>
<td>{car.tire_type === 'スタッレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : 'ノーマル'}</td> <td>{car.tire_type === 'スタッレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : 'ノーマル'}</td>
<td className={styles.actions}> <td className={styles.actions}>
<button className={styles.btnEdit} onClick={() => startEdit(car)}> <button className={styles.btnEdit} onClick={() => startEdit(car)}>
編集 編集

View File

@@ -111,46 +111,9 @@
background: #fafafa; background: #fafafa;
} }
.table tbody tr[draggable] {
cursor: grab;
}
.dragOver {
background: #eff6ff !important;
outline: 2px dashed #1a56db;
outline-offset: -2px;
}
.idCell { .idCell {
color: #9ca3af; color: #9ca3af;
width: 80px; width: 50px;
}
.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 { .descCell {

View File

@@ -2,20 +2,12 @@ import { useState, useEffect } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import styles from './ReservationModal.module.css'; import styles from './ReservationModal.module.css';
const PERIOD_OPTIONS = [
{ value: '', label: '指定なし' },
{ value: '午前', label: '午前' },
{ value: '午後', label: '午後' },
];
export default function ReservationModal({ cars, reservation, onSave, onDelete, onClose }) { export default function ReservationModal({ cars, reservation, onSave, onDelete, onClose }) {
const isEdit = !!reservation?.id; const isEdit = !!reservation?.id;
const [carId, setCarId] = useState(''); const [carId, setCarId] = useState('');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [startPeriod, setStartPeriod] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [endPeriod, setEndPeriod] = useState('');
const [customerName, setCustomerName] = useState(''); const [customerName, setCustomerName] = useState('');
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -24,9 +16,7 @@ export default function ReservationModal({ cars, reservation, onSave, onDelete,
if (reservation) { if (reservation) {
setCarId(String(reservation.car_id || (cars[0]?.id ?? ''))); setCarId(String(reservation.car_id || (cars[0]?.id ?? '')));
setStartDate(reservation.start_date || format(new Date(), 'yyyy-MM-dd')); setStartDate(reservation.start_date || format(new Date(), 'yyyy-MM-dd'));
setStartPeriod(reservation.start_period || '');
setEndDate(reservation.end_date || format(new Date(), 'yyyy-MM-dd')); setEndDate(reservation.end_date || format(new Date(), 'yyyy-MM-dd'));
setEndPeriod(reservation.end_period || '');
setCustomerName(reservation.customer_name || ''); setCustomerName(reservation.customer_name || '');
setNotes(reservation.notes || ''); setNotes(reservation.notes || '');
} }
@@ -44,9 +34,7 @@ export default function ReservationModal({ cars, reservation, onSave, onDelete,
await onSave({ await onSave({
car_id: Number(carId), car_id: Number(carId),
start_date: startDate, start_date: startDate,
start_period: startPeriod,
end_date: endDate, end_date: endDate,
end_period: endPeriod,
customer_name: customerName, customer_name: customerName,
notes, notes,
}); });
@@ -102,21 +90,6 @@ export default function ReservationModal({ cars, reservation, onSave, onDelete,
required required
/> />
</div> </div>
<div className={styles.field}>
<label className={styles.label}>開始時間帯</label>
<select
className={styles.select}
value={startPeriod}
onChange={(e) => setStartPeriod(e.target.value)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
<div className={styles.fieldRow}>
<div className={styles.field}> <div className={styles.field}>
<label className={styles.label}>終了日 <span className={styles.required}>*</span></label> <label className={styles.label}>終了日 <span className={styles.required}>*</span></label>
<input <input
@@ -128,18 +101,6 @@ export default function ReservationModal({ cars, reservation, onSave, onDelete,
required required
/> />
</div> </div>
<div className={styles.field}>
<label className={styles.label}>終了時間帯</label>
<select
className={styles.select}
value={endPeriod}
onChange={(e) => setEndPeriod(e.target.value)}
>
{PERIOD_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div> </div>
<div className={styles.field}> <div className={styles.field}>

View File

@@ -1,17 +1,16 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { format, addDays, parseISO, differenceInDays } from 'date-fns'; import { format, addDays, startOfWeek, parseISO, differenceInDays, isSameDay } from 'date-fns';
import { ja } from 'date-fns/locale'; import { ja } from 'date-fns/locale';
import { api } from '../api.js'; import { api } from '../api.js';
import { isInspectionExpirySoon, formatDateRange, formatReservationTooltip } from '../utils/carUtils.js'; import { isInspectionExpirySoon } from '../utils/carUtils.js';
import ReservationModal from './ReservationModal.jsx'; import ReservationModal from './ReservationModal.jsx';
import styles from './ScheduleView.module.css'; import styles from './ScheduleView.module.css';
const CELL_WIDTH = 52; // px per half-day slot (午前 or 午後) const CELL_WIDTH = 52; // px per day column
const ROW_HEIGHT = 64; // px per car row const ROW_HEIGHT = 64; // px per car row
const LABEL_WIDTH = 140; // px for car name column const LABEL_WIDTH = 140; // px for car name column
const HEADER_HEIGHT = 80; // px for the date header row (top: date+dow, bottom: 午前/午後) const HEADER_HEIGHT = 72; // px for the date header row
const DAYS_SHOWN = 21; // number of days to show const DAYS_SHOWN = 21; // number of days to show
const HALF_SLOTS = DAYS_SHOWN * 2; // total half-day slot columns (AM + PM per day)
// Detect touch-primary device to disable mouse-only drag & drop // Detect touch-primary device to disable mouse-only drag & drop
const isTouchDevice = typeof window !== 'undefined' && const isTouchDevice = typeof window !== 'undefined' &&
@@ -43,8 +42,10 @@ export default function ScheduleView({ reloadKey = 0 }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// The first date shown in the grid (start from today) // The first date shown in the grid
const [viewStart, setViewStart] = useState(() => new Date()); const [viewStart, setViewStart] = useState(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
);
// Drag-to-create state // Drag-to-create state
const [creating, setCreating] = useState(null); const [creating, setCreating] = useState(null);
@@ -90,18 +91,17 @@ export default function ScheduleView({ reloadKey = 0 }) {
// --- Navigation --- // --- Navigation ---
const prevWeek = () => setViewStart((d) => addDays(d, -7)); const prevWeek = () => setViewStart((d) => addDays(d, -7));
const nextWeek = () => setViewStart((d) => addDays(d, 7)); const nextWeek = () => setViewStart((d) => addDays(d, 7));
const goToday = () => setViewStart(new Date()); const goToday = () => setViewStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
// --- Grid position helpers --- // --- Grid position helpers ---
// Given a mouse clientX within the grid scroll area, get the half-day slot index (0-based) // Given a mouse clientX within the grid scroll area, get the day index (0-based)
// Each slot is CELL_WIDTH wide; even slots = 午前, odd slots = 午後 const getColFromX = useCallback((clientX) => {
const getSlotFromX = useCallback((clientX) => {
if (!gridRef.current) return -1; if (!gridRef.current) return -1;
const rect = gridRef.current.getBoundingClientRect(); const rect = gridRef.current.getBoundingClientRect();
const scrollLeft = gridRef.current.scrollLeft; const scrollLeft = gridRef.current.scrollLeft;
const x = clientX - rect.left + scrollLeft - LABEL_WIDTH; const x = clientX - rect.left + scrollLeft - LABEL_WIDTH;
if (x < 0) return -1; if (x < 0) return -1;
return Math.min(Math.floor(x / CELL_WIDTH), HALF_SLOTS - 1); return Math.floor(x / CELL_WIDTH);
}, []); }, []);
const getRowFromY = useCallback((clientY) => { const getRowFromY = useCallback((clientY) => {
@@ -114,62 +114,59 @@ export default function ScheduleView({ reloadKey = 0 }) {
}, []); }, []);
// --- Cell drag to create --- // --- Cell drag to create ---
const handleCellMouseDown = (e, carId, slot) => { const handleCellMouseDown = (e, carId, dateStr) => {
if (isTouchDevice) return; // drag-to-create is mouse-only if (isTouchDevice) return; // drag-to-create is mouse-only
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
setCreating({ carId, startSlot: slot, endSlot: slot }); setCreating({ carId, startDateStr: dateStr, endDateStr: dateStr });
}; };
// --- Cell tap to create (touch devices) --- // --- Cell tap to create (touch devices) ---
const handleCellClick = useCallback((e, carId) => { const handleCellClick = useCallback((e, carId) => {
if (!isTouchDevice) return; if (!isTouchDevice) return;
const slot = getSlotFromX(e.clientX); const col = getColFromX(e.clientX);
if (slot >= 0 && slot < HALF_SLOTS) { if (col >= 0 && col < DAYS_SHOWN) {
const dayIdx = Math.floor(slot / 2); const dateStr = dateToStr(dates[col]);
const dateStr = dateToStr(dates[dayIdx]);
const period = slot % 2 === 0 ? '午前' : '午後';
setModal({ setModal({
mode: 'create', mode: 'create',
prefill: { car_id: carId, start_date: dateStr, start_period: period, end_date: dateStr, end_period: period }, prefill: { car_id: carId, start_date: dateStr, end_date: dateStr },
}); });
} }
}, [dates, getSlotFromX]); }, [dates, getColFromX]);
const handleGridMouseMove = useCallback((e) => { const handleGridMouseMove = useCallback((e) => {
if (creating) { if (creating) {
const slot = getSlotFromX(e.clientX); const col = getColFromX(e.clientX);
if (slot >= 0 && slot < HALF_SLOTS) { if (col >= 0 && col < DAYS_SHOWN) {
const hoveredDate = dateToStr(dates[col]);
setCreating((prev) => { setCreating((prev) => {
if (!prev) return null; if (!prev) return null;
const s = prev.startSlot; // Ensure start <= end
const h = slot; const s = prev.startDateStr;
const h = hoveredDate;
return { return {
...prev, ...prev,
endSlot: h >= s ? h : s, endDateStr: h >= s ? h : s,
startSlot: h < s ? h : prev.startSlot, startDateStr: h < s ? h : prev.startDateStr,
}; };
}); });
} }
} }
if (moving) { if (moving) {
const slot = getSlotFromX(e.clientX); const col = getColFromX(e.clientX);
const row = getRowFromY(e.clientY); const row = getRowFromY(e.clientY);
movingRef.current = { ...movingRef.current }; movingRef.current = { ...movingRef.current };
if (slot >= 0 && slot < HALF_SLOTS) { if (col >= 0 && col < DAYS_SHOWN) {
const durationSlots = moving.durationSlots; const newStartCol = Math.max(0, col - moving.grabDayOffset);
const newStartSlot = Math.max(0, slot - moving.grabSlotOffset); const duration = differenceInDays(
const clampedStartSlot = Math.min(newStartSlot, HALF_SLOTS - 1 - durationSlots); parseISO(moving.reservation.end_date),
const clampedEndSlot = clampedStartSlot + durationSlots; parseISO(moving.reservation.start_date)
);
const newStartDayIdx = Math.max(0, Math.min(Math.floor(clampedStartSlot / 2), DAYS_SHOWN - 1)); const clampedStartCol = Math.min(newStartCol, DAYS_SHOWN - 1 - duration);
const newEndDayIdx = Math.max(0, Math.min(Math.floor(clampedEndSlot / 2), DAYS_SHOWN - 1)); const newStartDate = dateToStr(dates[Math.max(0, clampedStartCol)]);
const newStartDate = dateToStr(dates[newStartDayIdx]); const newEndDate = dateToStr(addDays(dates[Math.max(0, clampedStartCol)], duration));
const newStartPeriod = clampedStartSlot % 2 === 0 ? '午前' : '午後';
const newEndDate = dateToStr(dates[newEndDayIdx]);
const newEndPeriod = clampedEndSlot % 2 === 0 ? '午前' : '午後';
let newCarId = moving.currentCarId; let newCarId = moving.currentCarId;
if (row >= 0 && row < cars.length) { if (row >= 0 && row < cars.length) {
@@ -179,56 +176,54 @@ export default function ScheduleView({ reloadKey = 0 }) {
setMoving((prev) => prev ? { setMoving((prev) => prev ? {
...prev, ...prev,
currentCarId: newCarId, currentCarId: newCarId,
currentStartSlot: clampedStartSlot,
currentStartDate: newStartDate, currentStartDate: newStartDate,
currentStartPeriod: newStartPeriod,
currentEndDate: newEndDate, currentEndDate: newEndDate,
currentEndPeriod: newEndPeriod, col: clampedStartCol,
col: clampedStartSlot,
row: row >= 0 && row < cars.length ? row : prev.row, row: row >= 0 && row < cars.length ? row : prev.row,
} : null); } : null);
} }
} }
}, [creating, moving, dates, cars, getSlotFromX, getRowFromY]); }, [creating, moving, dates, cars, getColFromX, getRowFromY]);
const handleGridMouseUp = useCallback(async (e) => { const handleGridMouseUp = useCallback(async (e) => {
if (creating) { if (creating) {
const { carId, startSlot, endSlot } = creating; const { carId, startDateStr, endDateStr } = creating;
setCreating(null); setCreating(null);
const startDayIdx = Math.max(0, Math.min(Math.floor(startSlot / 2), DAYS_SHOWN - 1));
const endDayIdx = Math.max(0, Math.min(Math.floor(endSlot / 2), DAYS_SHOWN - 1));
// Open modal to confirm/fill details // Open modal to confirm/fill details
setModal({ setModal({
mode: 'create', mode: 'create',
prefill: { prefill: {
car_id: carId, car_id: carId,
start_date: dateToStr(dates[startDayIdx]), start_date: startDateStr,
start_period: startSlot % 2 === 0 ? '午前' : '午後', end_date: endDateStr,
end_date: dateToStr(dates[endDayIdx]),
end_period: endSlot % 2 === 0 ? '午前' : '午後',
}, },
}); });
} }
if (moving) { if (moving) {
const { reservation, currentCarId, origStartSlot, currentStartSlot, currentStartDate, currentStartPeriod, currentEndDate, currentEndPeriod } = moving; const { reservation, currentCarId, currentStartDate, currentEndDate } = moving;
setMoving(null); setMoving(null);
movingRef.current = null; movingRef.current = null;
const duration = differenceInDays(
parseISO(reservation.end_date),
parseISO(reservation.start_date)
);
const newEndDate = currentEndDate ||
dateToStr(addDays(parseISO(currentStartDate), duration));
// Only update if something changed // Only update if something changed
if ( if (
currentCarId !== reservation.car_id || currentCarId !== reservation.car_id ||
currentStartSlot !== origStartSlot currentStartDate !== reservation.start_date
) { ) {
try { try {
await api.updateReservation(reservation.id, { await api.updateReservation(reservation.id, {
car_id: currentCarId, car_id: currentCarId,
start_date: currentStartDate, start_date: currentStartDate,
end_date: currentEndDate, end_date: newEndDate,
customer_name: reservation.customer_name, customer_name: reservation.customer_name,
notes: reservation.notes, notes: reservation.notes,
start_period: currentStartPeriod,
end_period: currentEndPeriod,
}); });
await loadData(); await loadData();
} catch (err) { } catch (err) {
@@ -237,7 +232,7 @@ export default function ScheduleView({ reloadKey = 0 }) {
} }
} }
} }
}, [creating, moving, dates, loadData]); }, [creating, moving, loadData]);
useEffect(() => { useEffect(() => {
window.addEventListener('mousemove', handleGridMouseMove); window.addEventListener('mousemove', handleGridMouseMove);
@@ -268,28 +263,18 @@ export default function ScheduleView({ reloadKey = 0 }) {
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
const slot = getSlotFromX(e.clientX); const col = getColFromX(e.clientX);
// Compute the reservation's start/end slot within the current view const startCol = Math.max(0, dates.findIndex((d) => dateToStr(d) === reservation.start_date));
const startDayCol = Math.max(0, differenceInDays(parseISO(reservation.start_date), viewStart)); const grabOffset = col >= 0 ? col - startCol : 0;
const endDayCol = Math.max(0, differenceInDays(parseISO(reservation.end_date), viewStart));
const startSlot = startDayCol * 2 + (reservation.start_period === '午後' ? 1 : 0);
const endSlot = endDayCol * 2 + (reservation.end_period === '午前' ? 0 : 1);
const durationSlots = Math.max(0, endSlot - startSlot);
const grabOffset = slot >= 0 ? slot - startSlot : 0;
const carRow = cars.findIndex((c) => c.id === reservation.car_id); const carRow = cars.findIndex((c) => c.id === reservation.car_id);
setMoving({ setMoving({
reservation, reservation,
grabSlotOffset: Math.max(0, grabOffset), grabDayOffset: Math.max(0, grabOffset),
durationSlots,
origStartSlot: startSlot,
currentCarId: reservation.car_id, currentCarId: reservation.car_id,
currentStartSlot: startSlot,
currentStartDate: reservation.start_date, currentStartDate: reservation.start_date,
currentStartPeriod: reservation.start_period || '午前',
currentEndDate: reservation.end_date, currentEndDate: reservation.end_date,
currentEndPeriod: reservation.end_period || '午後', col: startCol,
col: startSlot,
row: carRow, row: carRow,
}); });
}; };
@@ -328,26 +313,26 @@ export default function ScheduleView({ reloadKey = 0 }) {
(r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr (r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr
); );
// For each reservation, calculate its left/width in the grid (accounting for 午前/午後 periods) // For each reservation, calculate its left/width in the grid
function getReservationLayout(r) { function getReservationLayout(r) {
const rStart = r.start_date < viewStartStr ? viewStartStr : r.start_date; const rStart = r.start_date < viewStartStr ? viewStartStr : r.start_date;
const rEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date; const rEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date;
const startDayCol = differenceInDays(parseISO(rStart), viewStart); const startCol = differenceInDays(parseISO(rStart), viewStart);
const endDayCol = differenceInDays(parseISO(rEnd), viewStart); const endCol = differenceInDays(parseISO(rEnd), viewStart);
// Even slot = 午前, odd slot = 午後; empty period treated as 午前 for start, 午後 for end const left = startCol * CELL_WIDTH;
const startSlot = startDayCol * 2 + (r.start_period === '午後' ? 1 : 0); const width = (endCol - startCol + 1) * CELL_WIDTH;
const endSlot = endDayCol * 2 + (r.end_period === '午前' ? 0 : 1); return { left, width, startCol, endCol };
const left = startSlot * CELL_WIDTH;
const width = (endSlot - startSlot + 1) * CELL_WIDTH;
return { left, width, startSlot, endSlot };
} }
// Create ghost for currently moving reservation // Create ghost for currently moving reservation
const movingGhost = moving ? (() => { const movingGhost = moving ? (() => {
const duration = differenceInDays(
parseISO(moving.reservation.end_date),
parseISO(moving.reservation.start_date)
);
const col = moving.col ?? 0; const col = moving.col ?? 0;
const durationSlots = moving.durationSlots;
const left = col * CELL_WIDTH; const left = col * CELL_WIDTH;
const width = (durationSlots + 1) * CELL_WIDTH; const width = (duration + 1) * CELL_WIDTH;
return { col, left, width, row: moving.row }; return { col, left, width, row: moving.row };
})() : null; })() : null;
@@ -388,6 +373,7 @@ export default function ScheduleView({ reloadKey = 0 }) {
</div> </div>
{error && <div className={styles.error}>エラー: {error}</div>} {error && <div className={styles.error}>エラー: {error}</div>}
{loading && <div className={styles.loading}>読み込み中...</div>}
{/* Grid */} {/* Grid */}
<div <div
@@ -397,17 +383,9 @@ export default function ScheduleView({ reloadKey = 0 }) {
// don't cancel on leave — handled by global events // don't cancel on leave — handled by global events
}} }}
> >
{loading && (
<div
className={styles.loadingOverlay}
style={{ height: HEADER_HEIGHT }}
>
読み込み中...
</div>
)}
<div <div
className={styles.grid} className={styles.grid}
style={{ width: LABEL_WIDTH + DAYS_SHOWN * 2 * CELL_WIDTH }} style={{ width: LABEL_WIDTH + DAYS_SHOWN * CELL_WIDTH }}
> >
{/* Header row */} {/* Header row */}
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}> <div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
@@ -416,7 +394,7 @@ export default function ScheduleView({ reloadKey = 0 }) {
className={styles.cornerCell} className={styles.cornerCell}
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }} style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
/> />
{/* Date headers — each day spans two half-day slots (午前 + 午後) */} {/* Date headers */}
{dates.map((date) => { {dates.map((date) => {
const ds = dateToStr(date); const ds = dateToStr(date);
const isToday = ds === todayStr; const isToday = ds === todayStr;
@@ -425,17 +403,11 @@ export default function ScheduleView({ reloadKey = 0 }) {
return ( return (
<div <div
key={ds} key={ds}
className={`${styles.dateHeaderGroup} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`} className={`${styles.dateHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
style={{ width: CELL_WIDTH * 2, height: HEADER_HEIGHT }} style={{ width: CELL_WIDTH, height: HEADER_HEIGHT }}
> >
<div className={styles.dateHeaderTop}> <span className={styles.dateDay}>{format(date, 'd')}</span>
<span className={styles.dateDay}>{format(date, 'd')}</span> <span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span>
<span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span>
</div>
<div className={styles.dateHeaderAmPm}>
<div className={styles.periodLabel}>午前</div>
<div className={styles.periodLabel}>午後</div>
</div>
</div> </div>
); );
})} })}
@@ -453,8 +425,10 @@ export default function ScheduleView({ reloadKey = 0 }) {
const isCreatingRow = creating && creating.carId === car.id; const isCreatingRow = creating && creating.carId === car.id;
let creatingLeft = 0, creatingWidth = 0; let creatingLeft = 0, creatingWidth = 0;
if (isCreatingRow) { if (isCreatingRow) {
creatingLeft = Math.max(0, creating.startSlot) * CELL_WIDTH; const startCol = differenceInDays(parseISO(creating.startDateStr), viewStart);
creatingWidth = (Math.min(creating.endSlot, HALF_SLOTS - 1) - Math.max(0, creating.startSlot) + 1) * CELL_WIDTH; const endCol = differenceInDays(parseISO(creating.endDateStr), viewStart);
creatingLeft = Math.max(0, startCol) * CELL_WIDTH;
creatingWidth = (Math.min(endCol, DAYS_SHOWN - 1) - Math.max(0, startCol) + 1) * CELL_WIDTH;
} }
// Ghost reservation for this row // Ghost reservation for this row
@@ -474,37 +448,32 @@ export default function ScheduleView({ reloadKey = 0 }) {
<span className={styles.carBadges}> <span className={styles.carBadges}>
{car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : null} {car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : null}
{car.tire_type === 'スタッドレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : null} {car.tire_type === 'スタッドレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : null}
{isInspectionExpirySoon(car.inspection_expiry) ? (
<span className={styles.badgeWarn} title={`車検満了日: ${car.inspection_expiry}(まもなく期限切れ)`}>車検</span>
) : null}
</span> </span>
{isInspectionExpirySoon(car.inspection_expiry) ? (
<span
className={`${styles.badgeWarn} ${styles.badgeWarnWide}`}
title={`車検満了日: ${car.inspection_expiry}(まもなく期限切れ)`}
>
車検
</span>
) : null}
</span> </span>
</div> </div>
{/* Day cells — two per day (午前 then 午後) */} {/* Day cells */}
<div <div
className={styles.cellArea} className={styles.cellArea}
style={{ width: DAYS_SHOWN * 2 * CELL_WIDTH, height: ROW_HEIGHT }} style={{ width: DAYS_SHOWN * CELL_WIDTH, height: ROW_HEIGHT }}
onClick={(e) => handleCellClick(e, car.id)} onClick={(e) => handleCellClick(e, car.id)}
> >
{dates.flatMap((date, dayIdx) => { {dates.map((date) => {
const ds = dateToStr(date); const ds = dateToStr(date);
const isToday = ds === todayStr; const isToday = ds === todayStr;
const dow = format(date, 'E', { locale: ja }); const dow = format(date, 'E', { locale: ja });
const isWeekend = dow === '土' || dow === '日'; const isWeekend = dow === '土' || dow === '日';
return ['午前', '午後'].map((period, pIdx) => ( return (
<div <div
key={`${ds}-${period}`} key={ds}
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''} ${pIdx === 1 ? styles.cellDayEnd : ''}`} className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''}`}
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }} style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
onMouseDown={(e) => handleCellMouseDown(e, car.id, dayIdx * 2 + pIdx)} onMouseDown={(e) => handleCellMouseDown(e, car.id, ds)}
/> />
)); );
})} })}
{/* Creating highlight */} {/* Creating highlight */}
@@ -571,14 +540,14 @@ export default function ScheduleView({ reloadKey = 0 }) {
e.stopPropagation(); e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, reservation: r }); setContextMenu({ x: e.clientX, y: e.clientY, reservation: r });
}} }}
title={formatReservationTooltip(r)} title={`${r.customer_name || '予約'}\n${r.start_date}${r.end_date}${r.notes ? '\n' + r.notes : ''}`}
> >
<span className={styles.blockText}> <span className={styles.blockText}>
{r.customer_name || '予約'} {r.customer_name || '予約'}
</span> </span>
{width > 80 && ( {width > 80 && (
<span className={styles.blockDates}> <span className={styles.blockDates}>
{formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)} {r.start_date.slice(5)} {r.end_date.slice(5)}
</span> </span>
)} )}
</div> </div>

View File

@@ -74,23 +74,6 @@
font-size: 14px; font-size: 14px;
} }
.loadingOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
color: #4b5563;
font-size: 14px;
font-weight: 600;
background: rgba(255, 255, 255, 0.92);
border-bottom: 2px solid #d1d5db;
pointer-events: none;
}
/* Grid wrapper - scrollable */ /* Grid wrapper - scrollable */
.gridWrapper { .gridWrapper {
flex: 1; flex: 1;
@@ -124,47 +107,6 @@
z-index: 30; z-index: 30;
} }
.dateHeaderGroup {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
border-right: 2px solid #d1d5db;
background: white;
overflow: hidden;
}
.dateHeaderTop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
flex: 1;
border-bottom: 1px solid #e5e7eb;
}
.dateHeaderAmPm {
display: flex;
height: 22px;
flex-shrink: 0;
}
.periodLabel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 500;
color: #9ca3af;
letter-spacing: 0.03em;
}
.periodLabel:first-child {
border-right: 1px solid #f0f0f0;
}
.dateHeader { .dateHeader {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -215,9 +157,9 @@
.carLabel { .carLabel {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 12px; padding: 0 12px;
border-right: 2px solid #d1d5db; border-right: 2px solid #d1d5db;
background: white; background: white;
position: sticky; position: sticky;
@@ -231,13 +173,12 @@
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
align-self: center;
} }
.carLabelContent { .carLabelContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 3px;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
@@ -246,11 +187,9 @@
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #374151; color: #374151;
width: 100%; white-space: nowrap;
white-space: normal; overflow: hidden;
overflow-wrap: anywhere; text-overflow: ellipsis;
word-break: break-word;
line-height: 1.2;
} }
.carBadges { .carBadges {
@@ -293,13 +232,6 @@
cursor: default; cursor: default;
} }
.badgeWarnWide {
display: flex;
width: 108%;
justify-content: center;
box-sizing: border-box;
}
/* Cell area */ /* Cell area */
.cellArea { .cellArea {
display: flex; display: flex;
@@ -322,10 +254,6 @@
background: rgba(26, 86, 219, 0.04); background: rgba(26, 86, 219, 0.04);
} }
.cellDayEnd {
border-right: 1px solid #d1d5db;
}
.todayCell { .todayCell {
background: rgba(59, 130, 246, 0.06); background: rgba(59, 130, 246, 0.06);
} }

View File

@@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { format, addDays, addMonths, startOfMonth, endOfMonth, parseISO, differenceInDays } from 'date-fns'; import { format, addDays, addMonths, startOfMonth, endOfMonth, parseISO, differenceInDays } from 'date-fns';
import { ja } from 'date-fns/locale'; import { ja } from 'date-fns/locale';
import { api } from '../api.js'; import { api } from '../api.js';
import { formatDateRange, formatReservationTooltip } from '../utils/carUtils.js';
import ReservationModal from './ReservationModal.jsx'; import ReservationModal from './ReservationModal.jsx';
import styles from './TimelineView.module.css'; import styles from './TimelineView.module.css';
@@ -136,9 +135,9 @@ export default function TimelineView({ reloadKey = 0 }) {
const carColorMap = {}; const carColorMap = {};
cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); }); cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); });
// Sort reservations by start_date descending (newest first) then car // Sort reservations by start_date then car
const sortedReservations = [...reservations].sort((a, b) => { const sortedReservations = [...reservations].sort((a, b) => {
if (a.start_date !== b.start_date) return a.start_date > b.start_date ? -1 : 1; if (a.start_date !== b.start_date) return a.start_date < b.start_date ? -1 : 1;
return a.car_id - b.car_id; return a.car_id - b.car_id;
}); });
@@ -191,17 +190,10 @@ export default function TimelineView({ reloadKey = 0 }) {
</div> </div>
{error && <div className={styles.error}>エラー: {error}</div>} {error && <div className={styles.error}>エラー: {error}</div>}
{loading && <div className={styles.loading}>読み込み中...</div>}
{/* Timeline grid */} {/* Timeline grid */}
<div className={styles.gridWrapper} ref={gridRef}> <div className={styles.gridWrapper} ref={gridRef}>
{loading && (
<div
className={styles.loadingOverlay}
style={{ height: HEADER_HEIGHT }}
>
読み込み中...
</div>
)}
<div className={styles.grid} style={{ width: totalWidth }}> <div className={styles.grid} style={{ width: totalWidth }}>
{/* Sticky header: month/day labels */} {/* Sticky header: month/day labels */}
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}> <div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
@@ -288,14 +280,14 @@ export default function TimelineView({ reloadKey = 0 }) {
e.stopPropagation(); e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, reservation: r }); setContextMenu({ x: e.clientX, y: e.clientY, reservation: r });
}} }}
title={formatReservationTooltip(r)} title={`${r.customer_name || '予約'}\n${r.start_date}${r.end_date}${r.notes ? '\n' + r.notes : ''}`}
> >
<span className={styles.barText}> <span className={styles.barText}>
{r.customer_name || '予約'} {r.customer_name || '予約'}
</span> </span>
{width > 80 && ( {width > 80 && (
<span className={styles.barDates}> <span className={styles.barDates}>
{formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)} {r.start_date.slice(5)} {r.end_date.slice(5)}
</span> </span>
)} )}
</div> </div>

View File

@@ -81,23 +81,6 @@
font-size: 14px; font-size: 14px;
} }
.loadingOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
color: #4b5563;
font-size: 14px;
font-weight: 600;
background: rgba(255, 255, 255, 0.92);
border-bottom: 2px solid #d1d5db;
pointer-events: none;
}
/* Grid */ /* Grid */
.gridWrapper { .gridWrapper {
flex: 1; flex: 1;

View File

@@ -11,28 +11,3 @@ export function isInspectionExpirySoon(inspectionExpiry) {
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1); oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
return expiry <= oneMonthLater; 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 : ''}`;
}

View File

@@ -49,7 +49,6 @@ export default defineConfig({
plugins: [react(), wsProxyPlugin()], plugins: [react(), wsProxyPlugin()],
server: { server: {
port: 5173, port: 5173,
allowedHosts: ["car.33-4.party"],
proxy: { proxy: {
'/api': { '/api': {
target: backendOrigin, target: backendOrigin,

View File

@@ -4,8 +4,8 @@
"description": "代車スケジュール管理システム", "description": "代車スケジュール管理システム",
"private": true, "private": true,
"scripts": { "scripts": {
"dev:backend": "cd backend && node server.js --port 3007", "dev:backend": "cd backend && node server.js",
"dev:frontend": "cd frontend && npx vite --port 3006", "dev:frontend": "cd frontend && npx vite",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npx vite build" "build": "cd frontend && npx vite build"
}, },