19 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ad96d38863 feat: split schedule view horizontal axis into AM/PM half-day slots
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/b0f3486c-edf5-4c08-b1e8-161b380aa0a0

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
2026-04-06 08:09:33 +00:00
h
b80c15e186 Merge pull request #7 from pdf114514/copilot/fix-morning-afternoon-specification
Fix AM/PM period loss on reservation drag-move; add drag & drop car reordering
2026-04-06 16:52:28 +09:00
copilot-swe-agent[bot]
7a04012b60 refactor: extract shared applyReorder helper in CarManagement
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/c5f8f1a2-8a8b-4951-8442-76ce37d906ae

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

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

Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
2026-04-06 07:12:49 +00:00
h
2e9e100178 Commit 2026-04-06 15:36:51 +09:00
h
1081ea1074 Merge pull request #5 from pdf114514/copilot/update-vehicle-indicator-display
Updating vehicle indicator and editing interface
2026-03-22 13:26:06 +09:00
copilot-swe-agent[bot]
761c7f1971 Changes before error encountered
Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/a42d4e36-a3cf-4ff7-b1cb-f076e601b1b8
2026-03-22 04:25:00 +00:00
h
0bd5efde2c Merge pull request #4 from pdf114514/copilot/add-field-and-sync-features
Implementing vehicle details and real-time editing synchronization
2026-03-22 13:00:47 +09:00
copilot-swe-agent[bot]
8e9db971d3 feat: add inspection_expiry/has_etc/tire_type fields, icons in schedule view, and WebSocket real-time sync
Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/6d0f25ae-6db4-4937-ae2b-6674456a5ca1
2026-03-22 03:58:07 +00:00
h
09872737b7 Merge pull request #3 from pdf114514/copilot/add-popup-for-edit-delete-reservations
Add timeline view, right-click context menu on reservations, fix trust proxy
2026-03-21 03:52:02 +09:00
copilot-swe-agent[bot]
cc3ad148fc Add timeline view, right-click context menu, and fix Express trust proxy
- backend/server.js: Add app.set('trust proxy', 1) to fix express-rate-limit
  ValidationError when app runs behind nginx reverse proxy
- ScheduleView.jsx: Add right-click context menu on reservation blocks with
  Edit and Delete options; closes on click-outside or Escape
- ScheduleView.module.css: Add context menu styles
- TimelineView.jsx: New Gantt-style monthly timeline view showing all
  reservations sorted by date, with month navigation and right-click menu
- TimelineView.module.css: Styles for the timeline view
- App.jsx: Add 'タイムライン' tab to navigation

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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.db
*.db-journal
.env
.DS_Store

View File

@@ -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

File diff suppressed because it is too large Load Diff

22
backend/package.json Normal file
View 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
View 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
View File

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

12
frontend/index.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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' }),
};

View File

@@ -0,0 +1,369 @@
import { useState, useEffect, useCallback, useRef } 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 [dragOverIdx, setDragOverIdx] = useState(null);
const dragSrcIdx = useRef(null);
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 applyReorder = async (newCars) => {
setCars(newCars);
try {
await api.reorderCars(newCars.map((c) => c.id));
} catch (e) {
setError(e.message);
await loadCars();
}
};
const handleReorder = async (index, direction) => {
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= cars.length) return;
const newCars = [...cars];
[newCars[index], newCars[swapIndex]] = [newCars[swapIndex], newCars[index]];
await applyReorder(newCars);
};
const handleDragStart = (index) => {
dragSrcIdx.current = index;
};
const handleDragOver = (e, index) => {
e.preventDefault();
setDragOverIdx(index);
};
const handleDragEnd = () => {
setDragOverIdx(null);
dragSrcIdx.current = null;
};
const handleDrop = async (e, dropIndex) => {
e.preventDefault();
const srcIndex = dragSrcIdx.current;
setDragOverIdx(null);
dragSrcIdx.current = null;
if (srcIndex === null || srcIndex === dropIndex) return;
const newCars = [...cars];
const [moved] = newCars.splice(srcIndex, 1);
newCars.splice(dropIndex, 0, moved);
await applyReorder(newCars);
};
return (
<div className={styles.container}>
<h2 className={styles.heading}>代車管理</h2>
<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}
draggable
onDragStart={() => handleDragStart(carIdx)}
onDragOver={(e) => handleDragOver(e, carIdx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, carIdx)}
className={dragOverIdx === carIdx ? styles.dragOver : ''}
>
<td className={styles.idCell}>
<div className={styles.orderBtns}>
<button
className={styles.btnOrder}
onClick={() => handleReorder(carIdx, -1)}
disabled={carIdx === 0}
title="上に移動"
aria-label="上に移動"
></button>
<button
className={styles.btnOrder}
onClick={() => handleReorder(carIdx, 1)}
disabled={carIdx === cars.length - 1}
title="下に移動"
aria-label="下に移動"
></button>
</div>
</td>
{editingId === car.id ? (
<>
<td>
<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>
);
}

View File

