8 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
7a04012b60 refactor: extract shared applyReorder helper in CarManagement
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/c5f8f1a2-8a8b-4951-8442-76ce37d906ae

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
2026-04-06 07:48:47 +00:00
copilot-swe-agent[bot]
3b49844d0b fix: preserve AM/PM when dragging reservation; add drag & drop car reordering
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/c5f8f1a2-8a8b-4951-8442-76ce37d906ae

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
2026-04-06 07:47:08 +00:00
h
81767e5270 Merge pull request #6 from pdf114514/copilot/update-reservation-options
Add car reordering and AM/PM period fields for reservations
2026-04-06 16:37:36 +09:00
copilot-swe-agent[bot]
675e5f6fe8 Add car reordering and AM/PM period for reservations
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/c0a4b7dc-228e-4e7d-a985-61b9a17de159

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
2026-04-06 07:12:49 +00:00
h
2e9e100178 Commit 2026-04-06 15:36:51 +09:00
h
1081ea1074 Merge pull request #5 from pdf114514/copilot/update-vehicle-indicator-display
Updating vehicle indicator and editing interface
2026-03-22 13:26:06 +09:00
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
14 changed files with 545 additions and 60 deletions

View File

@@ -64,18 +64,32 @@ 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 ---
const wss = new WebSocketServer({ server, path: '/ws' });
const wss = new WebSocketServer({ server, path: '/api/ws' });
function broadcast(message) {
const data = JSON.stringify(message);
@@ -86,13 +100,27 @@ function broadcast(message) {
});
}
function normalizeTireType(value) {
return value === 'スタットレス' ? 'スタッドレス' : value;
}
function normalizeCar(car) {
if (!car) {
return car;
}
return {
...car,
tire_type: normalizeTireType(car.tire_type),
};
}
wss.on('connection', (ws) => {
ws.on('error', () => {}); // suppress unhandled error events
});
// --- Cars API ---
app.get('/api/cars', (req, res) => {
const cars = db.prepare('SELECT * FROM cars ORDER BY id').all();
const cars = db.prepare('SELECT * FROM cars ORDER BY sort_order, id').all().map(normalizeCar);
res.json(cars);
});
@@ -101,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);
const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(result.lastInsertRowid);
'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()) {
@@ -120,7 +165,7 @@ app.put('/api/cars/:id', (req, res) => {
if (result.changes === 0) {
return res.status(404).json({ error: '車が見つかりません' });
}
const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id);
const car = normalizeCar(db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id));
broadcast({ type: 'data_changed', entity: 'cars' });
res.json(car);
});
@@ -141,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 は必須です' });
}
@@ -149,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 は必須です' });
}
@@ -165,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: '予約が見つかりません' });
}

112
car-login.html Normal file
View File

