Compare commits
13 Commits
copilot/ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a5947a80 | ||
|
|
ad96d38863 | ||
|
|
b80c15e186 | ||
|
|
7a04012b60 | ||
|
|
3b49844d0b | ||
|
|
81767e5270 | ||
|
|
675e5f6fe8 | ||
| 2e9e100178 | |||
|
|
1081ea1074 | ||
|
|
761c7f1971 | ||
|
|
0bd5efde2c | ||
|
|
8e9db971d3 | ||
|
|
09872737b7 |
24
backend/package-lock.json
generated
24
backend/package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.3.1"
|
"express-rate-limit": "^8.3.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -1309,6 +1310,27 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.3.1"
|
"express-rate-limit": "^8.3.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ const cors = require('cors');
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
// Trust the first proxy (nginx) so that express-rate-limit can correctly
|
// Trust the first proxy (nginx) so that express-rate-limit can correctly
|
||||||
// identify clients by their real IP from the X-Forwarded-For header.
|
// identify clients by their real IP from the X-Forwarded-For header.
|
||||||
@@ -14,6 +17,10 @@ app.set('trust proxy', 1);
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Serve the built frontend (production mode)
|
||||||
|
const distPath = path.join(__dirname, '..', 'frontend', 'dist');
|
||||||
|
app.use(express.static(distPath));
|
||||||
|
|
||||||
// Apply rate limiting to all API routes
|
// Apply rate limiting to all API routes
|
||||||
const apiLimiter = rateLimit({
|
const apiLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
@@ -46,41 +53,120 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migrate: add new car fields if they don't exist yet
|
||||||
|
const carCols = db.prepare("PRAGMA table_info(cars)").all().map((c) => c.name);
|
||||||
|
if (!carCols.includes('inspection_expiry')) {
|
||||||
|
db.exec("ALTER TABLE cars ADD COLUMN inspection_expiry TEXT DEFAULT ''");
|
||||||
|
}
|
||||||
|
if (!carCols.includes('has_etc')) {
|
||||||
|
db.exec('ALTER TABLE cars ADD COLUMN has_etc INTEGER DEFAULT 0');
|
||||||
|
}
|
||||||
|
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
|
// 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) VALUES (?, ?)');
|
const insertCar = db.prepare('INSERT INTO cars (name, description, sort_order) VALUES (?, ?, ?)');
|
||||||
insertCar.run('代車 A', '');
|
insertCar.run('代車 A', '', 1);
|
||||||
insertCar.run('代車 B', '');
|
insertCar.run('代車 B', '', 2);
|
||||||
insertCar.run('代車 C', '');
|
insertCar.run('代車 C', '', 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WebSocket Server ---
|
||||||
|
const wss = new WebSocketServer({ server, path: '/api/ws' });
|
||||||
|
|
||||||
|
function broadcast(message) {
|
||||||
|
const data = JSON.stringify(message);
|
||||||
|
wss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ---
|
// --- Cars API ---
|
||||||
app.get('/api/cars', (req, res) => {
|
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);
|
res.json(cars);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/cars', (req, res) => {
|
app.post('/api/cars', (req, res) => {
|
||||||
const { name, description = '' } = req.body;
|
const { name, description = '', inspection_expiry = '', has_etc = 0, tire_type = 'ノーマル' } = req.body;
|
||||||
if (!name || !name.trim()) {
|
if (!name || !name.trim()) {
|
||||||
return res.status(400).json({ error: '車名は必須です' });
|
return res.status(400).json({ error: '車名は必須です' });
|
||||||
}
|
}
|
||||||
const result = db.prepare('INSERT INTO cars (name, description) VALUES (?, ?)').run(name.trim(), description);
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM cars').get().m ?? 0;
|
||||||
const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(result.lastInsertRowid);
|
const result = db.prepare(
|
||||||
|
'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);
|
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 } = req.body;
|
const { name, description, inspection_expiry, has_etc, tire_type } = req.body;
|
||||||
if (!name || !name.trim()) {
|
if (!name || !name.trim()) {
|
||||||
return res.status(400).json({ error: '車名は必須です' });
|
return res.status(400).json({ error: '車名は必須です' });
|
||||||
}
|
}
|
||||||
const result = db.prepare('UPDATE cars SET name = ?, description = ? WHERE id = ?').run(name.trim(), description ?? '', req.params.id);
|
const result = db.prepare(
|
||||||
|
'UPDATE cars SET name = ?, description = ?, inspection_expiry = ?, has_etc = ?, tire_type = ? WHERE id = ?'
|
||||||
|
).run(name.trim(), description ?? '', inspection_expiry ?? '', has_etc ? 1 : 0, tire_type ?? 'ノーマル', req.params.id);
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: '車が見つかりません' });
|
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);
|
res.json(car);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +175,7 @@ app.delete('/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: '車が見つかりません' });
|
||||||
}
|
}
|
||||||
|
broadcast({ type: 'data_changed', entity: 'all' });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +186,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 = '' } = 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) {
|
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 は必須です' });
|
||||||
}
|
}
|
||||||
@@ -107,14 +194,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) VALUES (?, ?, ?, ?, ?)'
|
'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);
|
).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);
|
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
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 } = 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) {
|
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 は必須です' });
|
||||||
}
|
}
|
||||||
@@ -122,12 +210,13 @@ 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 = ? WHERE 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 ?? '', req.params.id);
|
).run(car_id, start_date, end_date, customer_name ?? '', notes ?? '', start_period ?? '', end_period ?? '', req.params.id);
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: '予約が見つかりません' });
|
return res.status(404).json({ error: '予約が見つかりません' });
|
||||||
}
|
}
|
||||||
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(req.params.id);
|
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(req.params.id);
|
||||||
|
broadcast({ type: 'data_changed', entity: 'reservations' });
|
||||||
res.json(reservation);
|
res.json(reservation);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,9 +225,15 @@ app.delete('/api/reservations/:id', (req, res) => {
|
|||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: '予約が見つかりません' });
|
return res.status(404).json({ error: '予約が見つかりません' });
|
||||||
}
|
}
|
||||||
|
broadcast({ type: 'data_changed', entity: 'reservations' });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// SPA fallback: serve index.html for any non-API route
|
||||||
|
app.get('/*path', apiLimiter, (req, res) => {
|
||||||
|
res.sendFile(path.join(distPath, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
console.log(`Server running on http://localhost:${PORT}`);
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
112
car-login.html
Normal file
112
car-login.html
Normal 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;
|
||||||
|
|
||||||
|
// 入力値をそのままCookie(site_auth)としてセットする
|
||||||
|
// encodeURIComponent を挟むことで、記号などが入力されても安全にCookie化します
|
||||||
|
document.cookie = "site_auth=" + encodeURIComponent(passwordInput) + "; path=/; max-age=86400";
|
||||||
|
|
||||||
|
// Nginx(トップページ)へリクエストを送る
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,16 +1,36 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import ScheduleView from './components/ScheduleView.jsx';
|
import ScheduleView from './components/ScheduleView.jsx';
|
||||||
import CarManagement from './components/CarManagement.jsx';
|
import CarManagement from './components/CarManagement.jsx';
|
||||||
import TimelineView from './components/TimelineView.jsx';
|
import TimelineView from './components/TimelineView.jsx';
|
||||||
|
import useWebSocket from './hooks/useWebSocket.js';
|
||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [page, setPage] = useState('schedule');
|
const [page, setPage] = useState('schedule');
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
const handleWsMessage = useCallback((msg) => {
|
||||||
|
if (msg.type === 'data_changed') {
|
||||||
|
setReloadKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { status: wsStatus } = useWebSocket(handleWsMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1 className={styles.title}>🚗 代車スケジュール管理</h1>
|
<div className={styles.headerLeft}>
|
||||||
|
<span
|
||||||
|
className={`${styles.wsIndicator} ${styles['wsIndicator_' + wsStatus]}`}
|
||||||
|
title={
|
||||||
|
wsStatus === 'connected' ? 'リアルタイム同期: 接続中' :
|
||||||
|
wsStatus === 'connecting' || wsStatus === 'disconnected' ? 'リアルタイム同期: 再接続中...' :
|
||||||
|
'リアルタイム同期: 接続失敗'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1 className={styles.title}>🚗 代車スケジュール管理</h1>
|
||||||
|
</div>
|
||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
<button
|
<button
|
||||||
className={`${styles.navBtn} ${page === 'schedule' ? styles.active : ''}`}
|
className={`${styles.navBtn} ${page === 'schedule' ? styles.active : ''}`}
|
||||||
@@ -32,10 +52,17 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{wsStatus === 'error' && (
|
||||||
|
<div className={styles.wsError}>
|
||||||
|
⚠️ サーバーとの接続が切断されました。ページを再読み込みしてください。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
{page === 'schedule' && <ScheduleView />}
|
{page === 'schedule' && <ScheduleView reloadKey={reloadKey} />}
|
||||||
{page === 'timeline' && <TimelineView />}
|
{page === 'timeline' && <TimelineView reloadKey={reloadKey} />}
|
||||||
{page === 'cars' && <CarManagement />}
|
{page === 'cars' && <CarManagement reloadKey={reloadKey} />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,12 +18,59 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* WebSocket connection indicator dot */
|
||||||
|
.wsIndicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wsIndicator_connected {
|
||||||
|
background: #4ade80; /* green */
|
||||||
|
box-shadow: 0 0 6px #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wsIndicator_connecting,
|
||||||
|
.wsIndicator_disconnected {
|
||||||
|
background: #fbbf24; /* amber */
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wsIndicator_error {
|
||||||
|
background: #f87171; /* red */
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disconnection warning banner */
|
||||||
|
.wsError {
|
||||||
|
background: #fef3c7;
|
||||||
|
border-bottom: 2px solid #f59e0b;
|
||||||
|
color: #92400e;
|
||||||
|
padding: 8px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -54,3 +101,4 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
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}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -18,6 +23,7 @@ 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'),
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { isInspectionExpirySoon } from '../utils/carUtils.js';
|
||||||
import styles from './CarManagement.module.css';
|
import styles from './CarManagement.module.css';
|
||||||
|
|
||||||
export default function CarManagement() {
|
export default function CarManagement({ reloadKey = 0 }) {
|
||||||
const [cars, setCars] = useState([]);
|
const [cars, setCars] = useState([]);
|
||||||
const [reservations, setReservations] = useState([]);
|
const [reservations, setReservations] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [newCarName, setNewCarName] = useState('');
|
const [newCarName, setNewCarName] = useState('');
|
||||||
const [newCarDesc, setNewCarDesc] = useState('');
|
const [newCarDesc, setNewCarDesc] = useState('');
|
||||||
|
const [newCarExpiry, setNewCarExpiry] = useState('');
|
||||||
|
const [newCarEtc, setNewCarEtc] = useState(false);
|
||||||
|
const [newCarTire, setNewCarTire] = useState('ノーマル');
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editDesc, setEditDesc] = useState('');
|
const [editDesc, setEditDesc] = useState('');
|
||||||
|
const [editExpiry, setEditExpiry] = useState('');
|
||||||
|
const [editEtc, setEditEtc] = useState(false);
|
||||||
|
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 = async () => {
|
const loadCars = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]);
|
const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]);
|
||||||
@@ -26,20 +35,29 @@ export default function CarManagement() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCars();
|
loadCars();
|
||||||
}, []);
|
}, [loadCars, reloadKey]);
|
||||||
|
|
||||||
const handleAdd = async (e) => {
|
const handleAdd = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newCarName.trim()) return;
|
if (!newCarName.trim()) return;
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await api.createCar({ name: newCarName.trim(), description: newCarDesc.trim() });
|
await api.createCar({
|
||||||
|
name: newCarName.trim(),
|
||||||
|
description: newCarDesc.trim(),
|
||||||
|
inspection_expiry: newCarExpiry,
|
||||||
|
has_etc: newCarEtc,
|
||||||
|
tire_type: newCarTire,
|
||||||
|
});
|
||||||
setNewCarName('');
|
setNewCarName('');
|
||||||
setNewCarDesc('');
|
setNewCarDesc('');
|
||||||
|
setNewCarExpiry('');
|
||||||
|
setNewCarEtc(false);
|
||||||
|
setNewCarTire('ノーマル');
|
||||||
await loadCars();
|
await loadCars();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
@@ -66,19 +84,31 @@ export default function CarManagement() {
|
|||||||
setEditingId(car.id);
|
setEditingId(car.id);
|
||||||
setEditName(car.name);
|
setEditName(car.name);
|
||||||
setEditDesc(car.description || '');
|
setEditDesc(car.description || '');
|
||||||
|
setEditExpiry(car.inspection_expiry || '');
|
||||||
|
setEditEtc(!!car.has_etc);
|
||||||
|
setEditTire(car.tire_type || 'ノーマル');
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const cancelEdit = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setEditName('');
|
setEditName('');
|
||||||
setEditDesc('');
|
setEditDesc('');
|
||||||
|
setEditExpiry('');
|
||||||
|
setEditEtc(false);
|
||||||
|
setEditTire('ノーマル');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (id) => {
|
const handleUpdate = async (id) => {
|
||||||
if (!editName.trim()) return;
|
if (!editName.trim()) return;
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await api.updateCar(id, { name: editName.trim(), description: editDesc.trim() });
|
await api.updateCar(id, {
|
||||||
|
name: editName.trim(),
|
||||||
|
description: editDesc.trim(),
|
||||||
|
inspection_expiry: editExpiry,
|
||||||
|
has_etc: editEtc,
|
||||||
|
tire_type: editTire,
|
||||||
|
});
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
await loadCars();
|
await loadCars();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -88,6 +118,50 @@ export default function CarManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -111,6 +185,39 @@ export default function CarManagement() {
|
|||||||
value={newCarDesc}
|
value={newCarDesc}
|
||||||
onChange={(e) => setNewCarDesc(e.target.value)}
|
onChange={(e) => setNewCarDesc(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<label className={styles.fieldLabel}>
|
||||||
|
車検満了日
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={styles.input}
|
||||||
|
value={newCarExpiry}
|
||||||
|
onChange={(e) => setNewCarExpiry(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.fieldLabel}>
|
||||||
|
ETC
|
||||||
|
<select
|
||||||
|
className={styles.input}
|
||||||
|
value={newCarEtc ? 'あり' : 'なし'}
|
||||||
|
onChange={(e) => setNewCarEtc(e.target.value === 'あり')}
|
||||||
|
>
|
||||||
|
<option value="なし">なし</option>
|
||||||
|
<option value="あり">あり</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.fieldLabel}>
|
||||||
|
タイヤ
|
||||||
|
<select
|
||||||
|
className={styles.input}
|
||||||
|
value={newCarTire}
|
||||||
|
onChange={(e) => setNewCarTire(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="ノーマル">ノーマル</option>
|
||||||
|
<option value="スタッドレス">スタッドレス</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit" className={styles.btnPrimary} disabled={submitting || !newCarName.trim()}>
|
<button type="submit" className={styles.btnPrimary} disabled={submitting || !newCarName.trim()}>
|
||||||
+ 追加
|
+ 追加
|
||||||
</button>
|
</button>
|
||||||
@@ -126,21 +233,49 @@ export default function CarManagement() {
|
|||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>順番</th>
|
||||||
<th>車名</th>
|
<th>車名</th>
|
||||||
<th>備考</th>
|
<th>備考</th>
|
||||||
|
<th>車検満了日</th>
|
||||||
|
<th>ETC</th>
|
||||||
|
<th>タイヤ</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{cars.length === 0 && (
|
{cars.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className={styles.empty}>代車がありません</td>
|
<td colSpan={7} className={styles.empty}>代車がありません</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{cars.map((car) => (
|
{cars.map((car, carIdx) => (
|
||||||
<tr key={car.id}>
|
<tr
|
||||||
<td className={styles.idCell}>{car.id}</td>
|
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 ? (
|
{editingId === car.id ? (
|
||||||
<>
|
<>
|
||||||
<td>
|
<td>
|
||||||
@@ -159,6 +294,34 @@ export default function CarManagement() {
|
|||||||
onChange={(e) => setEditDesc(e.target.value)}
|
onChange={(e) => setEditDesc(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={styles.input}
|
||||||
|
value={editExpiry}
|
||||||
|
onChange={(e) => setEditExpiry(e.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
className={styles.input}
|
||||||
|
value={editEtc ? 'あり' : 'なし'}
|
||||||
|
onChange={(e) => setEditEtc(e.target.value === 'あり')}
|
||||||
|
>
|
||||||
|
<option value="なし">なし</option>
|
||||||
|
<option value="あり">あり</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
className={styles.input}
|
||||||
|
value={editTire}
|
||||||
|
onChange={(e) => setEditTire(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="ノーマル">ノーマル</option>
|
||||||
|
<option value="スタッドレス">スタッドレス</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
<td className={styles.actions}>
|
<td className={styles.actions}>
|
||||||
<button
|
<button
|
||||||
className={styles.btnSave}
|
className={styles.btnSave}
|
||||||
@@ -176,6 +339,15 @@ export default function CarManagement() {
|
|||||||
<>
|
<>
|
||||||
<td>{car.name}</td>
|
<td>{car.name}</td>
|
||||||
<td className={styles.descCell}>{car.description || '-'}</td>
|
<td className={styles.descCell}>{car.description || '-'}</td>
|
||||||
|
<td className={styles.descCell}>
|
||||||
|
{car.inspection_expiry
|
||||||
|
? isInspectionExpirySoon(car.inspection_expiry)
|
||||||
|
? <span className={styles.expiryWarn}>⚠️ {car.inspection_expiry}</span>
|
||||||
|
: car.inspection_expiry
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td>{car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : 'なし'}</td>
|
||||||
|
<td>{car.tire_type === 'スタッドレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : 'ノーマル'}</td>
|
||||||
<td className={styles.actions}>
|
<td className={styles.actions}>
|
||||||
<button className={styles.btnEdit} onClick={() => startEdit(car)}>
|
<button className={styles.btnEdit} onClick={() => startEdit(car)}>
|
||||||
編集
|
編集
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
background: white;
|
background: white;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
@@ -111,9 +111,46 @@
|
|||||||
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: 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 {
|
.descCell {
|
||||||
@@ -199,3 +236,44 @@
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel .input {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiryWarn {
|
||||||
|
color: #b45309;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ 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);
|
||||||
@@ -16,7 +24,9 @@ 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 || '');
|
||||||
}
|
}
|
||||||
@@ -34,7 +44,9 @@ 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,
|
||||||
});
|
});
|
||||||
@@ -90,6 +102,21 @@ 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
|
||||||
@@ -101,6 +128,18 @@ 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}>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { format, addDays, startOfWeek, parseISO, differenceInDays, isSameDay } from 'date-fns';
|
import { format, addDays, startOfWeek, 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 { isInspectionExpirySoon, formatDateRange, formatReservationTooltip } 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 day column
|
const CELL_WIDTH = 52; // px per half-day slot (午前 or 午後)
|
||||||
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 LABEL_WIDTH = 140; // px for car name column
|
||||||
const HEADER_HEIGHT = 72; // px for the date header row
|
const HEADER_HEIGHT = 80; // px for the date header row (top: date+dow, bottom: 午前/午後)
|
||||||
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' &&
|
||||||
@@ -35,7 +37,7 @@ function dateToStr(date) {
|
|||||||
return format(date, 'yyyy-MM-dd');
|
return format(date, 'yyyy-MM-dd');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScheduleView() {
|
export default function ScheduleView({ reloadKey = 0 }) {
|
||||||
const [cars, setCars] = useState([]);
|
const [cars, setCars] = useState([]);
|
||||||
const [reservations, setReservations] = useState([]);
|
const [reservations, setReservations] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -85,7 +87,7 @@ export default function ScheduleView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData, reloadKey]);
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
const prevWeek = () => setViewStart((d) => addDays(d, -7));
|
const prevWeek = () => setViewStart((d) => addDays(d, -7));
|
||||||
@@ -93,14 +95,15 @@ export default function ScheduleView() {
|
|||||||
const goToday = () => setViewStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
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 day index (0-based)
|
// Given a mouse clientX within the grid scroll area, get the half-day slot index (0-based)
|
||||||
const getColFromX = useCallback((clientX) => {
|
// Each slot is CELL_WIDTH wide; even slots = 午前, odd slots = 午後
|
||||||
|
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.floor(x / CELL_WIDTH);
|
return Math.min(Math.floor(x / CELL_WIDTH), HALF_SLOTS - 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getRowFromY = useCallback((clientY) => {
|
const getRowFromY = useCallback((clientY) => {
|
||||||
@@ -113,59 +116,62 @@ export default function ScheduleView() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- Cell drag to create ---
|
// --- Cell drag to create ---
|
||||||
const handleCellMouseDown = (e, carId, dateStr) => {
|
const handleCellMouseDown = (e, carId, slot) => {
|
||||||
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, startDateStr: dateStr, endDateStr: dateStr });
|
setCreating({ carId, startSlot: slot, endSlot: slot });
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 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 col = getColFromX(e.clientX);
|
const slot = getSlotFromX(e.clientX);
|
||||||
if (col >= 0 && col < DAYS_SHOWN) {
|
if (slot >= 0 && slot < HALF_SLOTS) {
|
||||||
const dateStr = dateToStr(dates[col]);
|
const dayIdx = Math.floor(slot / 2);
|
||||||
|
const dateStr = dateToStr(dates[dayIdx]);
|
||||||
|
const period = slot % 2 === 0 ? '午前' : '午後';
|
||||||
setModal({
|
setModal({
|
||||||
mode: 'create',
|
mode: 'create',
|
||||||
prefill: { car_id: carId, start_date: dateStr, end_date: dateStr },
|
prefill: { car_id: carId, start_date: dateStr, start_period: period, end_date: dateStr, end_period: period },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [dates, getColFromX]);
|
}, [dates, getSlotFromX]);
|
||||||
|
|
||||||
const handleGridMouseMove = useCallback((e) => {
|
const handleGridMouseMove = useCallback((e) => {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
const col = getColFromX(e.clientX);
|
const slot = getSlotFromX(e.clientX);
|
||||||
if (col >= 0 && col < DAYS_SHOWN) {
|
if (slot >= 0 && slot < HALF_SLOTS) {
|
||||||
const hoveredDate = dateToStr(dates[col]);
|
|
||||||
setCreating((prev) => {
|
setCreating((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
// Ensure start <= end
|
const s = prev.startSlot;
|
||||||
const s = prev.startDateStr;
|
const h = slot;
|
||||||
const h = hoveredDate;
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
endDateStr: h >= s ? h : s,
|
endSlot: h >= s ? h : s,
|
||||||
startDateStr: h < s ? h : prev.startDateStr,
|
startSlot: h < s ? h : prev.startSlot,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (moving) {
|
if (moving) {
|
||||||
const col = getColFromX(e.clientX);
|
const slot = getSlotFromX(e.clientX);
|
||||||
const row = getRowFromY(e.clientY);
|
const row = getRowFromY(e.clientY);
|
||||||
movingRef.current = { ...movingRef.current };
|
movingRef.current = { ...movingRef.current };
|
||||||
|
|
||||||
if (col >= 0 && col < DAYS_SHOWN) {
|
if (slot >= 0 && slot < HALF_SLOTS) {
|
||||||
const newStartCol = Math.max(0, col - moving.grabDayOffset);
|
const durationSlots = moving.durationSlots;
|
||||||
const duration = differenceInDays(
|
const newStartSlot = Math.max(0, slot - moving.grabSlotOffset);
|
||||||
parseISO(moving.reservation.end_date),
|
const clampedStartSlot = Math.min(newStartSlot, HALF_SLOTS - 1 - durationSlots);
|
||||||
parseISO(moving.reservation.start_date)
|
const clampedEndSlot = clampedStartSlot + durationSlots;
|
||||||
);
|
|
||||||
const clampedStartCol = Math.min(newStartCol, DAYS_SHOWN - 1 - duration);
|
const newStartDayIdx = Math.max(0, Math.min(Math.floor(clampedStartSlot / 2), DAYS_SHOWN - 1));
|
||||||
const newStartDate = dateToStr(dates[Math.max(0, clampedStartCol)]);
|
const newEndDayIdx = Math.max(0, Math.min(Math.floor(clampedEndSlot / 2), DAYS_SHOWN - 1));
|
||||||
const newEndDate = dateToStr(addDays(dates[Math.max(0, clampedStartCol)], duration));
|
const newStartDate = dateToStr(dates[newStartDayIdx]);
|
||||||
|
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) {
|
||||||
@@ -175,54 +181,56 @@ export default function ScheduleView() {
|
|||||||
setMoving((prev) => prev ? {
|
setMoving((prev) => prev ? {
|
||||||
...prev,
|
...prev,
|
||||||
currentCarId: newCarId,
|
currentCarId: newCarId,
|
||||||
|
currentStartSlot: clampedStartSlot,
|
||||||
currentStartDate: newStartDate,
|
currentStartDate: newStartDate,
|
||||||
|
currentStartPeriod: newStartPeriod,
|
||||||
currentEndDate: newEndDate,
|
currentEndDate: newEndDate,
|
||||||
col: clampedStartCol,
|
currentEndPeriod: newEndPeriod,
|
||||||
|
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, getColFromX, getRowFromY]);
|
}, [creating, moving, dates, cars, getSlotFromX, getRowFromY]);
|
||||||
|
|
||||||
const handleGridMouseUp = useCallback(async (e) => {
|
const handleGridMouseUp = useCallback(async (e) => {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
const { carId, startDateStr, endDateStr } = creating;
|
const { carId, startSlot, endSlot } = 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: startDateStr,
|
start_date: dateToStr(dates[startDayIdx]),
|
||||||
end_date: endDateStr,
|
start_period: startSlot % 2 === 0 ? '午前' : '午後',
|
||||||
|
end_date: dateToStr(dates[endDayIdx]),
|
||||||
|
end_period: endSlot % 2 === 0 ? '午前' : '午後',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (moving) {
|
if (moving) {
|
||||||
const { reservation, currentCarId, currentStartDate, currentEndDate } = moving;
|
const { reservation, currentCarId, origStartSlot, currentStartSlot, currentStartDate, currentStartPeriod, currentEndDate, currentEndPeriod } = 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 ||
|
||||||
currentStartDate !== reservation.start_date
|
currentStartSlot !== origStartSlot
|
||||||
) {
|
) {
|
||||||
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: newEndDate,
|
end_date: currentEndDate,
|
||||||
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) {
|
||||||
@@ -231,7 +239,7 @@ export default function ScheduleView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [creating, moving, loadData]);
|
}, [creating, moving, dates, loadData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('mousemove', handleGridMouseMove);
|
window.addEventListener('mousemove', handleGridMouseMove);
|
||||||
@@ -262,18 +270,28 @@ export default function ScheduleView() {
|
|||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const col = getColFromX(e.clientX);
|
const slot = getSlotFromX(e.clientX);
|
||||||
const startCol = Math.max(0, dates.findIndex((d) => dateToStr(d) === reservation.start_date));
|
// Compute the reservation's start/end slot within the current view
|
||||||
const grabOffset = col >= 0 ? col - startCol : 0;
|
const startDayCol = Math.max(0, differenceInDays(parseISO(reservation.start_date), viewStart));
|
||||||
|
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,
|
||||||
grabDayOffset: Math.max(0, grabOffset),
|
grabSlotOffset: 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,
|
||||||
col: startCol,
|
currentEndPeriod: reservation.end_period || '午後',
|
||||||
|
col: startSlot,
|
||||||
row: carRow,
|
row: carRow,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -312,26 +330,26 @@ export default function ScheduleView() {
|
|||||||
(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
|
// For each reservation, calculate its left/width in the grid (accounting for 午前/午後 periods)
|
||||||
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 startCol = differenceInDays(parseISO(rStart), viewStart);
|
const startDayCol = differenceInDays(parseISO(rStart), viewStart);
|
||||||
const endCol = differenceInDays(parseISO(rEnd), viewStart);
|
const endDayCol = differenceInDays(parseISO(rEnd), viewStart);
|
||||||
const left = startCol * CELL_WIDTH;
|
// Even slot = 午前, odd slot = 午後; empty period treated as 午前 for start, 午後 for end
|
||||||
const width = (endCol - startCol + 1) * CELL_WIDTH;
|
const startSlot = startDayCol * 2 + (r.start_period === '午後' ? 1 : 0);
|
||||||
return { left, width, startCol, endCol };
|
const endSlot = endDayCol * 2 + (r.end_period === '午前' ? 0 : 1);
|
||||||
|
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 = (duration + 1) * CELL_WIDTH;
|
const width = (durationSlots + 1) * CELL_WIDTH;
|
||||||
return { col, left, width, row: moving.row };
|
return { col, left, width, row: moving.row };
|
||||||
})() : null;
|
})() : null;
|
||||||
|
|
||||||
@@ -372,7 +390,6 @@ export default function ScheduleView() {
|
|||||||
</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
|
||||||
@@ -382,9 +399,17 @@ export default function ScheduleView() {
|
|||||||
// 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 * CELL_WIDTH }}
|
style={{ width: LABEL_WIDTH + DAYS_SHOWN * 2 * CELL_WIDTH }}
|
||||||
>
|
>
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
|
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
|
||||||
@@ -393,7 +418,7 @@ export default function ScheduleView() {
|
|||||||
className={styles.cornerCell}
|
className={styles.cornerCell}
|
||||||
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
|
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
{/* Date headers */}
|
{/* Date headers — each day spans two half-day slots (午前 + 午後) */}
|
||||||
{dates.map((date) => {
|
{dates.map((date) => {
|
||||||
const ds = dateToStr(date);
|
const ds = dateToStr(date);
|
||||||
const isToday = ds === todayStr;
|
const isToday = ds === todayStr;
|
||||||
@@ -402,11 +427,17 @@ export default function ScheduleView() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ds}
|
key={ds}
|
||||||
className={`${styles.dateHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
|
className={`${styles.dateHeaderGroup} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
|
||||||
style={{ width: CELL_WIDTH, height: HEADER_HEIGHT }}
|
style={{ width: CELL_WIDTH * 2, height: HEADER_HEIGHT }}
|
||||||
>
|
>
|
||||||
<span className={styles.dateDay}>{format(date, 'd')}</span>
|
<div className={styles.dateHeaderTop}>
|
||||||
<span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span>
|
<span className={styles.dateDay}>{format(date, 'd')}</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -424,10 +455,8 @@ export default function ScheduleView() {
|
|||||||
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) {
|
||||||
const startCol = differenceInDays(parseISO(creating.startDateStr), viewStart);
|
creatingLeft = Math.max(0, creating.startSlot) * CELL_WIDTH;
|
||||||
const endCol = differenceInDays(parseISO(creating.endDateStr), viewStart);
|
creatingWidth = (Math.min(creating.endSlot, HALF_SLOTS - 1) - Math.max(0, creating.startSlot) + 1) * CELL_WIDTH;
|
||||||
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
|
||||||
@@ -442,28 +471,42 @@ export default function ScheduleView() {
|
|||||||
title={car.description || car.name}
|
title={car.description || car.name}
|
||||||
>
|
>
|
||||||
<span className={styles.carDot} style={{ background: color.border }} />
|
<span className={styles.carDot} style={{ background: color.border }} />
|
||||||
<span className={styles.carName}>{car.name}</span>
|
<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
|
||||||
|
className={`${styles.badgeWarn} ${styles.badgeWarnWide}`}
|
||||||
|
title={`車検満了日: ${car.inspection_expiry}(まもなく期限切れ)`}
|
||||||
|
>
|
||||||
|
⚠️ 車検
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day cells */}
|
{/* Day cells — two per day (午前 then 午後) */}
|
||||||
<div
|
<div
|
||||||
className={styles.cellArea}
|
className={styles.cellArea}
|
||||||
style={{ width: DAYS_SHOWN * CELL_WIDTH, height: ROW_HEIGHT }}
|
style={{ width: DAYS_SHOWN * 2 * CELL_WIDTH, height: ROW_HEIGHT }}
|
||||||
onClick={(e) => handleCellClick(e, car.id)}
|
onClick={(e) => handleCellClick(e, car.id)}
|
||||||
>
|
>
|
||||||
{dates.map((date) => {
|
{dates.flatMap((date, dayIdx) => {
|
||||||
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 (
|
return ['午前', '午後'].map((period, pIdx) => (
|
||||||
<div
|
<div
|
||||||
key={ds}
|
key={`${ds}-${period}`}
|
||||||
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''}`}
|
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''} ${pIdx === 1 ? styles.cellDayEnd : ''}`}
|
||||||
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
|
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
|
||||||
onMouseDown={(e) => handleCellMouseDown(e, car.id, ds)}
|
onMouseDown={(e) => handleCellMouseDown(e, car.id, dayIdx * 2 + pIdx)}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Creating highlight */}
|
{/* Creating highlight */}
|
||||||
@@ -530,14 +573,14 @@ export default function ScheduleView() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, reservation: r });
|
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}>
|
<span className={styles.blockText}>
|
||||||
{r.customer_name || '予約'}
|
{r.customer_name || '予約'}
|
||||||
</span>
|
</span>
|
||||||
{width > 80 && (
|
{width > 80 && (
|
||||||
<span className={styles.blockDates}>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,6 +74,23 @@
|
|||||||
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;
|
||||||
@@ -107,6 +124,47 @@
|
|||||||
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;
|
||||||
@@ -157,9 +215,9 @@
|
|||||||
.carLabel {
|
.carLabel {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 12px;
|
padding: 6px 12px;
|
||||||
border-right: 2px solid #d1d5db;
|
border-right: 2px solid #d1d5db;
|
||||||
background: white;
|
background: white;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -173,15 +231,73 @@
|
|||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carLabelContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carName {
|
.carName {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
|
width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carBadges {
|
||||||
|
display: flex;
|
||||||
|
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;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
letter-spacing: 0.03em;
|
||||||
text-overflow: ellipsis;
|
}
|
||||||
|
|
||||||
|
.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 */
|
/* Cell area */
|
||||||
@@ -206,6 +322,10 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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';
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ function dateToStr(date) {
|
|||||||
return format(date, 'yyyy-MM-dd');
|
return format(date, 'yyyy-MM-dd');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TimelineView() {
|
export default function TimelineView({ reloadKey = 0 }) {
|
||||||
const [cars, setCars] = useState([]);
|
const [cars, setCars] = useState([]);
|
||||||
const [reservations, setReservations] = useState([]);
|
const [reservations, setReservations] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -75,7 +76,7 @@ export default function TimelineView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData, reloadKey]);
|
||||||
|
|
||||||
// Close context menu on click / Escape
|
// Close context menu on click / Escape
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,10 +191,17 @@ export default function TimelineView() {
|
|||||||
</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 }}>
|
||||||
@@ -280,14 +288,14 @@ export default function TimelineView() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, reservation: r });
|
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}>
|
<span className={styles.barText}>
|
||||||
{r.customer_name || '予約'}
|
{r.customer_name || '予約'}
|
||||||
</span>
|
</span>
|
||||||
{width > 80 && (
|
{width > 80 && (
|
||||||
<span className={styles.barDates}>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,6 +81,23 @@
|
|||||||
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;
|
||||||
|
|||||||
103
frontend/src/hooks/useWebSocket.js
Normal file
103
frontend/src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const WS_BASE = (() => {
|
||||||
|
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
|
||||||
|
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 /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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintains a WebSocket connection with automatic reconnection.
|
||||||
|
*
|
||||||
|
* @param {(message: object) => void} onMessage - Called for each parsed JSON message.
|
||||||
|
* @returns {{ status: 'connecting'|'connected'|'disconnected'|'error' }}
|
||||||
|
*/
|
||||||
|
export default function useWebSocket(onMessage) {
|
||||||
|
const [status, setStatus] = useState('connecting');
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const retryCountRef = useRef(0);
|
||||||
|
const unmountedRef = useRef(false);
|
||||||
|
const retryTimerRef = useRef(null);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
|
||||||
|
// Keep onMessage ref up-to-date without re-running the effect
|
||||||
|
useEffect(() => {
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
}, [onMessage]);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (unmountedRef.current) return;
|
||||||
|
|
||||||
|
setStatus('connecting');
|
||||||
|
const ws = new WebSocket(WS_BASE);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (unmountedRef.current) { ws.close(); return; }
|
||||||
|
retryCountRef.current = 0;
|
||||||
|
setStatus('connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
onMessageRef.current(msg);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (unmountedRef.current) return;
|
||||||
|
const delay = RECONNECT_DELAYS[Math.min(retryCountRef.current, RECONNECT_DELAYS.length - 1)];
|
||||||
|
retryCountRef.current += 1;
|
||||||
|
// After exhausting all backoff levels, keep status as 'error'
|
||||||
|
if (retryCountRef.current > RECONNECT_DELAYS.length) {
|
||||||
|
setStatus('error');
|
||||||
|
} else {
|
||||||
|
setStatus('disconnected');
|
||||||
|
}
|
||||||
|
retryTimerRef.current = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// onclose will fire right after, which handles reconnect
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return { status };
|
||||||
|
}
|
||||||
38
frontend/src/utils/carUtils.js
Normal file
38
frontend/src/utils/carUtils.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Returns true if the given inspection expiry date string is within 1 month
|
||||||
|
* from today (or already past).
|
||||||
|
* @param {string} inspectionExpiry - ISO date string (YYYY-MM-DD) or empty
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isInspectionExpirySoon(inspectionExpiry) {
|
||||||
|
if (!inspectionExpiry) return false;
|
||||||
|
const expiry = new Date(inspectionExpiry);
|
||||||
|
const oneMonthLater = new Date();
|
||||||
|
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 : ''}`;
|
||||||
|
}
|
||||||
@@ -1,13 +1,58 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
const backendOrigin = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 /api/ws path before
|
||||||
|
* Vite gets a chance to claim it.
|
||||||
|
*/
|
||||||
|
function wsProxyPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'ws-proxy',
|
||||||
|
configureServer(server) {
|
||||||
|
server.httpServer?.on('upgrade', (req, socket, head) => {
|
||||||
|
if (req.url !== '/api/ws') return;
|
||||||
|
|
||||||
|
const { hostname, port: rawPort } = new URL(backendOrigin);
|
||||||
|
const port = parseInt(rawPort) || 3001;
|
||||||
|
|
||||||
|
const conn = net.createConnection({ host: hostname, port });
|
||||||
|
|
||||||
|
conn.on('error', () => socket.destroy());
|
||||||
|
socket.on('error', () => conn.destroy());
|
||||||
|
|
||||||
|
conn.on('connect', () => {
|
||||||
|
// Replay the original HTTP upgrade request to the backend
|
||||||
|
const headers =
|
||||||
|
`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n` +
|
||||||
|
Object.entries(req.headers)
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join('\r\n') +
|
||||||
|
'\r\n\r\n';
|
||||||
|
conn.write(headers);
|
||||||
|
if (head && head.length) conn.write(head);
|
||||||
|
|
||||||
|
// Bidirectional pipe
|
||||||
|
conn.pipe(socket).pipe(conn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), wsProxyPlugin()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
allowedHosts: ["car.33-4.party"],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.BACKEND_URL || 'http://localhost:3001',
|
target: backendOrigin,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"description": "代車スケジュール管理システム",
|
"description": "代車スケジュール管理システム",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && node server.js",
|
"dev:backend": "cd backend && node server.js --port 3007",
|
||||||
"dev:frontend": "cd frontend && npx vite",
|
"dev:frontend": "cd frontend && npx vite --port 3006",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user