feat: add weekly schedule shifts and bulk departures
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m1s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 56s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m11s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m37s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s

This commit is contained in:
Sven laptop
2026-09-18 18:00:51 +02:00
parent 9d1a37e248
commit 3f0a02aa04
12 changed files with 4818 additions and 1809 deletions
+34 -66
View File
@@ -1,93 +1,61 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
import TimetableDisplay from './TimetableDisplay'
import FixedScheduleEditor from './FixedScheduleEditor'
import React, { useEffect, useState } from "react";
import { transitClient } from "../api";
import WeeklyScheduleEditor from "./WeeklyScheduleEditor";
export default function ScheduleMaker({ token, user }) {
const client = transitClient(token)
const [scheduleTemplates, setScheduleTemplates] = useState([])
const [busLines, setBusLines] = useState([])
const [buses, setBuses] = useState([])
const [drivers, setDrivers] = useState([])
const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
export default function ScheduleMaker({ token }) {
const client = transitClient(token);
const [schedules, setSchedules] = useState([]);
const [busLines, setBusLines] = useState([]);
const [drivers, setDrivers] = useState([]);
const [message, setMessage] = useState(null);
const loadScheduleTemplates = async () => {
const loadSchedules = async () => {
try {
const res = await client.get('/admin/schedules?templates=1')
setScheduleTemplates((res.data.schedules || []).filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0))
const res = await client.get("/admin/weekly-schedules");
setSchedules(res.data.schedules || []);
} catch (err) {
setMessage('Error loading fixed schedules: ' + (err.response?.data?.error || err.message))
setMessage("Could not load weekly schedules.");
}
}
};
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
const res = await client.get("/admin/bus-lines");
setBusLines(res.data.busLines || []);
} catch (err) {
setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message))
setMessage("Could not load bus lines.");
}
}
const loadBuses = async () => {
try {
const res = await client.get('/admin/fleet/buses')
setBuses(res.data.buses || [])
} catch (err) {
setMessage('Error loading buses: ' + (err.response?.data?.error || err.message))
}
}
};
const loadDrivers = async () => {
try {
const res = await client.get('/admin/drivers')
setDrivers(res.data.drivers || [])
const res = await client.get("/admin/shift-drivers");
setDrivers(res.data.drivers || []);
} catch (err) {
setMessage('Error loading drivers: ' + (err.response?.data?.error || err.message))
setMessage("Could not load drivers.");
}
}
};
useEffect(() => {
loadScheduleTemplates()
loadBusLines()
loadBuses()
loadDrivers()
}, [])
loadSchedules();
loadBusLines();
loadDrivers();
}, []);
const handleScheduleChange = async () => {
await loadScheduleTemplates()
}
await loadSchedules();
};
return (
<div>
{/* Timetable widget - compact daily view */}
<TimetableDisplay
<WeeklyScheduleEditor
client={client}
overviewDate={overviewDate}
setOverviewDate={setOverviewDate}
selectedDriverFilter={selectedDriverFilter}
setSelectedDriverFilter={setSelectedDriverFilter}
busLines={busLines}
drivers={drivers}
schedules={schedules}
onChange={handleScheduleChange}
/>
{/* Fixed service editor - separate widget */}
{isAdmin && (
<FixedScheduleEditor
client={client}
token={token}
user={user}
scheduleTemplates={scheduleTemplates}
busLines={busLines}
buses={buses}
drivers={drivers}
message={message}
setMessage={setMessage}
onScheduleChange={handleScheduleChange}
/>
)}
{message && <div className="message">{message}</div>}
</div>
)
);
}
@@ -0,0 +1,268 @@
import React, { useMemo, useState } from "react";
const DAY_GROUPS = [
{ value: "weekdays", label: "Weekdays", days: [1, 2, 3, 4, 5] },
{ value: "saturday", label: "Saturday", days: [6] },
{ value: "sunday", label: "Sunday", days: [0] },
];
const dayLabel = (day) =>
[
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
][day];
export default function WeeklyScheduleEditor({
client,
busLines,
drivers,
schedules,
onChange,
}) {
const [lineId, setLineId] = useState("");
const [category, setCategory] = useState("weekdays");
const [startTimes, setStartTimes] = useState(["08:00"]);
const [newStartTime, setNewStartTime] = useState("");
const [driverId, setDriverId] = useState("");
const [message, setMessage] = useState(null);
const [saving, setSaving] = useState(false);
const groupedSchedules = useMemo(() => {
return schedules.reduce((groups, schedule) => {
const key =
schedule.dayOfWeek >= 1 && schedule.dayOfWeek <= 5
? "weekdays"
: schedule.dayOfWeek === 6
? "saturday"
: "sunday";
groups[key] = [...(groups[key] || []), schedule];
return groups;
}, {});
}, [schedules]);
const createSchedules = async (event) => {
event.preventDefault();
setMessage(null);
if (!lineId || startTimes.length === 0) {
setMessage("Choose a line and add at least one departure time.");
return;
}
const group = DAY_GROUPS.find((item) => item.value === category);
setSaving(true);
try {
const response = await client.post("/admin/weekly-schedules/bulk", {
busLineId: Number(lineId),
dayOfWeeks: group.days,
startTimes,
driverId: driverId ? Number(driverId) : null,
});
setMessage(
`${response.data.createdCount} departures created for ${group.label}.`,
);
setStartTimes(["08:00"]);
setNewStartTime("");
onChange();
} catch (error) {
setMessage(error.response?.data?.error || "Could not save the schedule.");
} finally {
setSaving(false);
}
};
const addStartTime = () => {
if (!newStartTime || startTimes.includes(newStartTime)) return;
setStartTimes([...startTimes, newStartTime].sort());
setNewStartTime("");
};
const removeStartTime = (time) => {
setStartTimes(startTimes.filter((item) => item !== time));
};
const assignDriver = async (scheduleId, value) => {
try {
await client.put(`/admin/weekly-schedules/${scheduleId}/shift`, {
driverId: value ? Number(value) : null,
});
setMessage("Driver assignment updated.");
onChange();
} catch (error) {
setMessage(
error.response?.data?.error ||
"Could not update the driver assignment.",
);
}
};
const removeSchedule = async (scheduleId) => {
if (!window.confirm("Delete this weekly schedule?")) return;
try {
await client.delete(`/admin/weekly-schedules/${scheduleId}`);
setMessage("Weekly schedule deleted.");
onChange();
} catch (error) {
setMessage(
error.response?.data?.error || "Could not delete the schedule.",
);
}
};
return (
<section className="card">
<div className="section-header">
<div>
<h3>Weekly schedules</h3>
<p>
Add multiple daily departures for weekdays, Saturday, or Sunday,
then assign a driver to the generated templates.
</p>
</div>
</div>
<form onSubmit={createSchedules} className="stack-form">
<div className="form-row">
<select
value={lineId}
onChange={(event) => setLineId(event.target.value)}
>
<option value="">Choose bus line</option>
{busLines.map((line) => (
<option key={line.id} value={line.id}>
Line {line.lineNumber} ({line.startStop} to {line.endStop})
</option>
))}
</select>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
{DAY_GROUPS.map((group) => (
<option key={group.value} value={group.value}>
{group.label}
</option>
))}
</select>
</div>
<div className="form-row" style={{ alignItems: "center" }}>
<input
type="time"
value={newStartTime}
onChange={(event) => setNewStartTime(event.target.value)}
aria-label="New departure time"
/>
<button type="button" className="secondary" onClick={addStartTime}>
Add departure
</button>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
flex: 1,
}}
>
{startTimes.map((time) => (
<span
key={time}
style={{
display: "inline-flex",
gap: "0.4rem",
alignItems: "center",
padding: "0.4rem 0.6rem",
border: "1px solid #c7dcff",
borderRadius: "999px",
background: "#eaf2ff",
}}
>
{time}
<button
type="button"
onClick={() => removeStartTime(time)}
aria-label={`Remove departure ${time}`}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
}}
>
x
</button>
</span>
))}
</div>
<select
value={driverId}
onChange={(event) => setDriverId(event.target.value)}
>
<option value="">Unassigned</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
<button type="submit" disabled={saving}>
{saving ? "Saving..." : `Create ${startTimes.length} departures`}
</button>
</div>
</form>
{message && <div className="message">{message}</div>}
{DAY_GROUPS.map((group) => (
<div key={group.value} style={{ marginTop: "1.5rem" }}>
<h4>{group.label}</h4>
{(groupedSchedules[group.value] || []).length === 0 ? (
<p>No schedules configured.</p>
) : (
<div style={{ display: "grid", gap: "0.75rem" }}>
{groupedSchedules[group.value].map((schedule) => (
<article key={schedule.id} className="schedule-card">
<div className="schedule-header">
<strong>Line {schedule.lineNumber}</strong>
<span className="schedule-time">
{String(schedule.startTime).slice(0, 5)} -{" "}
{String(schedule.endTime).slice(0, 5)}
</span>
</div>
<div className="schedule-details">
{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} to{" "}
{schedule.endStop}
</div>
<div className="form-row" style={{ marginTop: "0.75rem" }}>
<select
value={schedule.driverId || ""}
onChange={(event) =>
assignDriver(schedule.id, event.target.value)
}
>
<option value="">Unassigned</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
<button
type="button"
className="danger"
onClick={() => removeSchedule(schedule.id)}
>
Delete
</button>
</div>
</article>
))}
</div>
)}
</div>
))}
</section>
);
}