Compare commits
14 Commits
copilot/di
...
675e5f6fe8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675e5f6fe8 | ||
| 2e9e100178 | |||
|
|
1081ea1074 | ||
|
|
761c7f1971 | ||
|
|
0bd5efde2c | ||
|
|
8e9db971d3 | ||
|
|
09872737b7 | ||
|
|
cc3ad148fc | ||
|
|
1eb96877ff | ||
|
|
76dc94dd78 | ||
|
|
c3dd0cfa69 | ||
|
|
40371b43d1 | ||
|
|
19953dff55 | ||
|
|
50d3803610 |
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
.DS_Store
|
||||
85
README.md
85
README.md
@@ -1 +1,86 @@
|
||||
# CarReservation
|
||||
|
||||
代車スケジュール管理システム — Car Rental Schedule Management System
|
||||
|
||||
## Overview
|
||||
|
||||
A web application for managing loaner car (代車) schedules at an auto shop.
|
||||
|
||||
- **Frontend**: React + Vite
|
||||
- **Backend**: Express + SQLite (better-sqlite3)
|
||||
|
||||
## Features
|
||||
|
||||
- **Schedule View**: Table with cars as rows and dates as columns (21 days)
|
||||
- **Drag to Create**: Click and drag horizontally on empty cells to create a reservation
|
||||
- **Drag to Move**: Drag existing reservation blocks to move them to a new date/car
|
||||
- **Reservation Modal**: Create or edit reservations with car, dates, customer name, and notes
|
||||
- **Car Management**: Add, edit, and delete loaner cars
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install root dependencies (concurrently for dev)
|
||||
npm install
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend && npm install && cd ..
|
||||
|
||||
# Install frontend dependencies
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Start backend (port 3001)
|
||||
npm run dev:backend
|
||||
|
||||
# Start frontend (port 5173) in another terminal
|
||||
npm run dev:frontend
|
||||
|
||||
# Or start both concurrently
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in your browser.
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Backend URL (development proxy)
|
||||
|
||||
By default the Vite dev server proxies `/api` requests to `http://localhost:3001`.
|
||||
To point to a different backend server, set the `BACKEND_URL` environment variable when starting the frontend:
|
||||
|
||||
```bash
|
||||
BACKEND_URL=http://192.168.1.10:3001 npm run dev:frontend
|
||||
```
|
||||
|
||||
#### Backend URL (production build)
|
||||
|
||||
When the frontend is deployed separately from the backend, set `VITE_API_BASE_URL` to the backend server's origin before building:
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL=https://api.example.com npm run build
|
||||
```
|
||||
|
||||
This makes the built frontend send API requests to `https://api.example.com/api`.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | /api/cars | List all cars |
|
||||
| POST | /api/cars | Create a car |
|
||||
| PUT | /api/cars/:id | Update a car |
|
||||
| DELETE | /api/cars/:id | Delete a car (cascades to reservations) |
|
||||
| GET | /api/reservations | List all reservations |
|
||||
| POST | /api/reservations | Create a reservation |
|
||||
| PUT | /api/reservations/:id | Update a reservation |
|
||||
| DELETE | /api/reservations/:id | Delete a reservation |
|
||||
|
||||
1336
backend/package-lock.json
generated
Normal file
1336
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
backend/package.json
Normal file
22
backend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
239
backend/server.js
Normal file
239
backend/server.js
Normal file
@@ -0,0 +1,239 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Trust the first proxy (nginx) so that express-rate-limit can correctly
|
||||
// identify clients by their real IP from the X-Forwarded-For header.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(cors());
|
||||
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
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 300, // limit each IP to 300 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'リクエストが多すぎます。しばらくしてから再試行してください。' },
|
||||
});
|
||||
app.use('/api', apiLimiter);
|
||||
|
||||
const dbPath = path.join(__dirname, 'data.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Initialize database schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reservations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
car_id INTEGER NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
customer_name TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
FOREIGN KEY (car_id) REFERENCES cars(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// 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
|
||||
const carCount = db.prepare('SELECT COUNT(*) as cnt FROM cars').get();
|
||||
if (carCount.cnt === 0) {
|
||||
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: '/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 ---
|
||||
app.get('/api/cars', (req, res) => {
|
||||
const cars = db.prepare('SELECT * FROM cars ORDER BY sort_order, id').all().map(normalizeCar);
|
||||
res.json(cars);
|
||||
});
|
||||
|
||||
app.post('/api/cars', (req, res) => {
|
||||
const { name, description = '', inspection_expiry = '', has_etc = 0, tire_type = 'ノーマル' } = req.body;
|
||||
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, 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()) {
|
||||
return res.status(400).json({ error: '車名は必須です' });
|
||||
}
|
||||
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) {
|
||||
return res.status(404).json({ error: '車が見つかりません' });
|
||||
}
|
||||
const car = normalizeCar(db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id));
|
||||
broadcast({ type: 'data_changed', entity: 'cars' });
|
||||
res.json(car);
|
||||
});
|
||||
|
||||
app.delete('/api/cars/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM cars WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: '車が見つかりません' });
|
||||
}
|
||||
broadcast({ type: 'data_changed', entity: 'all' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// --- Reservations API ---
|
||||
app.get('/api/reservations', (req, res) => {
|
||||
const reservations = db.prepare('SELECT * FROM reservations ORDER BY start_date').all();
|
||||
res.json(reservations);
|
||||
});
|
||||
|
||||
app.post('/api/reservations', (req, res) => {
|
||||
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 は必須です' });
|
||||
}
|
||||
if (start_date > end_date) {
|
||||
return res.status(400).json({ error: '開始日は終了日以前である必要があります' });
|
||||
}
|
||||
const result = db.prepare(
|
||||
'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, 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 は必須です' });
|
||||
}
|
||||
if (start_date > end_date) {
|
||||
return res.status(400).json({ error: '開始日は終了日以前である必要があります' });
|
||||
}
|
||||
const result = db.prepare(
|
||||
'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: '予約が見つかりません' });
|
||||
}
|
||||
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(req.params.id);
|
||||
broadcast({ type: 'data_changed', entity: 'reservations' });
|
||||
res.json(reservation);
|
||||
});
|
||||
|
||||
app.delete('/api/reservations/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM reservations WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: '予約が見つかりません' });
|
||||
}
|
||||
broadcast({ type: 'data_changed', entity: 'reservations' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
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>
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>代車スケジュール管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
894
frontend/package-lock.json
generated
Normal file
894
frontend/package-lock.json
generated
Normal file
@@ -0,0 +1,894 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
||||
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.120.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
|
||||
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
|
||||
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "1.0.0-rc.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rolldown/plugin-babel": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.120.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.10"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.10",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
frontend/package.json
Normal file
18
frontend/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Car Reservation Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
69
frontend/src/App.jsx
Normal file
69
frontend/src/App.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import ScheduleView from './components/ScheduleView.jsx';
|
||||
import CarManagement from './components/CarManagement.jsx';
|
||||
import TimelineView from './components/TimelineView.jsx';
|
||||
import useWebSocket from './hooks/useWebSocket.js';
|
||||
import styles from './App.module.css';
|
||||
|
||||
export default function App() {
|
||||
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 (
|
||||
<div className={styles.app}>
|
||||
<header className={styles.header}>
|
||||
<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}>
|
||||
<button
|
||||
className={`${styles.navBtn} ${page === 'schedule' ? styles.active : ''}`}
|
||||
onClick={() => setPage('schedule')}
|
||||
>
|
||||
📅 スケジュール
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.navBtn} ${page === 'timeline' ? styles.active : ''}`}
|
||||
onClick={() => setPage('timeline')}
|
||||
>
|
||||
📊 タイムライン
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.navBtn} ${page === 'cars' ? styles.active : ''}`}
|
||||
onClick={() => setPage('cars')}
|
||||
>
|
||||
🚙 代車管理
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{wsStatus === 'error' && (
|
||||
<div className={styles.wsError}>
|
||||
⚠️ サーバーとの接続が切断されました。ページを再読み込みしてください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className={styles.main}>
|
||||
{page === 'schedule' && <ScheduleView reloadKey={reloadKey} />}
|
||||
{page === 'timeline' && <TimelineView reloadKey={reloadKey} />}
|
||||
{page === 'cars' && <CarManagement reloadKey={reloadKey} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
frontend/src/App.module.css
Normal file
104
frontend/src/App.module.css
Normal file
@@ -0,0 +1,104 @@
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.navBtn:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.navBtn.active {
|
||||
background: white;
|
||||
color: #1a56db;
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
33
frontend/src/api.js
Normal file
33
frontend/src/api.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Cars
|
||||
getCars: () => request('/cars'),
|
||||
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'),
|
||||
createReservation: (data) => request('/reservations', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateReservation: (id, data) => request(`/reservations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteReservation: (id) => request(`/reservations/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
329
frontend/src/components/CarManagement.jsx
Normal file
329
frontend/src/components/CarManagement.jsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { isInspectionExpirySoon } from '../utils/carUtils.js';
|
||||
import styles from './CarManagement.module.css';
|
||||
|
||||
export default function CarManagement({ reloadKey = 0 }) {
|
||||
const [cars, setCars] = useState([]);
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [newCarName, setNewCarName] = useState('');
|
||||
const [newCarDesc, setNewCarDesc] = useState('');
|
||||
const [newCarExpiry, setNewCarExpiry] = useState('');
|
||||
const [newCarEtc, setNewCarEtc] = useState(false);
|
||||
const [newCarTire, setNewCarTire] = useState('ノーマル');
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
const [editExpiry, setEditExpiry] = useState('');
|
||||
const [editEtc, setEditEtc] = useState(false);
|
||||
const [editTire, setEditTire] = useState('ノーマル');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadCars = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]);
|
||||
setCars(carsData);
|
||||
setReservations(resData);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCars();
|
||||
}, [loadCars, reloadKey]);
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newCarName.trim()) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await api.createCar({
|
||||
name: newCarName.trim(),
|
||||
description: newCarDesc.trim(),
|
||||
inspection_expiry: newCarExpiry,
|
||||
has_etc: newCarEtc,
|
||||
tire_type: newCarTire,
|
||||
});
|
||||
setNewCarName('');
|
||||
setNewCarDesc('');
|
||||
setNewCarExpiry('');
|
||||
setNewCarEtc(false);
|
||||
setNewCarTire('ノーマル');
|
||||
await loadCars();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id, name) => {
|
||||
const carReservations = reservations.filter((r) => r.car_id === id);
|
||||
const message = carReservations.length > 0
|
||||
? `「${name}」を削除しますか?\n⚠ この代車には ${carReservations.length} 件の予約があります。削除するとこれらの予約もすべて削除されます。`
|
||||
: `「${name}」を削除しますか?\n関連する予約もすべて削除されます。`;
|
||||
if (!confirm(message)) return;
|
||||
try {
|
||||
await api.deleteCar(id);
|
||||
await loadCars();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (car) => {
|
||||
setEditingId(car.id);
|
||||
setEditName(car.name);
|
||||
setEditDesc(car.description || '');
|
||||
setEditExpiry(car.inspection_expiry || '');
|
||||
setEditEtc(!!car.has_etc);
|
||||
setEditTire(car.tire_type || 'ノーマル');
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditName('');
|
||||
setEditDesc('');
|
||||
setEditExpiry('');
|
||||
setEditEtc(false);
|
||||
setEditTire('ノーマル');
|
||||
};
|
||||
|
||||
const handleUpdate = async (id) => {
|
||||
if (!editName.trim()) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await api.updateCar(id, {
|
||||
name: editName.trim(),
|
||||
description: editDesc.trim(),
|
||||
inspection_expiry: editExpiry,
|
||||
has_etc: editEtc,
|
||||
tire_type: editTire,
|
||||
});
|
||||
cancelEdit();
|
||||
await loadCars();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReorder = async (index, direction) => {
|
||||
const newCars = [...cars];
|
||||
const swapIndex = index + direction;
|
||||
if (swapIndex < 0 || swapIndex >= newCars.length) return;
|
||||
[newCars[index], newCars[swapIndex]] = [newCars[swapIndex], newCars[index]];
|
||||
setCars(newCars);
|
||||
try {
|
||||
await api.reorderCars(newCars.map((c) => c.id));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
await loadCars();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.heading}>代車管理</h2>
|
||||
|
||||
<div className={styles.addCard}>
|
||||
<h3 className={styles.subHeading}>代車を追加</h3>
|
||||
<form className={styles.form} onSubmit={handleAdd}>
|
||||
<div className={styles.formRow}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="車名(例:プリウス A)"
|
||||
value={newCarName}
|
||||
onChange={(e) => setNewCarName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="備考(任意)"
|
||||
value={newCarDesc}
|
||||
onChange={(e) => setNewCarDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<label className={styles.fieldLabel}>
|
||||
車検満了日
|
||||
<input
|
||||
type="date"
|
||||
className={styles.input}
|
||||
value={newCarExpiry}
|
||||
onChange={(e) => setNewCarExpiry(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.fieldLabel}>
|
||||
ETC
|
||||
<select
|
||||
className={styles.input}
|
||||
value={newCarEtc ? 'あり' : 'なし'}
|
||||
onChange={(e) => setNewCarEtc(e.target.value === 'あり')}
|
||||
>
|
||||
<option value="なし">なし</option>
|
||||
<option value="あり">あり</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.fieldLabel}>
|
||||
タイヤ
|
||||
<select
|
||||
className={styles.input}
|
||||
value={newCarTire}
|
||||
onChange={(e) => setNewCarTire(e.target.value)}
|
||||
>
|
||||
<option value="ノーマル">ノーマル</option>
|
||||
<option value="スタッドレス">スタッドレス</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className={styles.btnPrimary} disabled={submitting || !newCarName.trim()}>
|
||||
+ 追加
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading && <p className={styles.message}>読み込み中...</p>}
|
||||
{error && <p className={styles.error}>エラー: {error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>順番</th>
|
||||
<th>車名</th>
|
||||
<th>備考</th>
|
||||
<th>車検満了日</th>
|
||||
<th>ETC</th>
|
||||
<th>タイヤ</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cars.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className={styles.empty}>代車がありません</td>
|
||||
</tr>
|
||||
)}
|
||||
{cars.map((car, carIdx) => (
|
||||
<tr key={car.id}>
|
||||
<td className={styles.idCell}>
|
||||
<div className={styles.orderBtns}>
|
||||
<button
|
||||
className={styles.btnOrder}
|
||||
onClick={() => handleReorder(carIdx, -1)}
|
||||
disabled={carIdx === 0}
|
||||
title="上に移動"
|
||||
aria-label="上に移動"
|
||||
>▲</button>
|
||||
<button
|
||||
className={styles.btnOrder}
|
||||
onClick={() => handleReorder(carIdx, 1)}
|
||||
disabled={carIdx === cars.length - 1}
|
||||
title="下に移動"
|
||||
aria-label="下に移動"
|
||||
>▼</button>
|
||||
</div>
|
||||
</td>
|
||||
{editingId === car.id ? (
|
||||
<>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.input}
|
||||
value={editExpiry}
|
||||
onChange={(e) => setEditExpiry(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className={styles.input}
|
||||
value={editEtc ? 'あり' : 'なし'}
|
||||
onChange={(e) => setEditEtc(e.target.value === 'あり')}
|
||||
>
|
||||
<option value="なし">なし</option>
|
||||
<option value="あり">あり</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className={styles.input}
|
||||
value={editTire}
|
||||
onChange={(e) => setEditTire(e.target.value)}
|
||||
>
|
||||
<option value="ノーマル">ノーマル</option>
|
||||
<option value="スタッドレス">スタッドレス</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className={styles.actions}>
|
||||
<button
|
||||
className={styles.btnSave}
|
||||
onClick={() => handleUpdate(car.id)}
|
||||
disabled={submitting}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button className={styles.btnCancel} onClick={cancelEdit}>
|
||||
キャンセル
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td>{car.name}</td>
|
||||
<td className={styles.descCell}>{car.description || '-'}</td>
|
||||
<td className={styles.descCell}>
|
||||
{car.inspection_expiry
|
||||
? isInspectionExpirySoon(car.inspection_expiry)
|
||||
? <span className={styles.expiryWarn}>⚠️ {car.inspection_expiry}</span>
|
||||
: car.inspection_expiry
|
||||
: '-'}
|
||||
</td>
|
||||
<td>{car.has_etc ? <span className={styles.badgeEtc}>ETC</span> : 'なし'}</td>
|
||||
<td>{car.tire_type === 'スタッドレス' ? <span className={styles.badgeStudless}>スタッドレス</span> : 'ノーマル'}</td>
|
||||
<td className={styles.actions}>
|
||||
<button className={styles.btnEdit} onClick={() => startEdit(car)}>
|
||||
編集
|
||||
</button>
|
||||
<button className={styles.btnDelete} onClick={() => handleDelete(car.id, car.name)}>
|
||||
削除
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
frontend/src/components/CarManagement.module.css
Normal file
269
frontend/src/components/CarManagement.module.css
Normal file
@@ -0,0 +1,269 @@
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 32px auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: #1a56db;
|
||||
}
|
||||
|
||||
.subHeading {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.addCard {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #1a56db;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnPrimary:hover:not(:disabled) {
|
||||
background: #1447c0;
|
||||
}
|
||||
|
||||
.btnPrimary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f3f4f6;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 1.5px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.idCell {
|
||||
color: #9ca3af;
|
||||
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 {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btnEdit {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #374151;
|
||||
padding: 5px 14px;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnEdit:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btnDelete {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #dc2626;
|
||||
padding: 5px 14px;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnDelete:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.btnSave {
|
||||
background: #d1fae5;
|
||||
border: 1px solid #6ee7b7;
|
||||
color: #059669;
|
||||
padding: 5px 14px;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnSave:hover:not(:disabled) {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.btnCancel {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #6b7280;
|
||||
padding: 5px 14px;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btnCancel:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.message {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
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;
|
||||
}
|
||||
|
||||
191
frontend/src/components/ReservationModal.jsx
Normal file
191
frontend/src/components/ReservationModal.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
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 || '');
|
||||
}
|
||||
}, [reservation, cars]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!carId || !startDate || !endDate) return;
|
||||
if (startDate > endDate) {
|
||||
alert('開始日は終了日以前に設定してください');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await onSave({
|
||||
car_id: Number(carId),
|
||||
start_date: startDate,
|
||||
start_period: startPeriod,
|
||||
end_date: endDate,
|
||||
end_period: endPeriod,
|
||||
customer_name: customerName,
|
||||
notes,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('この予約を削除しますか?')) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await onDelete(reservation.id);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>
|
||||
{isEdit ? '予約を編集' : '新しい予約を作成'}
|
||||
</h2>
|
||||
<button className={styles.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>代車 <span className={styles.required}>*</span></label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={carId}
|
||||
onChange={(e) => setCarId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">選択してください</option>
|
||||
{cars.map((car) => (
|
||||
<option key={car.id} value={car.id}>{car.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>開始日 <span className={styles.required}>*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.input}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
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
|
||||
type="date"
|
||||
className={styles.input}
|
||||
value={endDate}
|
||||
min={startDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
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}>
|
||||
<label className={styles.label}>お客様名</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="例:山田 太郎"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>備考</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
placeholder="メモを入力..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDelete}
|
||||
onClick={handleDelete}
|
||||
disabled={submitting}
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.rightActions}>
|
||||
<button type="button" className={styles.btnCancel} onClick={onClose} disabled={submitting}>
|
||||
キャンセル
|
||||
</button>
|
||||
<button type="submit" className={styles.btnSave} disabled={submitting}>
|
||||
{submitting ? '保存中...' : (isEdit ? '更新' : '作成')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
frontend/src/components/ReservationModal.module.css
Normal file
177
frontend/src/components/ReservationModal.module.css
Normal file
@@ -0,0 +1,177 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 20px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
background: white;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.select:focus {
|
||||
border-color: #1a56db;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
border-color: #1a56db;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rightActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btnSave {
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 9px 24px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnSave:hover:not(:disabled) {
|
||||
background: #1447c0;
|
||||
}
|
||||
|
||||
.btnSave:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnCancel {
|
||||
background: #f3f4f6;
|
||||
border: 1.5px solid #d1d5db;
|
||||
color: #374151;
|
||||
padding: 9px 20px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnCancel:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btnDelete {
|
||||
background: #fee2e2;
|
||||
border: 1.5px solid #fca5a5;
|
||||
color: #dc2626;
|
||||
padding: 9px 20px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnDelete:hover:not(:disabled) {
|
||||
background: #fecaca;
|
||||
}
|
||||
621
frontend/src/components/ScheduleView.jsx
Normal file
621
frontend/src/components/ScheduleView.jsx
Normal file
@@ -0,0 +1,621 @@
|
||||
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, 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 = 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
|
||||
|
||||
// Detect touch-primary device to disable mouse-only drag & drop
|
||||
const isTouchDevice = typeof window !== 'undefined' &&
|
||||
('ontouchstart' in window || navigator.maxTouchPoints > 0);
|
||||
|
||||
// Palette for reservation colors (cycle through them by car index)
|
||||
const COLORS = [
|
||||
{ bg: '#dbeafe', border: '#3b82f6', text: '#1e3a8a' },
|
||||
{ bg: '#dcfce7', border: '#22c55e', text: '#14532d' },
|
||||
{ bg: '#fef9c3', border: '#eab308', text: '#713f12' },
|
||||
{ bg: '#fce7f3', border: '#ec4899', text: '#831843' },
|
||||
{ bg: '#ede9fe', border: '#8b5cf6', text: '#3b0764' },
|
||||
{ bg: '#ffedd5', border: '#f97316', text: '#7c2d12' },
|
||||
{ bg: '#e0f2fe', border: '#0ea5e9', text: '#0c4a6e' },
|
||||
{ bg: '#f0fdf4', border: '#16a34a', text: '#14532d' },
|
||||
];
|
||||
|
||||
function getColor(index) {
|
||||
return COLORS[index % COLORS.length];
|
||||
}
|
||||
|
||||
function dateToStr(date) {
|
||||
return format(date, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
export default function ScheduleView({ reloadKey = 0 }) {
|
||||
const [cars, setCars] = useState([]);
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// The first date shown in the grid
|
||||
const [viewStart, setViewStart] = useState(() =>
|
||||
startOfWeek(new Date(), { weekStartsOn: 1 })
|
||||
);
|
||||
|
||||
// Drag-to-create state
|
||||
const [creating, setCreating] = useState(null);
|
||||
// { carId, startDateStr, endDateStr }
|
||||
|
||||
// Drag-to-move state
|
||||
const [moving, setMoving] = useState(null);
|
||||
// { reservation, grabDayOffset, currentCarId, currentStartDate }
|
||||
|
||||
// Modal state
|
||||
const [modal, setModal] = useState(null);
|
||||
// null | { mode: 'create', prefill: {...} } | { mode: 'edit', reservation: {...} }
|
||||
|
||||
// Context menu state (right-click on reservation)
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
// null | { x, y, reservation }
|
||||
|
||||
const gridRef = useRef(null);
|
||||
const movingRef = useRef(null); // keeps latest moving state for event handlers
|
||||
|
||||
// Generate the array of dates shown
|
||||
const dates = Array.from({ length: DAYS_SHOWN }, (_, i) => addDays(viewStart, i));
|
||||
|
||||
// --- Data loading ---
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]);
|
||||
setCars(carsData);
|
||||
setReservations(resData);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData, reloadKey]);
|
||||
|
||||
// --- Navigation ---
|
||||
const prevWeek = () => setViewStart((d) => addDays(d, -7));
|
||||
const nextWeek = () => setViewStart((d) => addDays(d, 7));
|
||||
const goToday = () => setViewStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||
|
||||
// --- Grid position helpers ---
|
||||
// Given a mouse clientX within the grid scroll area, get the day index (0-based)
|
||||
const getColFromX = useCallback((clientX) => {
|
||||
if (!gridRef.current) return -1;
|
||||
const rect = gridRef.current.getBoundingClientRect();
|
||||
const scrollLeft = gridRef.current.scrollLeft;
|
||||
const x = clientX - rect.left + scrollLeft - LABEL_WIDTH;
|
||||
if (x < 0) return -1;
|
||||
return Math.floor(x / CELL_WIDTH);
|
||||
}, []);
|
||||
|
||||
const getRowFromY = useCallback((clientY) => {
|
||||
if (!gridRef.current) return -1;
|
||||
const rect = gridRef.current.getBoundingClientRect();
|
||||
const scrollTop = gridRef.current.scrollTop;
|
||||
const y = clientY - rect.top + scrollTop - HEADER_HEIGHT;
|
||||
if (y < 0) return -1;
|
||||
return Math.floor(y / ROW_HEIGHT);
|
||||
}, []);
|
||||
|
||||
// --- Cell drag to create ---
|
||||
const handleCellMouseDown = (e, carId, dateStr) => {
|
||||
if (isTouchDevice) return; // drag-to-create is mouse-only
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setCreating({ carId, startDateStr: dateStr, endDateStr: dateStr });
|
||||
};
|
||||
|
||||
// --- Cell tap to create (touch devices) ---
|
||||
const handleCellClick = useCallback((e, carId) => {
|
||||
if (!isTouchDevice) return;
|
||||
const col = getColFromX(e.clientX);
|
||||
if (col >= 0 && col < DAYS_SHOWN) {
|
||||
const dateStr = dateToStr(dates[col]);
|
||||
setModal({
|
||||
mode: 'create',
|
||||
prefill: { car_id: carId, start_date: dateStr, end_date: dateStr },
|
||||
});
|
||||
}
|
||||
}, [dates, getColFromX]);
|
||||
|
||||
const handleGridMouseMove = useCallback((e) => {
|
||||
if (creating) {
|
||||
const col = getColFromX(e.clientX);
|
||||
if (col >= 0 && col < DAYS_SHOWN) {
|
||||
const hoveredDate = dateToStr(dates[col]);
|
||||
setCreating((prev) => {
|
||||
if (!prev) return null;
|
||||
// Ensure start <= end
|
||||
const s = prev.startDateStr;
|
||||
const h = hoveredDate;
|
||||
return {
|
||||
...prev,
|
||||
endDateStr: h >= s ? h : s,
|
||||
startDateStr: h < s ? h : prev.startDateStr,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (moving) {
|
||||
const col = getColFromX(e.clientX);
|
||||
const row = getRowFromY(e.clientY);
|
||||
movingRef.current = { ...movingRef.current };
|
||||
|
||||
if (col >= 0 && col < DAYS_SHOWN) {
|
||||
const newStartCol = Math.max(0, col - moving.grabDayOffset);
|
||||
const duration = differenceInDays(
|
||||
parseISO(moving.reservation.end_date),
|
||||
parseISO(moving.reservation.start_date)
|
||||
);
|
||||
const clampedStartCol = Math.min(newStartCol, DAYS_SHOWN - 1 - duration);
|
||||
const newStartDate = dateToStr(dates[Math.max(0, clampedStartCol)]);
|
||||
const newEndDate = dateToStr(addDays(dates[Math.max(0, clampedStartCol)], duration));
|
||||
|
||||
let newCarId = moving.currentCarId;
|
||||
if (row >= 0 && row < cars.length) {
|
||||
newCarId = cars[row].id;
|
||||
}
|
||||
|
||||
setMoving((prev) => prev ? {
|
||||
...prev,
|
||||
currentCarId: newCarId,
|
||||
currentStartDate: newStartDate,
|
||||
currentEndDate: newEndDate,
|
||||
col: clampedStartCol,
|
||||
row: row >= 0 && row < cars.length ? row : prev.row,
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
}, [creating, moving, dates, cars, getColFromX, getRowFromY]);
|
||||
|
||||
const handleGridMouseUp = useCallback(async (e) => {
|
||||
if (creating) {
|
||||
const { carId, startDateStr, endDateStr } = creating;
|
||||
setCreating(null);
|
||||
// Open modal to confirm/fill details
|
||||
setModal({
|
||||
mode: 'create',
|
||||
prefill: {
|
||||
car_id: carId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (moving) {
|
||||
const { reservation, currentCarId, currentStartDate, currentEndDate } = moving;
|
||||
setMoving(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
|
||||
if (
|
||||
currentCarId !== reservation.car_id ||
|
||||
currentStartDate !== reservation.start_date
|
||||
) {
|
||||
try {
|
||||
await api.updateReservation(reservation.id, {
|
||||
car_id: currentCarId,
|
||||
start_date: currentStartDate,
|
||||
end_date: newEndDate,
|
||||
customer_name: reservation.customer_name,
|
||||
notes: reservation.notes,
|
||||
});
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(`予約の移動に失敗しました: ${err.message}`);
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [creating, moving, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', handleGridMouseMove);
|
||||
window.addEventListener('mouseup', handleGridMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleGridMouseMove);
|
||||
window.removeEventListener('mouseup', handleGridMouseUp);
|
||||
};
|
||||
}, [handleGridMouseMove, handleGridMouseUp]);
|
||||
|
||||
// Close context menu on any click or Escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const close = () => setContextMenu(null);
|
||||
const onKey = (e) => { if (e.key === 'Escape') close(); };
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
// --- Reservation drag to move ---
|
||||
const handleReservationMouseDown = (e, reservation) => {
|
||||
e.stopPropagation();
|
||||
if (isTouchDevice) return; // drag-to-move is mouse-only
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
const col = getColFromX(e.clientX);
|
||||
const startCol = Math.max(0, dates.findIndex((d) => dateToStr(d) === reservation.start_date));
|
||||
const grabOffset = col >= 0 ? col - startCol : 0;
|
||||
const carRow = cars.findIndex((c) => c.id === reservation.car_id);
|
||||
|
||||
setMoving({
|
||||
reservation,
|
||||
grabDayOffset: Math.max(0, grabOffset),
|
||||
currentCarId: reservation.car_id,
|
||||
currentStartDate: reservation.start_date,
|
||||
currentEndDate: reservation.end_date,
|
||||
col: startCol,
|
||||
row: carRow,
|
||||
});
|
||||
};
|
||||
|
||||
// --- Modal save/delete handlers ---
|
||||
const handleModalSave = async (data) => {
|
||||
try {
|
||||
if (modal.mode === 'edit') {
|
||||
await api.updateReservation(modal.reservation.id, data);
|
||||
} else {
|
||||
await api.createReservation(data);
|
||||
}
|
||||
setModal(null);
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
setError(`予約の保存に失敗しました: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalDelete = async (id) => {
|
||||
try {
|
||||
await api.deleteReservation(id);
|
||||
setModal(null);
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
setError(`予約の削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Rendering ---
|
||||
// Build a map of reservations visible in the date range
|
||||
const viewStartStr = dateToStr(viewStart);
|
||||
const viewEndStr = dateToStr(dates[dates.length - 1]);
|
||||
|
||||
const visibleReservations = reservations.filter(
|
||||
(r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr
|
||||
);
|
||||
|
||||
// For each reservation, calculate its left/width in the grid
|
||||
function getReservationLayout(r) {
|
||||
const rStart = r.start_date < viewStartStr ? viewStartStr : r.start_date;
|
||||
const rEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date;
|
||||
const startCol = differenceInDays(parseISO(rStart), viewStart);
|
||||
const endCol = differenceInDays(parseISO(rEnd), viewStart);
|
||||
const left = startCol * CELL_WIDTH;
|
||||
const width = (endCol - startCol + 1) * CELL_WIDTH;
|
||||
return { left, width, startCol, endCol };
|
||||
}
|
||||
|
||||
// Create ghost for currently moving reservation
|
||||
const movingGhost = moving ? (() => {
|
||||
const duration = differenceInDays(
|
||||
parseISO(moving.reservation.end_date),
|
||||
parseISO(moving.reservation.start_date)
|
||||
);
|
||||
const col = moving.col ?? 0;
|
||||
const left = col * CELL_WIDTH;
|
||||
const width = (duration + 1) * CELL_WIDTH;
|
||||
return { col, left, width, row: moving.row };
|
||||
})() : null;
|
||||
|
||||
// Today column
|
||||
const todayStr = dateToStr(new Date());
|
||||
|
||||
const carColorMap = {};
|
||||
cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); });
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.navGroup}>
|
||||
<button className={styles.toolBtn} onClick={prevWeek}>‹ 前週</button>
|
||||
<button className={styles.toolBtn} onClick={goToday}>今日</button>
|
||||
<button className={styles.toolBtn} onClick={nextWeek}>次週 ›</button>
|
||||
</div>
|
||||
<div className={styles.dateRange}>
|
||||
{format(viewStart, 'yyyy年M月d日', { locale: ja })} 〜{' '}
|
||||
{format(dates[dates.length - 1], 'yyyy年M月d日', { locale: ja })}
|
||||
</div>
|
||||
<button
|
||||
className={styles.addBtn}
|
||||
onClick={() =>
|
||||
setModal({
|
||||
mode: 'create',
|
||||
prefill: {
|
||||
car_id: cars[0]?.id,
|
||||
start_date: todayStr,
|
||||
end_date: todayStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
+ 予約を追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>エラー: {error}</div>}
|
||||
|
||||
{/* Grid */}
|
||||
<div
|
||||
className={styles.gridWrapper}
|
||||
ref={gridRef}
|
||||
onMouseLeave={() => {
|
||||
// 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 }}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
|
||||
{/* Corner cell */}
|
||||
<div
|
||||
className={styles.cornerCell}
|
||||
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
|
||||
/>
|
||||
{/* Date headers */}
|
||||
{dates.map((date) => {
|
||||
const ds = dateToStr(date);
|
||||
const isToday = ds === todayStr;
|
||||
const dow = format(date, 'E', { locale: ja });
|
||||
const isWeekend = dow === '土' || dow === '日';
|
||||
return (
|
||||
<div
|
||||
key={ds}
|
||||
className={`${styles.dateHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
|
||||
style={{ width: CELL_WIDTH, height: HEADER_HEIGHT }}
|
||||
>
|
||||
<span className={styles.dateDay}>{format(date, 'd')}</span>
|
||||
<span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Car rows */}
|
||||
{cars.map((car, carIdx) => {
|
||||
const color = carColorMap[car.id];
|
||||
const carReservations = visibleReservations.filter((r) => {
|
||||
if (moving && r.id === moving.reservation.id) return false; // hide while moving
|
||||
return r.car_id === car.id;
|
||||
});
|
||||
|
||||
// Creating highlight for this row
|
||||
const isCreatingRow = creating && creating.carId === car.id;
|
||||
let creatingLeft = 0, creatingWidth = 0;
|
||||
if (isCreatingRow) {
|
||||
const startCol = differenceInDays(parseISO(creating.startDateStr), viewStart);
|
||||
const endCol = differenceInDays(parseISO(creating.endDateStr), viewStart);
|
||||
creatingLeft = Math.max(0, startCol) * CELL_WIDTH;
|
||||
creatingWidth = (Math.min(endCol, DAYS_SHOWN - 1) - Math.max(0, startCol) + 1) * CELL_WIDTH;
|
||||
}
|
||||
|
||||
// Ghost reservation for this row
|
||||
const showGhost = moving && moving.row === carIdx && movingGhost;
|
||||
|
||||
return (
|
||||
<div key={car.id} className={styles.carRow} style={{ height: ROW_HEIGHT }}>
|
||||
{/* Car label */}
|
||||
<div
|
||||
className={styles.carLabel}
|
||||
style={{ width: LABEL_WIDTH, height: ROW_HEIGHT }}
|
||||
title={car.description || car.name}
|
||||
>
|
||||
<span className={styles.carDot} style={{ background: color.border }} />
|
||||
<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>
|
||||
|
||||
{/* Day cells */}
|
||||
<div
|
||||
className={styles.cellArea}
|
||||
style={{ width: DAYS_SHOWN * CELL_WIDTH, height: ROW_HEIGHT }}
|
||||
onClick={(e) => handleCellClick(e, car.id)}
|
||||
>
|
||||
{dates.map((date) => {
|
||||
const ds = dateToStr(date);
|
||||
const isToday = ds === todayStr;
|
||||
const dow = format(date, 'E', { locale: ja });
|
||||
const isWeekend = dow === '土' || dow === '日';
|
||||
return (
|
||||
<div
|
||||
key={ds}
|
||||
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''}`}
|
||||
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
|
||||
onMouseDown={(e) => handleCellMouseDown(e, car.id, ds)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Creating highlight */}
|
||||
{isCreatingRow && creatingWidth > 0 && (
|
||||
<div
|
||||
className={styles.creatingHighlight}
|
||||
style={{
|
||||
left: creatingLeft,
|
||||
width: creatingWidth,
|
||||
top: 4,
|
||||
height: ROW_HEIGHT - 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ghost while moving */}
|
||||
{showGhost && (
|
||||
<div
|
||||
className={styles.reservationBlock}
|
||||
style={{
|
||||
left: movingGhost.left,
|
||||
width: movingGhost.width,
|
||||
top: 5,
|
||||
height: ROW_HEIGHT - 10,
|
||||
background: color.bg,
|
||||
borderColor: color.border,
|
||||
color: color.text,
|
||||
opacity: 0.6,
|
||||
cursor: 'grabbing',
|
||||
}}
|
||||
>
|
||||
<span className={styles.blockText}>
|
||||
{moving.reservation.customer_name || '予約'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reservation blocks */}
|
||||
{carReservations.map((r) => {
|
||||
const { left, width } = getReservationLayout(r);
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={styles.reservationBlock}
|
||||
style={{
|
||||
left,
|
||||
width: width - 4,
|
||||
top: 5,
|
||||
height: ROW_HEIGHT - 10,
|
||||
background: color.bg,
|
||||
borderColor: color.border,
|
||||
color: color.text,
|
||||
cursor: isTouchDevice ? 'pointer' : 'grab',
|
||||
}}
|
||||
onMouseDown={(e) => handleReservationMouseDown(e, r)}
|
||||
onClick={(e) => {
|
||||
if (!moving) {
|
||||
e.stopPropagation();
|
||||
setModal({ mode: 'edit', reservation: r });
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, reservation: r });
|
||||
}}
|
||||
title={formatReservationTooltip(r)}
|
||||
>
|
||||
<span className={styles.blockText}>
|
||||
{r.customer_name || '予約'}
|
||||
</span>
|
||||
{width > 80 && (
|
||||
<span className={styles.blockDates}>
|
||||
{formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{cars.length === 0 && !loading && (
|
||||
<div className={styles.noCars}>
|
||||
代車が登録されていません。「代車管理」から追加してください。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reservation Modal */}
|
||||
{modal && (
|
||||
<ReservationModal
|
||||
cars={cars}
|
||||
reservation={modal.mode === 'edit' ? modal.reservation : modal.prefill}
|
||||
onSave={handleModalSave}
|
||||
onDelete={handleModalDelete}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right-click context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className={styles.contextMenu}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.contextMenuItem}
|
||||
onClick={() => {
|
||||
setModal({ mode: 'edit', reservation: contextMenu.reservation });
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
✏️ 編集
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.contextMenuItem} ${styles.contextMenuItemDelete}`}
|
||||
onClick={async () => {
|
||||
setContextMenu(null);
|
||||
await handleModalDelete(contextMenu.reservation.id);
|
||||
}}
|
||||
>
|
||||
🗑️ 削除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
383
frontend/src/components/ScheduleView.module.css
Normal file
383
frontend/src/components/ScheduleView.module.css
Normal file
@@ -0,0 +1,383 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 56px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.navGroup {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolBtn {
|
||||
background: #f3f4f6;
|
||||
border: 1.5px solid #d1d5db;
|
||||
color: #374151;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.toolBtn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.dateRange {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 7px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.addBtn:hover {
|
||||
background: #1447c0;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
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;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Sticky header row */
|
||||
.headerRow {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: white;
|
||||
border-bottom: 2px solid #d1d5db;
|
||||
}
|
||||
|
||||
.cornerCell {
|
||||
flex-shrink: 0;
|
||||
background: #f9fafb;
|
||||
border-right: 2px solid #d1d5db;
|
||||
border-bottom: 2px solid #d1d5db;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.dateHeader {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
gap: 2px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.todayHeader {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.weekendHeader {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.dateDay {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dateDow {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weekendDow {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Car rows */
|
||||
.carRow {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carRow:hover .cellArea {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.carLabel {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-right: 2px solid #d1d5db;
|
||||
background: white;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.carDot {
|
||||
width: 10px;
|
||||
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;
|
||||
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;
|
||||
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 */
|
||||
.cellArea {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cell {
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
cursor: crosshair;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.cellTouch {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell:hover {
|
||||
background: rgba(26, 86, 219, 0.04);
|
||||
}
|
||||
|
||||
.todayCell {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.weekendCell {
|
||||
background: rgba(0,0,0,0.015);
|
||||
}
|
||||
|
||||
/* Highlight while dragging to create */
|
||||
.creatingHighlight {
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
background: rgba(26, 86, 219, 0.2);
|
||||
border: 2px dashed #1a56db;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Reservation block */
|
||||
.reservationBlock {
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
z-index: 6;
|
||||
transition: box-shadow 0.1s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reservationBlock:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.blockText {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.blockDates {
|
||||
font-size: 10px;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.noCars {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Right-click context menu */
|
||||
.contextMenu {
|
||||
position: fixed;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
min-width: 140px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.contextMenuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.contextMenuItem:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.contextMenuItemDelete {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.contextMenuItemDelete:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
355
frontend/src/components/TimelineView.jsx
Normal file
355
frontend/src/components/TimelineView.jsx
Normal file
@@ -0,0 +1,355 @@
|
||||
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';
|
||||
|
||||
const ROW_HEIGHT = 48; // px per reservation row
|
||||
const LABEL_WIDTH = 180; // px for reservation info column
|
||||
const HEADER_HEIGHT = 60; // px for date header
|
||||
const DAY_WIDTH = 36; // px per day column
|
||||
const BAR_PADDING = 4; // px gap between bar and row edge
|
||||
|
||||
// Same colour palette as ScheduleView
|
||||
const COLORS = [
|
||||
{ bg: '#dbeafe', border: '#3b82f6', text: '#1e3a8a' },
|
||||
{ bg: '#dcfce7', border: '#22c55e', text: '#14532d' },
|
||||
{ bg: '#fef9c3', border: '#eab308', text: '#713f12' },
|
||||
{ bg: '#fce7f3', border: '#ec4899', text: '#831843' },
|
||||
{ bg: '#ede9fe', border: '#8b5cf6', text: '#3b0764' },
|
||||
{ bg: '#ffedd5', border: '#f97316', text: '#7c2d12' },
|
||||
{ bg: '#e0f2fe', border: '#0ea5e9', text: '#0c4a6e' },
|
||||
{ bg: '#f0fdf4', border: '#16a34a', text: '#14532d' },
|
||||
];
|
||||
|
||||
function getColor(index) {
|
||||
return COLORS[index % COLORS.length];
|
||||
}
|
||||
|
||||
function dateToStr(date) {
|
||||
return format(date, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
export default function TimelineView({ reloadKey = 0 }) {
|
||||
const [cars, setCars] = useState([]);
|
||||
const [reservations, setReservations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [modal, setModal] = useState(null);
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
|
||||
// View window: show the current month by default
|
||||
const [viewStart, setViewStart] = useState(() => startOfMonth(new Date()));
|
||||
const [viewEnd, setViewEnd] = useState(() => endOfMonth(new Date()));
|
||||
|
||||
const gridRef = useRef(null);
|
||||
|
||||
const days = (() => {
|
||||
const result = [];
|
||||
let d = viewStart;
|
||||
while (d <= viewEnd) {
|
||||
result.push(d);
|
||||
d = addDays(d, 1);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
const totalWidth = LABEL_WIDTH + days.length * DAY_WIDTH;
|
||||
const todayStr = dateToStr(new Date());
|
||||
|
||||
// --- Data loading ---
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]);
|
||||
setCars(carsData);
|
||||
setReservations(resData);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData, reloadKey]);
|
||||
|
||||
// Close context menu on click / Escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const close = () => setContextMenu(null);
|
||||
const onKey = (e) => { if (e.key === 'Escape') close(); };
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
// --- Navigation ---
|
||||
const prevMonth = () => {
|
||||
const start = addMonths(viewStart, -1);
|
||||
setViewStart(startOfMonth(start));
|
||||
setViewEnd(endOfMonth(start));
|
||||
};
|
||||
const nextMonth = () => {
|
||||
const start = addMonths(viewStart, 1);
|
||||
setViewStart(startOfMonth(start));
|
||||
setViewEnd(endOfMonth(start));
|
||||
};
|
||||
const goThisMonth = () => {
|
||||
setViewStart(startOfMonth(new Date()));
|
||||
setViewEnd(endOfMonth(new Date()));
|
||||
};
|
||||
|
||||
// --- Handlers ---
|
||||
const handleModalSave = async (data) => {
|
||||
try {
|
||||
if (modal.mode === 'edit') {
|
||||
await api.updateReservation(modal.reservation.id, data);
|
||||
} else {
|
||||
await api.createReservation(data);
|
||||
}
|
||||
setModal(null);
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
setError(`予約の保存に失敗しました: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalDelete = async (id) => {
|
||||
try {
|
||||
await api.deleteReservation(id);
|
||||
setModal(null);
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
setError(`予約の削除に失敗しました: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Build car colour map
|
||||
const carColorMap = {};
|
||||
cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); });
|
||||
|
||||
// Sort reservations by start_date then car
|
||||
const sortedReservations = [...reservations].sort((a, b) => {
|
||||
if (a.start_date !== b.start_date) return a.start_date < b.start_date ? -1 : 1;
|
||||
return a.car_id - b.car_id;
|
||||
});
|
||||
|
||||
const viewStartStr = dateToStr(viewStart);
|
||||
const viewEndStr = dateToStr(viewEnd);
|
||||
|
||||
// Filter to reservations that overlap the view window
|
||||
const visibleReservations = sortedReservations.filter(
|
||||
(r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr
|
||||
);
|
||||
|
||||
function getBarLayout(r) {
|
||||
const clampedStart = r.start_date < viewStartStr ? viewStartStr : r.start_date;
|
||||
const clampedEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date;
|
||||
const startOffset = differenceInDays(parseISO(clampedStart), viewStart);
|
||||
const endOffset = differenceInDays(parseISO(clampedEnd), viewStart);
|
||||
const left = startOffset * DAY_WIDTH;
|
||||
const width = (endOffset - startOffset + 1) * DAY_WIDTH;
|
||||
return { left, width };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.navGroup}>
|
||||
<button className={styles.toolBtn} onClick={prevMonth}>‹ 前月</button>
|
||||
<button className={styles.toolBtn} onClick={goThisMonth}>今月</button>
|
||||
<button className={styles.toolBtn} onClick={nextMonth}>次月 ›</button>
|
||||
</div>
|
||||
<div className={styles.monthLabel}>
|
||||
{format(viewStart, 'yyyy年M月', { locale: ja })}
|
||||
</div>
|
||||
<button
|
||||
className={styles.addBtn}
|
||||
disabled={cars.length === 0}
|
||||
onClick={() =>
|
||||
setModal({
|
||||
mode: 'create',
|
||||
prefill: {
|
||||
car_id: cars[0]?.id,
|
||||
start_date: todayStr,
|
||||
end_date: todayStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
+ 予約を追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>エラー: {error}</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 }}>
|
||||
{/* Corner */}
|
||||
<div
|
||||
className={styles.cornerCell}
|
||||
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
|
||||
>
|
||||
<span className={styles.cornerText}>予約一覧</span>
|
||||
</div>
|
||||
{/* Day columns */}
|
||||
{days.map((date) => {
|
||||
const ds = dateToStr(date);
|
||||
const isToday = ds === todayStr;
|
||||
const dow = format(date, 'E', { locale: ja });
|
||||
const isWeekend = dow === '土' || dow === '日';
|
||||
const isSun = dow === '日';
|
||||
const isSat = dow === '土';
|
||||
return (
|
||||
<div
|
||||
key={ds}
|
||||
className={`${styles.dayHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
|
||||
style={{ width: DAY_WIDTH, height: HEADER_HEIGHT }}
|
||||
>
|
||||
<span className={styles.dayNum}>{format(date, 'd')}</span>
|
||||
<span className={`${styles.dayDow} ${isSun ? styles.sunDow : ''} ${isSat ? styles.satDow : ''}`}>{dow}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reservation rows */}
|
||||
{visibleReservations.map((r) => {
|
||||
const car = cars.find((c) => c.id === r.car_id);
|
||||
const color = carColorMap[r.car_id] || COLORS[0];
|
||||
const { left, width } = getBarLayout(r);
|
||||
|
||||
return (
|
||||
<div key={r.id} className={styles.resRow} style={{ height: ROW_HEIGHT }}>
|
||||
{/* Label: car + customer */}
|
||||
<div
|
||||
className={styles.resLabel}
|
||||
style={{ width: LABEL_WIDTH, height: ROW_HEIGHT }}
|
||||
>
|
||||
<span className={styles.carDot} style={{ background: color.border }} />
|
||||
<div className={styles.labelText}>
|
||||
<span className={styles.labelCar}>{car?.name ?? '—'}</span>
|
||||
<span className={styles.labelCustomer}>{r.customer_name || '(名前なし)'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day cells (background) */}
|
||||
<div
|
||||
className={styles.cellArea}
|
||||
style={{ width: days.length * DAY_WIDTH, height: ROW_HEIGHT, position: 'relative' }}
|
||||
>
|
||||
{days.map((date) => {
|
||||
const ds = dateToStr(date);
|
||||
const isToday = ds === todayStr;
|
||||
const dow = format(date, 'E', { locale: ja });
|
||||
const isWeekend = dow === '土' || dow === '日';
|
||||
return (
|
||||
<div
|
||||
key={ds}
|
||||
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''}`}
|
||||
style={{ width: DAY_WIDTH, height: ROW_HEIGHT }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bar */}
|
||||
<div
|
||||
className={styles.bar}
|
||||
style={{
|
||||
left,
|
||||
width: width - BAR_PADDING,
|
||||
background: color.bg,
|
||||
borderColor: color.border,
|
||||
color: color.text,
|
||||
}}
|
||||
onClick={() => setModal({ mode: 'edit', reservation: r })}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, reservation: r });
|
||||
}}
|
||||
title={formatReservationTooltip(r)}
|
||||
>
|
||||
<span className={styles.barText}>
|
||||
{r.customer_name || '予約'}
|
||||
</span>
|
||||
{width > 80 && (
|
||||
<span className={styles.barDates}>
|
||||
{formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{visibleReservations.length === 0 && !loading && (
|
||||
<div className={styles.empty}>
|
||||
この月には予約がありません。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reservation Modal */}
|
||||
{modal && (
|
||||
<ReservationModal
|
||||
cars={cars}
|
||||
reservation={modal.mode === 'edit' ? modal.reservation : modal.prefill}
|
||||
onSave={handleModalSave}
|
||||
onDelete={handleModalDelete}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right-click context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className={styles.contextMenu}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.contextMenuItem}
|
||||
onClick={() => {
|
||||
setModal({ mode: 'edit', reservation: contextMenu.reservation });
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
✏️ 編集
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.contextMenuItem} ${styles.contextMenuItemDelete}`}
|
||||
onClick={async () => {
|
||||
setContextMenu(null);
|
||||
await handleModalDelete(contextMenu.reservation.id);
|
||||
}}
|
||||
>
|
||||
🗑️ 削除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
341
frontend/src/components/TimelineView.module.css
Normal file
341
frontend/src/components/TimelineView.module.css
Normal file
@@ -0,0 +1,341 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 56px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.navGroup {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolBtn {
|
||||
background: #f3f4f6;
|
||||
border: 1.5px solid #d1d5db;
|
||||
color: #374151;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolBtn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.monthLabel {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 7px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addBtn:hover {
|
||||
background: #1447c0;
|
||||
}
|
||||
|
||||
.addBtn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
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;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Header row */
|
||||
.headerRow {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: white;
|
||||
border-bottom: 2px solid #d1d5db;
|
||||
}
|
||||
|
||||
.cornerCell {
|
||||
flex-shrink: 0;
|
||||
background: #f9fafb;
|
||||
border-right: 2px solid #d1d5db;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cornerText {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dayHeader {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
gap: 2px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.todayHeader {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.weekendHeader {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.dayNum {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dayDow {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sunDow {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.satDow {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Reservation rows */
|
||||
.resRow {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.resRow:hover .cellArea {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.resLabel {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-right: 2px solid #d1d5db;
|
||||
background: white;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.carDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.labelText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.labelCar {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labelCustomer {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Cell area */
|
||||
.cellArea {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cell {
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.todayCell {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.weekendCell {
|
||||
background: rgba(0,0,0,0.015);
|
||||
}
|
||||
|
||||
/* Bar */
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
height: calc(100% - 12px);
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.1s;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.barText {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.barDates {
|
||||
font-size: 10px;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Right-click context menu */
|
||||
.contextMenu {
|
||||
position: fixed;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
min-width: 140px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.contextMenuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.contextMenuItem:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.contextMenuItemDelete {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.contextMenuItemDelete:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
27
frontend/src/index.css
Normal file
27
frontend/src/index.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/* Global styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
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 : ''}`;
|
||||
}
|
||||
60
frontend/vite.config.js
Normal file
60
frontend/vite.config.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { defineConfig } from 'vite';
|
||||
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({
|
||||
plugins: [react(), wsProxyPlugin()],
|
||||
server: {
|
||||
port: 5173,
|
||||
allowedHosts: ["car.33-4.party"],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendOrigin,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
329
package-lock.json
generated
Normal file
329
package-lock.json
generated
Normal file
@@ -0,0 +1,329 @@
|
||||
{
|
||||
"name": "car-reservation",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "car-reservation",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "car-reservation",
|
||||
"version": "1.0.0",
|
||||
"description": "代車スケジュール管理システム",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user