chore: standardize service domains and error logging
Build and Push Docker Images / detect-changes (push) Successful in 15s
Build and Push Docker Images / build-auth-api (push) Successful in 1m2s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m2s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m10s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m22s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
Build and Push Docker Images / detect-changes (push) Successful in 15s
Build and Push Docker Images / build-auth-api (push) Successful in 1m2s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m2s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m10s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m22s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
This commit is contained in:
+69
-50
@@ -1,87 +1,106 @@
|
||||
import axios from 'axios'
|
||||
import axios from "axios";
|
||||
|
||||
const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {}
|
||||
const RUNTIME =
|
||||
typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};
|
||||
|
||||
const normalizeApiBaseUrl = (value, fallbackPath) => {
|
||||
const rawValue = value || ''
|
||||
if (!rawValue) return fallbackPath
|
||||
if (/^https?:\/\//i.test(rawValue) || rawValue.startsWith('//') || rawValue.startsWith('/')) {
|
||||
return rawValue
|
||||
const rawValue = value || "";
|
||||
if (!rawValue) return fallbackPath;
|
||||
if (
|
||||
/^https?:\/\//i.test(rawValue) ||
|
||||
rawValue.startsWith("//") ||
|
||||
rawValue.startsWith("/")
|
||||
) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
return `${window.location.protocol}//${rawValue}`
|
||||
}
|
||||
return `${window.location.protocol}//${rawValue}`;
|
||||
};
|
||||
|
||||
const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000')
|
||||
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
|
||||
const ERROR_LOG_API = normalizeApiBaseUrl(RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, 'http://localhost:5003')
|
||||
const AUTH_API = normalizeApiBaseUrl(
|
||||
RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API,
|
||||
"https://auth.xtransit.testinstance.nl",
|
||||
);
|
||||
const TRANSIT_API = normalizeApiBaseUrl(
|
||||
RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API,
|
||||
"https://transit.xtransit.testinstance.nl",
|
||||
);
|
||||
const ERROR_LOG_API = normalizeApiBaseUrl(
|
||||
RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API,
|
||||
"https://error.xtransit.testinstance.nl",
|
||||
);
|
||||
|
||||
const reportClientError = async ({ service, partOfService, error }) => {
|
||||
const reportClientError = async ({ service, partofservice, error }) => {
|
||||
try {
|
||||
await fetch(`${ERROR_LOG_API}/error_log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
service,
|
||||
partOfService,
|
||||
error: typeof error === 'string' ? error : JSON.stringify(error),
|
||||
partofservice,
|
||||
error: typeof error === "string" ? error : JSON.stringify(error),
|
||||
}),
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore logging failures
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const authClient = axios.create({ baseURL: AUTH_API })
|
||||
export const authClient = axios.create({ baseURL: AUTH_API });
|
||||
|
||||
authClient.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
(response) => response,
|
||||
(error) => {
|
||||
void reportClientError({
|
||||
service: 'admin_ui',
|
||||
partOfService: 'authClient',
|
||||
error: error?.response?.data?.error || error?.message || 'authClient_error',
|
||||
})
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
service: "admin_ui",
|
||||
partofservice: "authClient",
|
||||
error:
|
||||
error?.response?.data?.error || error?.message || "authClient_error",
|
||||
});
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const transitClient = (token) => {
|
||||
const client = axios.create({
|
||||
baseURL: TRANSIT_API,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
// Auto-logout on unauthorized responses to avoid delayed failures
|
||||
client.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
(response) => response,
|
||||
(error) => {
|
||||
try {
|
||||
const status = error?.response?.status
|
||||
const status = error?.response?.status;
|
||||
if (status === 401 || status === 403) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
// reload to let App show the Login screen
|
||||
if (typeof window !== 'undefined') window.location.reload()
|
||||
if (typeof window !== "undefined") window.location.reload();
|
||||
}
|
||||
void reportClientError({
|
||||
service: 'admin_ui',
|
||||
partOfService: 'transitClient',
|
||||
error: error?.response?.data?.error || error?.message || 'transitClient_error',
|
||||
})
|
||||
service: "admin_ui",
|
||||
partofservice: "transitClient",
|
||||
error:
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
"transitClient_error",
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
return client
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
export const errorLogClient = (token) => axios.create({
|
||||
baseURL: ERROR_LOG_API,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
export const errorLogClient = (token) =>
|
||||
axios.create({
|
||||
baseURL: ERROR_LOG_API,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
export { reportClientError }
|
||||
export { reportClientError };
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { errorLogClient } from '../api'
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { errorLogClient } from "../api";
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
if (!value) return "—";
|
||||
try {
|
||||
return new Date(value).toLocaleString()
|
||||
return new Date(value).toLocaleString();
|
||||
} catch (_) {
|
||||
return String(value)
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function ErrorLogViewer({ token }) {
|
||||
const client = useMemo(() => errorLogClient(token), [token])
|
||||
const [logs, setLogs] = useState([])
|
||||
const [service, setService] = useState('')
|
||||
const [partOfService, setPartOfService] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState(null)
|
||||
const client = useMemo(() => errorLogClient(token), [token]);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [service, setService] = useState("");
|
||||
const [partofservice, setPartofservice] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true)
|
||||
setMessage(null)
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const params = { limit: 100 }
|
||||
if (service) params.service = service
|
||||
if (partOfService) params.partOfService = partOfService
|
||||
const res = await client.get('/error_log', { params })
|
||||
setLogs(res.data.errors || [])
|
||||
const params = { limit: 100 };
|
||||
if (service) params.service = service;
|
||||
if (partofservice) params.partofservice = partofservice;
|
||||
const res = await client.get("/error_log", { params });
|
||||
setLogs(res.data.errors || []);
|
||||
} catch (err) {
|
||||
setMessage(err.response?.data?.error || err.message || 'Kon fouten niet laden')
|
||||
setMessage(
|
||||
err.response?.data?.error || err.message || "Kon fouten niet laden",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [service, partOfService])
|
||||
loadLogs();
|
||||
}, [service, partofservice]);
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
@@ -46,29 +48,41 @@ export default function ErrorLogViewer({ token }) {
|
||||
<p>Laatste foutmeldingen uit de centrale logging service.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadLogs} disabled={loading}>
|
||||
{loading ? 'Laden...' : 'Ververs'}
|
||||
{loading ? "Laden..." : "Ververs"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<input placeholder="Service filter" value={service} onChange={e => setService(e.target.value)} />
|
||||
<input placeholder="Part of service filter" value={partOfService} onChange={e => setPartOfService(e.target.value)} />
|
||||
<input
|
||||
placeholder="Service filter"
|
||||
value={service}
|
||||
onChange={(e) => setService(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Part of service filter"
|
||||
value={partofservice}
|
||||
onChange={(e) => setPartofservice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
|
||||
<div className="list-grid">
|
||||
{logs.map(log => (
|
||||
{logs.map((log) => (
|
||||
<article className="mini-card" key={log.id}>
|
||||
<strong>{log.service}</strong>
|
||||
<div>{formatDate(log.date)}</div>
|
||||
<div className="muted" style={{ marginTop: 6 }}>{log.partOfService || '—'}</div>
|
||||
<div className="muted" style={{ marginTop: 6 }}>
|
||||
{log.partofservice || "—"}
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>{log.error}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!logs.length && !message && <div className="muted">Nog geen foutmeldingen gevonden.</div>}
|
||||
{!logs.length && !message && (
|
||||
<div className="muted">Nog geen foutmeldingen gevonden.</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,72 +1,85 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
|
||||
const [schedules, setSchedules] = useState([])
|
||||
const [summary, setSummary] = useState(null)
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
||||
const [selectedLineId, setSelectedLineId] = useState('')
|
||||
const [lines, setLines] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
export default function TimetableViewer({
|
||||
apiBaseUrl = "https://transit.xtransit.testinstance.nl",
|
||||
}) {
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [selectedDate, setSelectedDate] = useState(
|
||||
new Date().toISOString().split("T")[0],
|
||||
);
|
||||
const [selectedLineId, setSelectedLineId] = useState("");
|
||||
const [lines, setLines] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Fetch available bus lines
|
||||
useEffect(() => {
|
||||
const fetchLines = async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`)
|
||||
const data = await res.json()
|
||||
setLines(data.busLines || [])
|
||||
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`);
|
||||
const data = await res.json();
|
||||
setLines(data.busLines || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load lines:', err)
|
||||
console.error("Failed to load lines:", err);
|
||||
}
|
||||
}
|
||||
fetchLines()
|
||||
}, [apiBaseUrl])
|
||||
};
|
||||
fetchLines();
|
||||
}, [apiBaseUrl]);
|
||||
|
||||
// Fetch schedules based on date and line filter
|
||||
useEffect(() => {
|
||||
const fetchSchedules = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`
|
||||
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`;
|
||||
if (selectedLineId) {
|
||||
url += `&lineId=${selectedLineId}`
|
||||
url += `&lineId=${selectedLineId}`;
|
||||
}
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
setSchedules(data.schedules || [])
|
||||
setSummary(data.summary || null)
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
setSchedules(data.schedules || []);
|
||||
setSummary(data.summary || null);
|
||||
} catch (err) {
|
||||
setError('Failed to load schedules: ' + err.message)
|
||||
setError("Failed to load schedules: " + err.message);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchSchedules()
|
||||
}, [selectedDate, selectedLineId, apiBaseUrl])
|
||||
};
|
||||
fetchSchedules();
|
||||
}, [selectedDate, selectedLineId, apiBaseUrl]);
|
||||
|
||||
const timeSlots = generateTimeSlots()
|
||||
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
||||
const timeSlots = generateTimeSlots();
|
||||
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ padding: "20px", maxWidth: "1200px", margin: "0 auto" }}>
|
||||
<h1>Public Transit Timetable</h1>
|
||||
|
||||
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
|
||||
|
||||
<div style={{ marginBottom: "20px", display: "flex", gap: "10px" }}>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={e => setSelectedDate(e.target.value)}
|
||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #ccc",
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={selectedLineId}
|
||||
onChange={e => setSelectedLineId(e.target.value)}
|
||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }}
|
||||
onChange={(e) => setSelectedLineId(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #ccc",
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<option value="">All lines</option>
|
||||
{lines.map(line => (
|
||||
{lines.map((line) => (
|
||||
<option key={line.id} value={line.id}>
|
||||
Line {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||
</option>
|
||||
@@ -75,101 +88,191 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div style={{ marginBottom: '16px', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#eef2ff', color: '#3730a3' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "16px",
|
||||
display: "flex",
|
||||
gap: "12px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: "#eef2ff",
|
||||
color: "#3730a3",
|
||||
}}
|
||||
>
|
||||
Total: {summary.total}
|
||||
</span>
|
||||
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#ecfeff', color: '#155e75' }}>
|
||||
<span
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: "#ecfeff",
|
||||
color: "#155e75",
|
||||
}}
|
||||
>
|
||||
Vast: {summary.recurring}
|
||||
</span>
|
||||
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#f8fafc', color: '#334155' }}>
|
||||
<span
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderRadius: "999px",
|
||||
backgroundColor: "#f8fafc",
|
||||
color: "#334155",
|
||||
}}
|
||||
>
|
||||
Eenmalig: {summary.oneTime}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p>Loading schedules...</p>}
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}>
|
||||
<thead style={{ backgroundColor: '#f1f5f9' }}>
|
||||
<table
|
||||
style={{ width: "100%", borderCollapse: "collapse", marginTop: "20px" }}
|
||||
>
|
||||
<thead style={{ backgroundColor: "#f1f5f9" }}>
|
||||
<tr>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Day status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Delay</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "12px",
|
||||
textAlign: "left",
|
||||
borderBottom: "2px solid #ddd",
|
||||
}}
|
||||
>
|
||||
Time
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "12px",
|
||||
textAlign: "left",
|
||||
borderBottom: "2px solid #ddd",
|
||||
}}
|
||||
>
|
||||
Line
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "12px",
|
||||
textAlign: "left",
|
||||
borderBottom: "2px solid #ddd",
|
||||
}}
|
||||
>
|
||||
Vehicle
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "12px",
|
||||
textAlign: "left",
|
||||
borderBottom: "2px solid #ddd",
|
||||
}}
|
||||
>
|
||||
Day status
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: "12px",
|
||||
textAlign: "left",
|
||||
borderBottom: "2px solid #ddd",
|
||||
}}
|
||||
>
|
||||
Delay
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timeSlots.map((slot, idx) => {
|
||||
const slotSchedules = schedulesBySlot[slot] || []
|
||||
if (slotSchedules.length === 0) return null
|
||||
const slotSchedules = schedulesBySlot[slot] || [];
|
||||
if (slotSchedules.length === 0) return null;
|
||||
return slotSchedules.map((schedule, sidx) => (
|
||||
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{sidx === 0 ? slot : ''}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}>
|
||||
<tr
|
||||
key={`${idx}-${sidx}`}
|
||||
style={{ borderBottom: "1px solid #eee" }}
|
||||
>
|
||||
<td style={{ padding: "12px" }}>{sidx === 0 ? slot : ""}</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "12px",
|
||||
fontWeight: "bold",
|
||||
color: "#4f46e5",
|
||||
}}
|
||||
>
|
||||
Line {schedule.lineNumber}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<td style={{ padding: "12px" }}>
|
||||
{schedule.busName} ({schedule.licensePlate})
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<span
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: schedule.status === 'in_progress' ? '#dbeafe' : schedule.status === 'completed' ? '#dcfce7' : '#f3f4f6',
|
||||
color: schedule.status === 'in_progress' ? '#1d4ed8' : schedule.status === 'completed' ? '#166534' : '#374151',
|
||||
fontSize: '0.9rem',
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor:
|
||||
schedule.status === "in_progress"
|
||||
? "#dbeafe"
|
||||
: schedule.status === "completed"
|
||||
? "#dcfce7"
|
||||
: "#f3f4f6",
|
||||
color:
|
||||
schedule.status === "in_progress"
|
||||
? "#1d4ed8"
|
||||
: schedule.status === "completed"
|
||||
? "#166534"
|
||||
: "#374151",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{schedule.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{typeof schedule.currentDelayMinutes === 'number' && schedule.currentDelayMinutes !== null
|
||||
? `${schedule.currentDelayMinutes > 0 ? '+' : ''}${schedule.currentDelayMinutes} min`
|
||||
: '—'}
|
||||
<td style={{ padding: "12px" }}>
|
||||
{typeof schedule.currentDelayMinutes === "number" &&
|
||||
schedule.currentDelayMinutes !== null
|
||||
? `${schedule.currentDelayMinutes > 0 ? "+" : ""}${schedule.currentDelayMinutes} min`
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
));
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{schedules.length === 0 && !loading && (
|
||||
<p style={{ textAlign: 'center', color: '#999', marginTop: '40px' }}>
|
||||
<p style={{ textAlign: "center", color: "#999", marginTop: "40px" }}>
|
||||
No schedules found for the selected date and line.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function generateTimeSlots() {
|
||||
const slots = []
|
||||
const slots = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
|
||||
slots.push(
|
||||
`${String(hour).padStart(2, "0")}:00-${String((hour + 1) % 24).padStart(2, "0")}:00`,
|
||||
);
|
||||
}
|
||||
return slots
|
||||
return slots;
|
||||
}
|
||||
|
||||
function groupSchedulesByTimeSlot(schedules, slots) {
|
||||
const grouped = {}
|
||||
slots.forEach(slot => {
|
||||
grouped[slot] = []
|
||||
})
|
||||
const grouped = {};
|
||||
slots.forEach((slot) => {
|
||||
grouped[slot] = [];
|
||||
});
|
||||
|
||||
schedules.forEach(schedule => {
|
||||
const startHour = new Date(schedule.startTime).getHours()
|
||||
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
|
||||
schedules.forEach((schedule) => {
|
||||
const startHour = new Date(schedule.startTime).getHours();
|
||||
const slot = `${String(startHour).padStart(2, "0")}:00-${String((startHour + 1) % 24).padStart(2, "0")}:00`;
|
||||
if (grouped[slot]) {
|
||||
grouped[slot].push(schedule)
|
||||
grouped[slot].push(schedule);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return grouped
|
||||
return grouped;
|
||||
}
|
||||
|
||||
@@ -215,32 +215,50 @@ export default function WeeklyScheduleEditor({
|
||||
|
||||
{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" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: "1rem",
|
||||
marginTop: "1.5rem",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
{DAY_GROUPS.map((group) => (
|
||||
<section
|
||||
key={group.value}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
border: "1px solid #dbe3ef",
|
||||
borderRadius: "8px",
|
||||
background: "#f8fafc",
|
||||
padding: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ marginTop: 0 }}>{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>
|
||||
<select
|
||||
value={schedule.driverId || ""}
|
||||
onChange={(event) =>
|
||||
assignDriver(schedule.id, event.target.value)
|
||||
}
|
||||
style={{ width: "100%", marginTop: "0.75rem" }}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{drivers.map((driver) => (
|
||||
@@ -253,16 +271,17 @@ export default function WeeklyScheduleEditor({
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => removeSchedule(schedule.id)}
|
||||
style={{ width: "100%", marginTop: "0.5rem" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user