Files
car/frontend/src/components/TimelineView.jsx

356 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}