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
This commit is contained in:
33
frontend/src/App.jsx
Normal file
33
frontend/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/App.module.css
Normal file
56
frontend/src/App.module.css
Normal 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
27
frontend/src/api.js
Normal 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' }),
|
||||
};
|
||||
191
frontend/src/components/CarManagement.jsx
Normal file
191
frontend/src/components/CarManagement.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
frontend/src/components/CarManagement.module.css
Normal file
201
frontend/src/components/CarManagement.module.css
Normal 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;
|
||||
}
|
||||
152
frontend/src/components/ReservationModal.jsx
Normal file
152
frontend/src/components/ReservationModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
frontend/src/components/ReservationModal.module.css
Normal file
177
frontend/src/components/ReservationModal.module.css
Normal file
@@ -0,0 +1,177 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 20px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
background: white;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.select:focus {
|
||||
border-color: #1a56db;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
border-color: #1a56db;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rightActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btnSave {
|
||||
background: #1a56db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 9px 24px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnSave:hover:not(:disabled) {
|
||||
background: #1447c0;
|
||||
}
|
||||
|
||||
.btnSave:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnCancel {
|
||||
background: #f3f4f6;
|
||||
border: 1.5px solid #d1d5db;
|
||||
color: #374151;
|
||||
padding: 9px 20px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnCancel:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btnDelete {
|
||||
background: #fee2e2;
|
||||
border: 1.5px solid #fca5a5;
|
||||
color: #dc2626;
|
||||
padding: 9px 20px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btnDelete:hover:not(:disabled) {
|
||||
background: #fecaca;
|
||||
}
|
||||
529
frontend/src/components/ScheduleView.jsx
Normal file
529
frontend/src/components/ScheduleView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
frontend/src/components/ScheduleView.module.css
Normal file
266
frontend/src/components/ScheduleView.module.css
Normal 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
27
frontend/src/index.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/* Global styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
Reference in New Issue
Block a user