Merge pull request #1 from pdf114514/copilot/create-car-rental-schedule-system

Add car rental schedule management system (React+Vite + Express+SQLite)
This commit is contained in:
h
2026-03-21 03:07:15 +09:00
committed by GitHub
22 changed files with 4500 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,65 @@
# 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.
### 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 |

1314
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
backend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"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"
}
}

140
backend/server.js Normal file
View File

@@ -0,0 +1,140 @@
const express = require('express');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const Database = require('better-sqlite3');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// 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
);
`);
// Seed some initial cars if none exist
const carCount = db.prepare('SELECT COUNT(*) as cnt FROM cars').get();
if (carCount.cnt === 0) {
const insertCar = db.prepare('INSERT INTO cars (name, description) VALUES (?, ?)');
insertCar.run('代車 A', '');
insertCar.run('代車 B', '');
insertCar.run('代車 C', '');
}
// --- Cars API ---
app.get('/api/cars', (req, res) => {
const cars = db.prepare('SELECT * FROM cars ORDER BY id').all();
res.json(cars);
});
app.post('/api/cars', (req, res) => {
const { name, description = '' } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: '車名は必須です' });
}
const result = db.prepare('INSERT INTO cars (name, description) VALUES (?, ?)').run(name.trim(), description);
const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(car);
});
app.put('/api/cars/:id', (req, res) => {
const { name, description } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: '車名は必須です' });
}
const result = db.prepare('UPDATE cars SET name = ?, description = ? WHERE id = ?').run(name.trim(), description ?? '', req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: '車が見つかりません' });
}
const car = db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id);
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: '車が見つかりません' });
}
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 = '' } = 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) VALUES (?, ?, ?, ?, ?)'
).run(car_id, start_date, end_date, customer_name, notes);
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(reservation);
});
app.put('/api/reservations/:id', (req, res) => {
const { car_id, start_date, end_date, customer_name, notes } = 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 = ? WHERE id = ?'
).run(car_id, start_date, end_date, customer_name ?? '', notes ?? '', 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);
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: '予約が見つかりません' });
}
res.json({ success: true });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

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>

895
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,895 @@
{
"name": "frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.0.0",
"license": "ISC",
"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"
}
}

33
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import ScheduleView from './components/ScheduleView.jsx';
import CarManagement from './components/CarManagement.jsx';
import styles from './App.module.css';
export default function App() {
const [page, setPage] = useState('schedule');
return (
<div className={styles.app}>
<header className={styles.header}>
<h1 className={styles.title}>🚗 代車スケジュール管理</h1>
<nav className={styles.nav}>
<button
className={`${styles.navBtn} ${page === 'schedule' ? styles.active : ''}`}
onClick={() => setPage('schedule')}
>
📅 スケジュール
</button>
<button
className={`${styles.navBtn} ${page === 'cars' ? styles.active : ''}`}
onClick={() => setPage('cars')}
>
🚙 代車管理
</button>
</nav>
</header>
<main className={styles.main}>
{page === 'schedule' ? <ScheduleView /> : <CarManagement />}
</main>
</div>
);
}

View File

@@ -0,0 +1,56 @@
.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;
}
.title {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.5px;
}
.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;
}

27
frontend/src/api.js Normal file
View File

@@ -0,0 +1,27 @@
const API_BASE = '/api';
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...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' }),
// 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,191 @@
import { useState, useEffect } from 'react';
import { api } from '../api.js';
import styles from './CarManagement.module.css';
export default function CarManagement() {
const [cars, setCars] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [newCarName, setNewCarName] = useState('');
const [newCarDesc, setNewCarDesc] = useState('');
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const [submitting, setSubmitting] = useState(false);
const loadCars = async () => {
try {
setLoading(true);
const data = await api.getCars();
setCars(data);
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCars();
}, []);
const handleAdd = async (e) => {
e.preventDefault();
if (!newCarName.trim()) return;
try {
setSubmitting(true);
await api.createCar({ name: newCarName.trim(), description: newCarDesc.trim() });
setNewCarName('');
setNewCarDesc('');
await loadCars();
} catch (e) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
const handleDelete = async (id, name) => {
if (!confirm(`${name}」を削除しますか?\n関連する予約もすべて削除されます。`)) 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 || '');
};
const cancelEdit = () => {
setEditingId(null);
setEditName('');
setEditDesc('');
};
const handleUpdate = async (id) => {
if (!editName.trim()) return;
try {
setSubmitting(true);
await api.updateCar(id, { name: editName.trim(), description: editDesc.trim() });
cancelEdit();
await loadCars();
} catch (e) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
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)}
/>
<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>ID</th>
<th>車名</th>
<th>備考</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{cars.length === 0 && (
<tr>
<td colSpan={4} className={styles.empty}>代車がありません</td>
</tr>
)}
{cars.map((car) => (
<tr key={car.id}>
<td className={styles.idCell}>{car.id}</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 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.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,201 @@
.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: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
background: #f3f4f6;
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 1.5px solid #e5e7eb;
}
.table td {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: #fafafa;
}
.idCell {
color: #9ca3af;
width: 50px;
}
.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;
}

View File

@@ -0,0 +1,152 @@
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import styles from './ReservationModal.module.css';
export default function ReservationModal({ cars, reservation, onSave, onDelete, onClose }) {
const isEdit = !!reservation?.id;
const [carId, setCarId] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = 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'));
setEndDate(reservation.end_date || format(new Date(), 'yyyy-MM-dd'));
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,
end_date: endDate,
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}>終了日 <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>
<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,529 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { format, addDays, startOfWeek, parseISO, differenceInDays, isSameDay } from 'date-fns';
import { ja } from 'date-fns/locale';
import { api } from '../api.js';
import ReservationModal from './ReservationModal.jsx';
import styles from './ScheduleView.module.css';
const CELL_WIDTH = 52; // px per day column
const ROW_HEIGHT = 52; // px per car row
const LABEL_WIDTH = 140; // px for car name column
const HEADER_HEIGHT = 72; // px for the date header row
const DAYS_SHOWN = 21; // number of days to show
// 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() {
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: {...} }
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]);
// --- Navigation ---
const prevWeek = () => setViewStart((d) => addDays(d, -7));
const nextWeek = () => setViewStart((d) => addDays(d, 7));
const goToday = () => setViewStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
// --- Grid position helpers ---
// Given a mouse clientX within the grid scroll area, get the day index (0-based)
const getColFromX = useCallback((clientX) => {
if (!gridRef.current) return -1;
const rect = gridRef.current.getBoundingClientRect();
const scrollLeft = gridRef.current.scrollLeft;
const x = clientX - rect.left + scrollLeft - LABEL_WIDTH;
if (x < 0) return -1;
return Math.floor(x / CELL_WIDTH);
}, []);
const getRowFromY = useCallback((clientY) => {
if (!gridRef.current) return -1;
const rect = gridRef.current.getBoundingClientRect();
const scrollTop = gridRef.current.scrollTop;
const y = clientY - rect.top + scrollTop - HEADER_HEIGHT;
if (y < 0) return -1;
return Math.floor(y / ROW_HEIGHT);
}, []);
// --- Cell drag to create ---
const handleCellMouseDown = (e, carId, dateStr) => {
if (e.button !== 0) return;
e.preventDefault();
setCreating({ carId, startDateStr: dateStr, endDateStr: dateStr });
};
const handleGridMouseMove = useCallback((e) => {
if (creating) {
const col = getColFromX(e.clientX);
if (col >= 0 && col < DAYS_SHOWN) {
const hoveredDate = dateToStr(dates[col]);
setCreating((prev) => {
if (!prev) return null;
// Ensure start <= end
const s = prev.startDateStr;
const h = hoveredDate;
return {
...prev,
endDateStr: h >= s ? h : s,
startDateStr: h < s ? h : prev.startDateStr,
};
});
}
}
if (moving) {
const col = getColFromX(e.clientX);
const row = getRowFromY(e.clientY);
movingRef.current = { ...movingRef.current };
if (col >= 0 && col < DAYS_SHOWN) {
const newStartCol = Math.max(0, col - moving.grabDayOffset);
const duration = differenceInDays(
parseISO(moving.reservation.end_date),
parseISO(moving.reservation.start_date)
);
const clampedStartCol = Math.min(newStartCol, DAYS_SHOWN - 1 - duration);
const newStartDate = dateToStr(dates[Math.max(0, clampedStartCol)]);
const newEndDate = dateToStr(addDays(dates[Math.max(0, clampedStartCol)], duration));
let newCarId = moving.currentCarId;
if (row >= 0 && row < cars.length) {
newCarId = cars[row].id;
}
setMoving((prev) => prev ? {
...prev,
currentCarId: newCarId,
currentStartDate: newStartDate,
currentEndDate: newEndDate,
col: clampedStartCol,
row: row >= 0 && row < cars.length ? row : prev.row,
} : null);
}
}
}, [creating, moving, dates, cars, getColFromX, getRowFromY]);
const handleGridMouseUp = useCallback(async (e) => {
if (creating) {
const { carId, startDateStr, endDateStr } = creating;
setCreating(null);
// Open modal to confirm/fill details
setModal({
mode: 'create',
prefill: {
car_id: carId,
start_date: startDateStr,
end_date: endDateStr,
},
});
}
if (moving) {
const { reservation, currentCarId, currentStartDate, currentEndDate } = moving;
setMoving(null);
movingRef.current = null;
const duration = differenceInDays(
parseISO(reservation.end_date),
parseISO(reservation.start_date)
);
const newEndDate = currentEndDate ||
dateToStr(addDays(parseISO(currentStartDate), duration));
// Only update if something changed
if (
currentCarId !== reservation.car_id ||
currentStartDate !== reservation.start_date
) {
try {
await api.updateReservation(reservation.id, {
car_id: currentCarId,
start_date: currentStartDate,
end_date: newEndDate,
customer_name: reservation.customer_name,
notes: reservation.notes,
});
await loadData();
} catch (err) {
setError(`予約の移動に失敗しました: ${err.message}`);
await loadData();
}
}
}
}, [creating, moving, loadData]);
useEffect(() => {
window.addEventListener('mousemove', handleGridMouseMove);
window.addEventListener('mouseup', handleGridMouseUp);
return () => {
window.removeEventListener('mousemove', handleGridMouseMove);
window.removeEventListener('mouseup', handleGridMouseUp);
};
}, [handleGridMouseMove, handleGridMouseUp]);
// --- Reservation drag to move ---
const handleReservationMouseDown = (e, reservation) => {
e.stopPropagation();
if (e.button !== 0) return;
e.preventDefault();
const col = getColFromX(e.clientX);
const startCol = Math.max(0, dates.findIndex((d) => dateToStr(d) === reservation.start_date));
const grabOffset = col >= 0 ? col - startCol : 0;
const carRow = cars.findIndex((c) => c.id === reservation.car_id);
setMoving({
reservation,
grabDayOffset: Math.max(0, grabOffset),
currentCarId: reservation.car_id,
currentStartDate: reservation.start_date,
currentEndDate: reservation.end_date,
col: startCol,
row: carRow,
});
};
// --- Modal save/delete handlers ---
const handleModalSave = async (data) => {
try {
if (modal.mode === 'edit') {
await api.updateReservation(modal.reservation.id, data);
} else {
await api.createReservation(data);
}
setModal(null);
await loadData();
} catch (e) {
setError(`予約の保存に失敗しました: ${e.message}`);
}
};
const handleModalDelete = async (id) => {
try {
await api.deleteReservation(id);
setModal(null);
await loadData();
} catch (e) {
setError(`予約の削除に失敗しました: ${e.message}`);
}
};
// --- Rendering ---
// Build a map of reservations visible in the date range
const viewStartStr = dateToStr(viewStart);
const viewEndStr = dateToStr(dates[dates.length - 1]);
const visibleReservations = reservations.filter(
(r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr
);
// For each reservation, calculate its left/width in the grid
function getReservationLayout(r) {
const rStart = r.start_date < viewStartStr ? viewStartStr : r.start_date;
const rEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date;
const startCol = differenceInDays(parseISO(rStart), viewStart);
const endCol = differenceInDays(parseISO(rEnd), viewStart);
const left = startCol * CELL_WIDTH;
const width = (endCol - startCol + 1) * CELL_WIDTH;
return { left, width, startCol, endCol };
}
// Create ghost for currently moving reservation
const movingGhost = moving ? (() => {
const duration = differenceInDays(
parseISO(moving.reservation.end_date),
parseISO(moving.reservation.start_date)
);
const col = moving.col ?? 0;
const left = col * CELL_WIDTH;
const width = (duration + 1) * CELL_WIDTH;
return { col, left, width, row: moving.row };
})() : null;
// Today column
const todayStr = dateToStr(new Date());
const carColorMap = {};
cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); });
return (
<div className={styles.container}>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.navGroup}>
<button className={styles.toolBtn} onClick={prevWeek}> 前週</button>
<button className={styles.toolBtn} onClick={goToday}>今日</button>
<button className={styles.toolBtn} onClick={nextWeek}>次週 </button>
</div>
<div className={styles.dateRange}>
{format(viewStart, 'yyyy年M月d日', { locale: ja })} {' '}
{format(dates[dates.length - 1], 'yyyy年M月d日', { locale: ja })}
</div>
<button
className={styles.addBtn}
onClick={() =>
setModal({
mode: 'create',
prefill: {
car_id: cars[0]?.id,
start_date: todayStr,
end_date: todayStr,
},
})
}
>
+ 予約を追加
</button>
</div>
{error && <div className={styles.error}>エラー: {error}</div>}
{loading && <div className={styles.loading}>読み込み中...</div>}
{/* Grid */}
<div
className={styles.gridWrapper}
ref={gridRef}
onMouseLeave={() => {
// don't cancel on leave — handled by global events
}}
>
<div
className={styles.grid}
style={{ width: LABEL_WIDTH + DAYS_SHOWN * CELL_WIDTH }}
>
{/* Header row */}
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
{/* Corner cell */}
<div
className={styles.cornerCell}
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
/>
{/* Date headers */}
{dates.map((date) => {
const ds = dateToStr(date);
const isToday = ds === todayStr;
const dow = format(date, 'E', { locale: ja });
const isWeekend = dow === '土' || dow === '日';
return (
<div
key={ds}
className={`${styles.dateHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
style={{ width: CELL_WIDTH, height: HEADER_HEIGHT }}
>
<span className={styles.dateDay}>{format(date, 'd')}</span>
<span className={`${styles.dateDow} ${isWeekend ? styles.weekendDow : ''}`}>{dow}</span>
</div>
);
})}
</div>
{/* Car rows */}
{cars.map((car, carIdx) => {
const color = carColorMap[car.id];
const carReservations = visibleReservations.filter((r) => {
if (moving && r.id === moving.reservation.id) return false; // hide while moving
return r.car_id === car.id;
});
// Creating highlight for this row
const isCreatingRow = creating && creating.carId === car.id;
let creatingLeft = 0, creatingWidth = 0;
if (isCreatingRow) {
const startCol = differenceInDays(parseISO(creating.startDateStr), viewStart);
const endCol = differenceInDays(parseISO(creating.endDateStr), viewStart);
creatingLeft = Math.max(0, startCol) * CELL_WIDTH;
creatingWidth = (Math.min(endCol, DAYS_SHOWN - 1) - Math.max(0, startCol) + 1) * CELL_WIDTH;
}
// Ghost reservation for this row
const showGhost = moving && moving.row === carIdx && movingGhost;
return (
<div key={car.id} className={styles.carRow} style={{ height: ROW_HEIGHT }}>
{/* Car label */}
<div
className={styles.carLabel}
style={{ width: LABEL_WIDTH, height: ROW_HEIGHT }}
title={car.description || car.name}
>
<span className={styles.carDot} style={{ background: color.border }} />
<span className={styles.carName}>{car.name}</span>
</div>
{/* Day cells */}
<div
className={styles.cellArea}
style={{ width: DAYS_SHOWN * CELL_WIDTH, height: ROW_HEIGHT }}
>
{dates.map((date) => {
const ds = dateToStr(date);
const isToday = ds === todayStr;
const dow = format(date, 'E', { locale: ja });
const isWeekend = dow === '土' || dow === '日';
return (
<div
key={ds}
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''}`}
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
onMouseDown={(e) => handleCellMouseDown(e, car.id, ds)}
/>
);
})}
{/* Creating highlight */}
{isCreatingRow && creatingWidth > 0 && (
<div
className={styles.creatingHighlight}
style={{
left: creatingLeft,
width: creatingWidth,
top: 4,
height: ROW_HEIGHT - 8,
}}
/>
)}
{/* Ghost while moving */}
{showGhost && (
<div
className={styles.reservationBlock}
style={{
left: movingGhost.left,
width: movingGhost.width,
top: 5,
height: ROW_HEIGHT - 10,
background: color.bg,
borderColor: color.border,
color: color.text,
opacity: 0.6,
cursor: 'grabbing',
}}
>
<span className={styles.blockText}>
{moving.reservation.customer_name || '予約'}
</span>
</div>
)}
{/* Reservation blocks */}
{carReservations.map((r) => {
const { left, width } = getReservationLayout(r);
return (
<div
key={r.id}
className={styles.reservationBlock}
style={{
left,
width: width - 4,
top: 5,
height: ROW_HEIGHT - 10,
background: color.bg,
borderColor: color.border,
color: color.text,
cursor: 'grab',
}}
onMouseDown={(e) => handleReservationMouseDown(e, r)}
onClick={(e) => {
if (!moving) {
e.stopPropagation();
setModal({ mode: 'edit', reservation: r });
}
}}
title={`${r.customer_name || '予約'}\n${r.start_date}${r.end_date}${r.notes ? '\n' + r.notes : ''}`}
>
<span className={styles.blockText}>
{r.customer_name || '予約'}
</span>
{width > 80 && (
<span className={styles.blockDates}>
{r.start_date.slice(5)} {r.end_date.slice(5)}
</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)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,266 @@
.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;
}
/* Grid wrapper - scrollable */
.gridWrapper {
flex: 1;
overflow: auto;
position: relative;
user-select: none;
}
.grid {
position: relative;
min-height: 100%;
}
/* Sticky header row */
.headerRow {
display: flex;
position: sticky;
top: 0;
z-index: 20;
background: white;
border-bottom: 2px solid #d1d5db;
}
.cornerCell {
flex-shrink: 0;
background: #f9fafb;
border-right: 2px solid #d1d5db;
border-bottom: 2px solid #d1d5db;
position: sticky;
left: 0;
z-index: 30;
}
.dateHeader {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-right: 1px solid #e5e7eb;
gap: 2px;
background: white;
}
.todayHeader {
background: #eff6ff;
}
.weekendHeader {
background: #fafafa;
}
.dateDay {
font-size: 16px;
font-weight: 700;
color: #111827;
line-height: 1;
}
.dateDow {
font-size: 11px;
color: #6b7280;
line-height: 1;
}
.weekendDow {
color: #ef4444;
}
/* Car rows */
.carRow {
display: flex;
border-bottom: 1px solid #e5e7eb;
position: relative;
}
.carRow:hover .cellArea {
background: #fafafa;
}
.carLabel {
flex-shrink: 0;
display: flex;
align-items: 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: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.carName {
font-size: 13px;
font-weight: 600;
color: #374151;
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;
cursor: crosshair;
transition: background 0.1s;
}
.cell:hover {
background: rgba(26, 86, 219, 0.04);
}
.todayCell {
background: rgba(59, 130, 246, 0.06);
}
.weekendCell {
background: rgba(0,0,0,0.015);
}
/* Highlight while dragging to create */
.creatingHighlight {
position: absolute;
border-radius: 6px;
background: rgba(26, 86, 219, 0.2);
border: 2px dashed #1a56db;
pointer-events: none;
z-index: 5;
}
/* Reservation block */
.reservationBlock {
position: absolute;
border-radius: 6px;
border: 1.5px solid;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
padding: 2px 8px;
z-index: 6;
transition: box-shadow 0.1s;
min-width: 0;
}
.reservationBlock:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 7;
}
.blockText {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.blockDates {
font-size: 10px;
opacity: 0.75;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.noCars {
padding: 40px 20px;
text-align: center;
color: #6b7280;
font-size: 14px;
}

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

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
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",
"dev:frontend": "cd frontend && npx vite",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npx vite build"
},
"keywords": [],
"author": "",
"devDependencies": {
"concurrently": "^9.2.1"
}
}