Agent-Logs-Url: https://github.com/pdf114514/CarReservation/sessions/c0a4b7dc-228e-4e7d-a985-61b9a17de159 Co-authored-by: pdf114514 <57948770+pdf114514@users.noreply.github.com>
356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { format, addDays, addMonths, startOfMonth, endOfMonth, parseISO, differenceInDays } from 'date-fns';
|
||
import { ja } from 'date-fns/locale';
|
||
import { api } from '../api.js';
|
||
import { formatDateRange, formatReservationTooltip } from '../utils/carUtils.js';
|
||
import ReservationModal from './ReservationModal.jsx';
|
||
import styles from './TimelineView.module.css';
|
||
|
||
const ROW_HEIGHT = 48; // px per reservation row
|
||
const LABEL_WIDTH = 180; // px for reservation info column
|
||
const HEADER_HEIGHT = 60; // px for date header
|
||
const DAY_WIDTH = 36; // px per day column
|
||
const BAR_PADDING = 4; // px gap between bar and row edge
|
||
|
||
// Same colour palette as ScheduleView
|
||
const COLORS = [
|
||
{ bg: '#dbeafe', border: '#3b82f6', text: '#1e3a8a' },
|
||
{ bg: '#dcfce7', border: '#22c55e', text: '#14532d' },
|
||
{ bg: '#fef9c3', border: '#eab308', text: '#713f12' },
|
||
{ bg: '#fce7f3', border: '#ec4899', text: '#831843' },
|
||
{ bg: '#ede9fe', border: '#8b5cf6', text: '#3b0764' },
|
||
{ bg: '#ffedd5', border: '#f97316', text: '#7c2d12' },
|
||
{ bg: '#e0f2fe', border: '#0ea5e9', text: '#0c4a6e' },
|
||
{ bg: '#f0fdf4', border: '#16a34a', text: '#14532d' },
|
||
];
|
||
|
||
function getColor(index) {
|
||
return COLORS[index % COLORS.length];
|
||
}
|
||
|
||
function dateToStr(date) {
|
||
return format(date, 'yyyy-MM-dd');
|
||
}
|
||
|
||
export default function TimelineView({ reloadKey = 0 }) {
|
||
const [cars, setCars] = useState([]);
|
||
const [reservations, setReservations] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [modal, setModal] = useState(null);
|
||
const [contextMenu, setContextMenu] = useState(null);
|
||
|
||
// View window: show the current month by default
|
||
const [viewStart, setViewStart] = useState(() => startOfMonth(new Date()));
|
||
const [viewEnd, setViewEnd] = useState(() => endOfMonth(new Date()));
|
||
|
||
const gridRef = useRef(null);
|
||
|
||
const days = (() => {
|
||
const result = [];
|
||
let d = viewStart;
|
||
while (d <= viewEnd) {
|
||
result.push(d);
|
||
d = addDays(d, 1);
|
||
}
|
||
return result;
|
||
})();
|
||
|
||
const totalWidth = LABEL_WIDTH + days.length * DAY_WIDTH;
|
||
const todayStr = dateToStr(new Date());
|
||
|
||
// --- Data loading ---
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const [carsData, resData] = await Promise.all([api.getCars(), api.getReservations()]);
|
||
setCars(carsData);
|
||
setReservations(resData);
|
||
setError(null);
|
||
} catch (e) {
|
||
setError(e.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData, reloadKey]);
|
||
|
||
// Close context menu on click / Escape
|
||
useEffect(() => {
|
||
if (!contextMenu) return;
|
||
const close = () => setContextMenu(null);
|
||
const onKey = (e) => { if (e.key === 'Escape') close(); };
|
||
window.addEventListener('click', close);
|
||
window.addEventListener('keydown', onKey);
|
||
return () => {
|
||
window.removeEventListener('click', close);
|
||
window.removeEventListener('keydown', onKey);
|
||
};
|
||
}, [contextMenu]);
|
||
|
||
// --- Navigation ---
|
||
const prevMonth = () => {
|
||
const start = addMonths(viewStart, -1);
|
||
setViewStart(startOfMonth(start));
|
||
setViewEnd(endOfMonth(start));
|
||
};
|
||
const nextMonth = () => {
|
||
const start = addMonths(viewStart, 1);
|
||
setViewStart(startOfMonth(start));
|
||
setViewEnd(endOfMonth(start));
|
||
};
|
||
const goThisMonth = () => {
|
||
setViewStart(startOfMonth(new Date()));
|
||
setViewEnd(endOfMonth(new Date()));
|
||
};
|
||
|
||
// --- Handlers ---
|
||
const handleModalSave = async (data) => {
|
||
try {
|
||
if (modal.mode === 'edit') {
|
||
await api.updateReservation(modal.reservation.id, data);
|
||
} else {
|
||
await api.createReservation(data);
|
||
}
|
||
setModal(null);
|
||
await loadData();
|
||
} catch (e) {
|
||
setError(`予約の保存に失敗しました: ${e.message}`);
|
||
}
|
||
};
|
||
|
||
const handleModalDelete = async (id) => {
|
||
try {
|
||
await api.deleteReservation(id);
|
||
setModal(null);
|
||
await loadData();
|
||
} catch (e) {
|
||
setError(`予約の削除に失敗しました: ${e.message}`);
|
||
}
|
||
};
|
||
|
||
// Build car colour map
|
||
const carColorMap = {};
|
||
cars.forEach((car, i) => { carColorMap[car.id] = getColor(i); });
|
||
|
||
// Sort reservations by start_date then car
|
||
const sortedReservations = [...reservations].sort((a, b) => {
|
||
if (a.start_date !== b.start_date) return a.start_date < b.start_date ? -1 : 1;
|
||
return a.car_id - b.car_id;
|
||
});
|
||
|
||
const viewStartStr = dateToStr(viewStart);
|
||
const viewEndStr = dateToStr(viewEnd);
|
||
|
||
// Filter to reservations that overlap the view window
|
||
const visibleReservations = sortedReservations.filter(
|
||
(r) => r.end_date >= viewStartStr && r.start_date <= viewEndStr
|
||
);
|
||
|
||
function getBarLayout(r) {
|
||
const clampedStart = r.start_date < viewStartStr ? viewStartStr : r.start_date;
|
||
const clampedEnd = r.end_date > viewEndStr ? viewEndStr : r.end_date;
|
||
const startOffset = differenceInDays(parseISO(clampedStart), viewStart);
|
||
const endOffset = differenceInDays(parseISO(clampedEnd), viewStart);
|
||
const left = startOffset * DAY_WIDTH;
|
||
const width = (endOffset - startOffset + 1) * DAY_WIDTH;
|
||
return { left, width };
|
||
}
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
{/* Toolbar */}
|
||
<div className={styles.toolbar}>
|
||
<div className={styles.navGroup}>
|
||
<button className={styles.toolBtn} onClick={prevMonth}>‹ 前月</button>
|
||
<button className={styles.toolBtn} onClick={goThisMonth}>今月</button>
|
||
<button className={styles.toolBtn} onClick={nextMonth}>次月 ›</button>
|
||
</div>
|
||
<div className={styles.monthLabel}>
|
||
{format(viewStart, 'yyyy年M月', { locale: ja })}
|
||
</div>
|
||
<button
|
||
className={styles.addBtn}
|
||
disabled={cars.length === 0}
|
||
onClick={() =>
|
||
setModal({
|
||
mode: 'create',
|
||
prefill: {
|
||
car_id: cars[0]?.id,
|
||
start_date: todayStr,
|
||
end_date: todayStr,
|
||
},
|
||
})
|
||
}
|
||
>
|
||
+ 予約を追加
|
||
</button>
|
||
</div>
|
||
|
||
{error && <div className={styles.error}>エラー: {error}</div>}
|
||
|
||
{/* Timeline grid */}
|
||
<div className={styles.gridWrapper} ref={gridRef}>
|
||
{loading && (
|
||
<div
|
||
className={styles.loadingOverlay}
|
||
style={{ height: HEADER_HEIGHT }}
|
||
>
|
||
読み込み中...
|
||
</div>
|
||
)}
|
||
<div className={styles.grid} style={{ width: totalWidth }}>
|
||
{/* Sticky header: month/day labels */}
|
||
<div className={styles.headerRow} style={{ height: HEADER_HEIGHT }}>
|
||
{/* Corner */}
|
||
<div
|
||
className={styles.cornerCell}
|
||
style={{ width: LABEL_WIDTH, height: HEADER_HEIGHT }}
|
||
>
|
||
<span className={styles.cornerText}>予約一覧</span>
|
||
</div>
|
||
{/* Day columns */}
|
||
{days.map((date) => {
|
||
const ds = dateToStr(date);
|
||
const isToday = ds === todayStr;
|
||
const dow = format(date, 'E', { locale: ja });
|
||
const isWeekend = dow === '土' || dow === '日';
|
||
const isSun = dow === '日';
|
||
const isSat = dow === '土';
|
||
return (
|
||
<div
|
||
key={ds}
|
||
className={`${styles.dayHeader} ${isToday ? styles.todayHeader : ''} ${isWeekend ? styles.weekendHeader : ''}`}
|
||
style={{ width: DAY_WIDTH, height: HEADER_HEIGHT }}
|
||
>
|
||
<span className={styles.dayNum}>{format(date, 'd')}</span>
|
||
<span className={`${styles.dayDow} ${isSun ? styles.sunDow : ''} ${isSat ? styles.satDow : ''}`}>{dow}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Reservation rows */}
|
||
{visibleReservations.map((r) => {
|
||
const car = cars.find((c) => c.id === r.car_id);
|
||
const color = carColorMap[r.car_id] || COLORS[0];
|
||
const { left, width } = getBarLayout(r);
|
||
|
||
return (
|
||
<div key={r.id} className={styles.resRow} style={{ height: ROW_HEIGHT }}>
|
||
{/* Label: car + customer */}
|
||
<div
|
||
className={styles.resLabel}
|
||
style={{ width: LABEL_WIDTH, height: ROW_HEIGHT }}
|
||
>
|
||
<span className={styles.carDot} style={{ background: color.border }} />
|
||
<div className={styles.labelText}>
|
||
<span className={styles.labelCar}>{car?.name ?? '—'}</span>
|
||
<span className={styles.labelCustomer}>{r.customer_name || '(名前なし)'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Day cells (background) */}
|
||
<div
|
||
className={styles.cellArea}
|
||
style={{ width: days.length * DAY_WIDTH, height: ROW_HEIGHT, position: 'relative' }}
|
||
>
|
||
{days.map((date) => {
|
||
const ds = dateToStr(date);
|
||
const isToday = ds === todayStr;
|
||
const dow = format(date, 'E', { locale: ja });
|
||
const isWeekend = dow === '土' || dow === '日';
|
||
return (
|
||
<div
|
||
key={ds}
|
||
className={`${styles.cell} ${isToday ? styles.todayCell : ''} ${isWeekend ? styles.weekendCell : ''}`}
|
||
style={{ width: DAY_WIDTH, height: ROW_HEIGHT }}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{/* Bar */}
|
||
<div
|
||
className={styles.bar}
|
||
style={{
|
||
left,
|
||
width: width - BAR_PADDING,
|
||
background: color.bg,
|
||
borderColor: color.border,
|
||
color: color.text,
|
||
}}
|
||
onClick={() => setModal({ mode: 'edit', reservation: r })}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setContextMenu({ x: e.clientX, y: e.clientY, reservation: r });
|
||
}}
|
||
title={formatReservationTooltip(r)}
|
||
>
|
||
<span className={styles.barText}>
|
||
{r.customer_name || '予約'}
|
||
</span>
|
||
{width > 80 && (
|
||
<span className={styles.barDates}>
|
||
{formatDateRange(r.start_date, r.start_period, r.end_date, r.end_period)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{visibleReservations.length === 0 && !loading && (
|
||
<div className={styles.empty}>
|
||
この月には予約がありません。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Reservation Modal */}
|
||
{modal && (
|
||
<ReservationModal
|
||
cars={cars}
|
||
reservation={modal.mode === 'edit' ? modal.reservation : modal.prefill}
|
||
onSave={handleModalSave}
|
||
onDelete={handleModalDelete}
|
||
onClose={() => setModal(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Right-click context menu */}
|
||
{contextMenu && (
|
||
<div
|
||
className={styles.contextMenu}
|
||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
className={styles.contextMenuItem}
|
||
onClick={() => {
|
||
setModal({ mode: 'edit', reservation: contextMenu.reservation });
|
||
setContextMenu(null);
|
||
}}
|
||
>
|
||
✏️ 編集
|
||
</button>
|
||
<button
|
||
className={`${styles.contextMenuItem} ${styles.contextMenuItemDelete}`}
|
||
onClick={async () => {
|
||
setContextMenu(null);
|
||
await handleModalDelete(contextMenu.reservation.id);
|
||
}}
|
||
>
|
||
🗑️ 削除
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|