@@ -0,0 +1,279 @@
.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;
}
.table tbody tr[draggable] {
cursor: grab;
}
.dragOver {
background: #eff6ff !important;
outline: 2px dashed #1a56db;
outline-offset: -2px;
}
.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;
}

View 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>
);
}

View 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;
}

View File

@@ -0,0 +1,642 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { format, addDays, startOfWeek, parseISO, differenceInDays } 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 half-day slot (午前 or 午後)
const ROW_HEIGHT = 64; // px per car row
const LABEL_WIDTH = 140; // px for car name column
const HEADER_HEIGHT = 80; // px for the date header row (top: date+dow, bottom: 午前/午後)
const DAYS_SHOWN = 21; // number of days to show
const HALF_SLOTS = DAYS_SHOWN * 2; // total half-day slot columns (AM + PM per day)
// Detect touch-primary device to disable mouse-only drag & drop
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 half-day slot index (0-based)
// Each slot is CELL_WIDTH wide; even slots = 午前, odd slots = 午後
const getSlotFromX = 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.min(Math.floor(x / CELL_WIDTH), HALF_SLOTS - 1);
}, []);
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, slot) => {
if (isTouchDevice) return; // drag-to-create is mouse-only
if (e.button !== 0) return;
e.preventDefault();
setCreating({ carId, startSlot: slot, endSlot: slot });
};
// --- Cell tap to create (touch devices) ---
const handleCellClick = useCallback((e, carId) => {
if (!isTouchDevice) return;
const slot = getSlotFromX(e.clientX);
if (slot >= 0 && slot < HALF_SLOTS) {
const dayIdx = Math.floor(slot / 2);
const dateStr = dateToStr(dates[dayIdx]);
const period = slot % 2 === 0 ? '午前' : '午後';
setModal({
mode: 'create',
prefill: { car_id: carId, start_date: dateStr, start_period: period, end_date: dateStr, end_period: period },
});
}
}, [dates, getSlotFromX]);
const handleGridMouseMove = useCallback((e) => {
if (creating) {
const slot = getSlotFromX(e.clientX);
if (slot >= 0 && slot < HALF_SLOTS) {
setCreating((prev) => {
if (!prev) return null;
const s = prev.startSlot;
const h = slot;
return {
...prev,
endSlot: h >= s ? h : s,
startSlot: h < s ? h : prev.startSlot,
};
});
}
}
if (moving) {
const slot = getSlotFromX(e.clientX);
const row = getRowFromY(e.clientY);
movingRef.current = { ...movingRef.current };
if (slot >= 0 && slot < HALF_SLOTS) {
const durationSlots = moving.durationSlots;
const newStartSlot = Math.max(0, slot - moving.grabSlotOffset);
const clampedStartSlot = Math.min(newStartSlot, HALF_SLOTS - 1 - durationSlots);
const clampedEndSlot = clampedStartSlot + durationSlots;
const newStartDayIdx = Math.max(0, Math.min(Math.floor(clampedStartSlot / 2), DAYS_SHOWN - 1));
const newEndDayIdx = Math.max(0, Math.min(Math.floor(clampedEndSlot / 2), DAYS_SHOWN - 1));
const newStartDate = dateToStr(dates[newStartDayIdx]);
const newStartPeriod = clampedStartSlot % 2 === 0 ? '午前' : '午後';
const newEndDate = dateToStr(dates[newEndDayIdx]);
const newEndPeriod = clampedEndSlot % 2 === 0 ? '午前' : '午後';
let newCarId = moving.currentCarId;
if (row >= 0 && row < cars.length) {
newCarId = cars[row].id;
}
setMoving((prev) => prev ? {
...prev,
currentCarId: newCarId,
currentStartSlot: clampedStartSlot,
currentStartDate: newStartDate,
currentStartPeriod: newStartPeriod,
currentEndDate: newEndDate,
currentEndPeriod: newEndPeriod,
col: clampedStartSlot,
row: row >= 0 && row < cars.length ? row : prev.row,
} : null);
}
}
}, [creating, moving, dates, cars, getSlotFromX, getRowFromY]);
const handleGridMouseUp = useCallback(async (e) => {
if (creating) {
const { carId, startSlot, endSlot } = creating;
setCreating(null);
const startDayIdx = Math.max(0, Math.min(Math.floor(startSlot / 2), DAYS_SHOWN - 1));
const endDayIdx = Math.max(0, Math.min(Math.floor(endSlot / 2), DAYS_SHOWN - 1));
// Open modal to confirm/fill details
setModal({
mode: 'create',
prefill: {
car_id: carId,
start_date: dateToStr(dates[startDayIdx]),
start_period: startSlot % 2 === 0 ? '午前' : '午後',
end_date: dateToStr(dates[endDayIdx]),
end_period: endSlot % 2 === 0 ? '午前' : '午後',
},
});
}
if (moving) {
const { reservation, currentCarId, origStartSlot, currentStartSlot, currentStartDate, currentStartPeriod, currentEndDate, currentEndPeriod } = moving;
setMoving(null);
movingRef.current = null;
// Only update if something changed
if (
currentCarId !== reservation.car_id ||
currentStartSlot !== origStartSlot
) {
try {
await api.updateReservation(reservation.id, {
car_id: currentCarId,
start_date: currentStartDate,
end_date: currentEndDate,
customer_name: reservation.customer_name,
notes: reservation.notes,
start_period: currentStartPeriod,
end_period: currentEndPeriod,
});
await loadData();
} catch (err) {
setError(`予約の移動に失敗しました: ${err.message}`);
await loadData();
}
}
}
}, [creating, moving, dates, 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 slot = getSlotFromX(e.clientX);
// Compute the reservation's start/end slot within the current view
const startDayCol = Math.max(0, differenceInDays(parseISO(reservation.start_date), viewStart));
const endDayCol = Math.max(0, differenceInDays(parseISO(reservation.end_date), viewStart));
const startSlot = startDayCol * 2 + (reservation.start_period === '午後' ? 1 : 0);
const endSlot = endDayCol * 2 + (reservation.end_period === '午前' ? 0 : 1);
const durationSlots = Math.max(0, endSlot - startSlot);
const grabOffset = slot >= 0 ? slot - startSlot : 0;
const carRow = cars.findIndex((c) => c.id === reservation.car_id);
setMoving({
reservation,
grabSlotOffset: Math.max(0, grabOffset),
durationSlots,
origStartSlot: startSlot,
currentCarId: reservation.car_id,
currentStartSlot: startSlot,
currentStartDate: reservation.start_date,
currentStartPeriod: reservation.start_period || '午前',
currentEndDate: reservation.end_date,
currentEndPeriod: reservation.end_period || '午後',
col: startSlot,
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 (accounting for 午前/午後 periods)
function getReservationLayout(r) {
const rStart = r.start_date < viewStartStr ? viewStartStr : r.start_date;
const rEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date;
const startDayCol = differenceInDays(parseISO(rStart), viewStart);
const endDayCol = differenceInDays(parseISO(rEnd), viewStart);
// Even slot = 午前, odd slot = 午後; empty period treated as 午前 for start, 午後 for end
const startSlot = startDayCol * 2 + (r.start_period === '午後' ? 1 : 0);
const endSlot = endDayCol * 2 + (r.end_period === '午前' ? 0 : 1);
const left = startSlot * CELL_WIDTH;
const width = (endSlot - startSlot + 1) * CELL_WIDTH;
return { left, width, startSlot, endSlot };
}
// Create ghost for currently moving reservation
const movingGhost = moving ? (() => {
const col = moving.col ?? 0;
const durationSlots = moving.durationSlots;
const left = col * CELL_WIDTH;
const width = (durationSlots + 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 * 2 * 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 — each day spans two half-day slots (午前 + 午後) */}
{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.dateHeaderGroup} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
style={{ width: CELL_WIDTH * 2, height: HEADER_HEIGHT }}
>
<div className={styles.dateHeaderTop}>
<span className={styles.dateDay}>{format(date, 'd')}</span>
<span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span>
</div>
<div className={styles.dateHeaderAmPm}>
<div className={styles.periodLabel}>午前</div>
<div className={styles.periodLabel}>午後</div>
</div>
</div>
);
})}
</div>
{/* 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) {
creatingLeft = Math.max(0, creating.startSlot) * CELL_WIDTH;
creatingWidth = (Math.min(creating.endSlot, HALF_SLOTS - 1) - Math.max(0, creating.startSlot) + 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 — two per day (午前 then 午後) */}
<div
className={styles.cellArea}
style={{ width: DAYS_SHOWN * 2 * CELL_WIDTH, height: ROW_HEIGHT }}
onClick={(e) => handleCellClick(e, car.id)}
>
{dates.flatMap((date, dayIdx) => {
const ds = dateToStr(date);
const isToday = ds === todayStr;
const dow = format(date, 'E', { locale: ja });
const isWeekend = dow === '土' || dow === '日';
return ['午前', '午後'].map((period, pIdx) => (
<div
key={`${ds}-${period}`}
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''} ${isTouchDevice ? styles.cellTouch : ''} ${pIdx === 1 ? styles.cellDayEnd : ''}`}
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
onMouseDown={(e) => handleCellMouseDown(e, car.id, dayIdx * 2 + pIdx)}
/>
));
})}
{/* 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>
);
}

View File

@@ -0,0 +1,428 @@
.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;
}
.dateHeaderGroup {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
border-right: 2px solid #d1d5db;
background: white;
overflow: hidden;
}
.dateHeaderTop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
flex: 1;
border-bottom: 1px solid #e5e7eb;
}
.dateHeaderAmPm {
display: flex;
height: 22px;
flex-shrink: 0;
}
.periodLabel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 500;
color: #9ca3af;
letter-spacing: 0.03em;
}
.periodLabel:first-child {
border-right: 1px solid #f0f0f0;
}
.dateHeader {
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);
}
.cellDayEnd {
border-right: 1px solid #d1d5db;
}
.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;
}

View 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>
);
}

View 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;
}

View 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
View 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
View 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>
);

View 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
View 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
View 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
View 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"
}
}