@@ -0,0 +1,112 @@
<!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,8 +1,13 @@
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api';
async function request(path, options = {}) {
const hasBody = options.body !== undefined;
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
headers: {
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
@@ -18,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'),

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { api } from '../api.js';
import { isInspectionExpirySoon } from '../utils/carUtils.js';
import styles from './CarManagement.module.css';
@@ -20,6 +20,8 @@ export default function CarManagement({ reloadKey = 0 }) {
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 {
@@ -116,6 +118,50 @@ 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 (
<div className={styles.container}>
<h2 className={styles.heading}>代車管理</h2>
@@ -169,7 +215,7 @@ export default function CarManagement({ reloadKey = 0 }) {
onChange={(e) => setNewCarTire(e.target.value)}
>
<option value="ノーマル">ノーマル</option>
<option value="スタッレス">スタッレス</option>
<option value="スタッレス">スタッレス</option>
</select>
</label>
<button type="submit" className={styles.btnPrimary} disabled={submitting || !newCarName.trim()}>
@@ -187,7 +233,7 @@ export default function CarManagement({ reloadKey = 0 }) {
<table className={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>順番</th>
<th>車名</th>
<th>備考</th>
<th>車検満了日</th>
@@ -202,9 +248,34 @@ export default function CarManagement({ reloadKey = 0 }) {
<td colSpan={7} className={styles.empty}>代車がありません</td>
</tr>
)}
{cars.map((car) => (
<tr key={car.id}>
<td className={styles.idCell}>{car.id}</td>
{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>
@@ -248,7 +319,7 @@ export default function CarManagement({ reloadKey = 0 }) {
onChange={(e) => setEditTire(e.target.value)}
>
<option value="ノーマル">ノーマル</option>
<option value="スタッレス">スタッレス</option>
<option value="スタッレス">スタッレス</option>
</select>
</td>
<td className={styles.actions}>
@@ -275,8 +346,8 @@ export default function CarManagement({ reloadKey = 0 }) {
: car.inspection_expiry
: '-'}
</td>
<td>{car.has_etc ? '🛣️ あり' : 'なし'}</td>
<td>{car.tire_type === 'スタッレス' ? '❄️ スタッレス' : 'ノーマル'}</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)}>
編集

View File

@@ -79,7 +79,7 @@
background: white;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
overflow: hidden;
overflow-x: auto;
}
.table {
@@ -111,9 +111,46 @@
background: #fafafa;
}
.table tbody tr[draggable] {
cursor: grab;
}
.dragOver {
background: #eff6ff !important;
outline: 2px dashed #1a56db;
outline-offset: -2px;
}
.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 {
@@ -218,3 +255,25 @@
font-weight: 600;
}
.badgeEtc {
display: inline-block;
background: #7c3aed;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
letter-spacing: 0.04em;
}
.badgeStudless {
display: inline-block;
background: #0ea5e9;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
letter-spacing: 0.03em;
}

View File

@@ -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
/>
</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}>
<label className={styles.label}>終了日 <span className={styles.required}>*</span></label>
<input
@@ -101,6 +128,18 @@ export default function ReservationModal({ cars, reservation, onSave, onDelete,
required
/>
</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 className={styles.field}>

View File

@@ -2,12 +2,12 @@ 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';
const CELL_WIDTH = 52; // px per day column
const ROW_HEIGHT = 52; // px per car row
const ROW_HEIGHT = 64; // px per car row
const LABEL_WIDTH = 140; // px for car name column
const HEADER_HEIGHT = 72; // px for the date header row
const DAYS_SHOWN = 21; // number of days to show
@@ -224,6 +224,8 @@ export default function ScheduleView({ reloadKey = 0 }) {
end_date: newEndDate,
customer_name: reservation.customer_name,
notes: reservation.notes,
start_period: reservation.start_period,
end_period: reservation.end_period,
});
await loadData();
} catch (err) {
@@ -373,7 +375,6 @@ export default function ScheduleView({ reloadKey = 0 }) {
</div>
{error && <div className={styles.error}>エラー: {error}</div>}
{loading && <div className={styles.loading}>読み込み中...</div>}
{/* Grid */}
<div
@@ -383,6 +384,14 @@ export default function ScheduleView({ reloadKey = 0 }) {
// don't cancel on leave — handled by global events
}}
>
{loading && (
<div
className={styles.loadingOverlay}
style={{ height: HEADER_HEIGHT }}
>
読み込み中...
</div>
)}
<div
className={styles.grid}
style={{ width: LABEL_WIDTH + DAYS_SHOWN * CELL_WIDTH }}
@@ -443,12 +452,19 @@ export default function ScheduleView({ reloadKey = 0 }) {
title={car.description || car.name}
>
<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}
<span className={styles.carLabelContent}>
<span className={styles.carName}>{car.name}</span>
<span className={styles.carBadges}>
{car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : null}
{car.tire_type === 'スタッドレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : null}
</span>
{isInspectionExpirySoon(car.inspection_expiry) ? (
<span title={`車検満了日: ${car.inspection_expiry}(まもなく期限切れ)`}></span>
<span
className={`${styles.badgeWarn} ${styles.badgeWarnWide}`}
title={`車検満了日: ${car.inspection_expiry}(まもなく期限切れ)`}
>
車検
</span>
) : null}
</span>
</div>
@@ -538,14 +554,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)}
>
<span className={styles.blockText}>
{r.customer_name || '予約'}
</span>
{width > 80 && (
<span className={styles.blockDates}>
{r.start_date.slice(5)} {r.end_date.slice(5)}
{formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)}
</span>
)}
</div>

View File

@@ -74,6 +74,23 @@
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 */
.gridWrapper {
flex: 1;
@@ -157,9 +174,9 @@
.carLabel {
flex-shrink: 0;
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
padding: 0 12px;
padding: 6px 12px;
border-right: 2px solid #d1d5db;
background: white;
position: sticky;
@@ -173,24 +190,73 @@
height: 10px;
border-radius: 50%;
flex-shrink: 0;
align-self: center;
}
.carLabelContent {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.carName {
font-size: 13px;
font-weight: 600;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.2;
}
.carIcons {
.carBadges {
display: flex;
gap: 2px;
font-size: 14px;
flex-shrink: 0;
gap: 4px;
flex-wrap: nowrap;
}
.badgeEtc {
background: #7c3aed;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
letter-spacing: 0.03em;
}
.badgeStudless {
background: #0ea5e9;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
letter-spacing: 0.03em;
}
.badgeWarn {
background: #d97706;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
letter-spacing: 0.03em;
cursor: default;
}
.badgeWarnWide {
display: flex;
width: 108%;
justify-content: center;
box-sizing: border-box;
}
/* Cell area */

View File

@@ -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';
@@ -190,10 +191,17 @@ export default function TimelineView({ reloadKey = 0 }) {
</div>
{error && <div className={styles.error}>エラー: {error}</div>}
{loading && <div className={styles.loading}>読み込み中...</div>}
{/* Timeline grid */}
<div className={styles.gridWrapper} ref={gridRef}>
{loading && (
<div
className={styles.loadingOverlay}
style={{ height: HEADER_HEIGHT }}
>
読み込み中...
</div>
)}
<div className={styles.grid} style={{ width: totalWidth }}>
{/* Sticky header: month/day labels */}
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
@@ -280,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)}
>
<span className={styles.barText}>
{r.customer_name || '予約'}
</span>
{width > 80 && (
<span className={styles.barDates}>
{r.start_date.slice(5)} {r.end_date.slice(5)}
{formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)}
</span>
)}
</div>

View File

@@ -81,6 +81,23 @@
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 */
.gridWrapper {
flex: 1;

View File

@@ -5,8 +5,9 @@ const WS_BASE = (() => {
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`;
// proxies /api/ws to the backend, and in production nginx does the same
// via the existing location /api/ block (no extra nginx config needed).
return `${proto}//${loc.host}/api/ws`;
})();
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000, 30000]; // ms
@@ -71,11 +72,30 @@ export default function useWebSocket(onMessage) {
}, []);
useEffect(() => {
// Reset on each mount so that React StrictMode's simulated unmount/remount
// does not leave unmountedRef permanently true and block reconnection.
unmountedRef.current = false;
connect();
return () => {
unmountedRef.current = true;
clearTimeout(retryTimerRef.current);
wsRef.current?.close();
const ws = wsRef.current;
if (ws) {
// Null out all handlers first to prevent any reconnect attempts.
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
if (ws.readyState === WebSocket.CONNECTING) {
// Closing a CONNECTING socket triggers the browser warning
// "WebSocket is closed before the connection is established".
// Instead, schedule a close as soon as it opens so the server
// isn't left with a permanently idle connection.
ws.onopen = () => ws.close();
} else if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
};
}, [connect]);

View File

@@ -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 : ''}`;
}

View File

@@ -5,10 +5,10 @@ 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
* Custom Vite plugin that tunnels WebSocket upgrade requests at /api/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
* hooks directly onto `httpServer.upgrade` and handles the /api/ws path before
* Vite gets a chance to claim it.
*/
function wsProxyPlugin() {
@@ -16,7 +16,7 @@ function wsProxyPlugin() {
name: 'ws-proxy',
configureServer(server) {
server.httpServer?.on('upgrade', (req, socket, head) => {
if (req.url !== '/ws') return;
if (req.url !== '/api/ws') return;
const { hostname, port: rawPort } = new URL(backendOrigin);
const port = parseInt(rawPort) || 3001;
@@ -49,6 +49,7 @@ export default defineConfig({
plugins: [react(), wsProxyPlugin()],
server: {
port: 5173,
allowedHosts: ["car.33-4.party"],
proxy: {
'/api': {
target: backendOrigin,

View File

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