M18: implement operational workspaces

This commit is contained in:
NuklearRabbit
2026-08-10 12:41:28 +02:00
parent 8030753dbc
commit fe06ff75a1
33 changed files with 1082 additions and 95 deletions
+23 -2
View File
@@ -33,7 +33,7 @@ test("operator can create and cancel a booking through the UI", async ({ page, r
await expect(page.getByText("geannuleerd", { exact: true })).toBeVisible();
});
test("manager can create and deactivate an operational user", async ({ page, request }) => {
test("manager can create, edit and deactivate an operational user", async ({ page, request }) => {
await reset(request);
await login(page);
await page.goto("/users");
@@ -41,12 +41,33 @@ test("manager can create and deactivate an operational user", async ({ page, req
await page.getByLabel("E-mail").fill("e2e.planner@example.test");
await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password");
await page.getByRole("button", { name: "Gebruiker toevoegen" }).click();
const row = page.getByRole("row", { name: /E2E Planner/ });
let row = page.getByRole("row", { name: /E2E Planner/ });
await expect(row).toBeVisible();
await row.getByRole("button", { name: "Bewerken" }).click();
const editor = page.locator(".user-edit");
await editor.getByLabel("Naam").fill("E2E Senior Planner");
await editor.getByLabel("Rol").selectOption("operations_manager");
await editor.getByLabel("Nieuw wachtwoord").fill("updated-e2e-password");
await editor.getByRole("button", { name: "Wijzigingen opslaan" }).click();
row = page.getByRole("row", { name: /E2E Senior Planner/ });
await expect(row.getByText("Operationeel beheerder")).toBeVisible();
await row.getByRole("button", { name: "Deactiveren" }).click();
await expect(row.getByText("inactief", { exact: true })).toBeVisible();
});
test("manager can assign data-quality work from the queue", async ({ page, request }) => {
await reset(request);
await login(page);
await page.goto("/data-quality?status=open");
const firstRow = page.locator(".data-table tbody tr").first();
await firstRow.getByRole("checkbox").check();
const assignee = page.getByLabel("Toewijzen aan");
await assignee.selectOption({ index: 1 });
const selectedLabel = await assignee.locator("option:checked").textContent();
await page.getByRole("button", { name: "Werkvoorraad bijwerken" }).click();
await expect(firstRow).toContainText(selectedLabel ?? "");
});
test("manager can record maintenance and release a safe vehicle", async ({ page, request }) => {
await reset(request);
const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json();
+8
View File
@@ -25,6 +25,10 @@ export interface Vehicle {
next_service_km: number;
active: boolean;
attention: boolean;
attention_reason: "blocked_status" | "service_due" | "data_quality" | null;
service_remaining_km: number;
next_booking_ref: string | null;
next_booking_at: string | null;
}
export interface Page<T> {
@@ -113,6 +117,10 @@ export interface DataQualityIssue {
status: "open" | "deferred" | "resolved" | "rejected";
evidence: Record<string, unknown>;
detected_at: string;
due_at: string | null;
assigned_to_ref: string | null;
assigned_to_name: string | null;
overdue: boolean;
resolved_at: string | null;
}
@@ -8,6 +8,18 @@
"searchPlaceholder": "Booking, customer or vehicle",
"statusLabel": "Status",
"statusAll": "All statuses",
"fromLabel": "From date",
"toLabel": "Through date",
"locationLabel": "Location",
"locationPlaceholder": "Exact operating location",
"sortLabel": "Sort",
"sortOperational": "Operational priority",
"sortAscending": "Start date ascending",
"sortDescending": "Start date descending",
"presetsLabel": "Quick filters",
"todayPreset": "Today",
"upcomingPreset": "Upcoming reservations",
"clearFilters": "Clear filters",
"loading": "Loading booking ledger…",
"empty": "No bookings found",
"emptyDetail": "Adjust the booking status filter.",
@@ -8,6 +8,15 @@
"statusLabel": "Status",
"statusAll": "All statuses",
"attentionOnly": "Attention only",
"locationFilter": "Location",
"locationPlaceholder": "Exact operating location",
"serviceDue": "Service overdue",
"serviceRemaining": "{{count}} km remaining",
"attentionReasons": {
"blocked_status": "Blocked status",
"service_due": "Service overdue",
"data_quality": "Open quality issue"
},
"loading": "Loading fleet registry…",
"empty": "No vehicles found",
"emptyDetail": "Adjust the current fleet filters.",
@@ -22,6 +31,8 @@
"location": "Location",
"status": "Status",
"odometer": "Odometer (km)",
"service": "Service",
"nextBooking": "Next booking",
"attention": "Attention"
},
"needsAttention": "Needs attention"
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "edit": "Edit", "editTitle": "Edit user {{ref}}", "newPassword": "New password", "passwordUnchanged": "Leave empty to keep unchanged", "saveChanges": "Save changes", "cancel": "Cancel", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" }
}
+17 -1
View File
@@ -22,6 +22,19 @@
"ruleTypeAll": "All rule types",
"severityLabel": "Severity",
"severityAll": "All severity levels",
"assigneeLabel": "Assigned to",
"assigneeAll": "All team members",
"unassigned": "Unassigned",
"overdueOnly": "Overdue only",
"bulkTitle": "Update selected work",
"selected": "{{count}} selected",
"assignTo": "Assign to",
"dueAt": "Due date",
"bulkApply": "Update work queue",
"bulkSaving": "Updating…",
"bulkFailed": "The work queue could not be updated.",
"clearSelection": "Clear selection",
"selectIssue": "Select issue {{ref}}",
"demoScenariosOnly": "Demo scenarios only",
"loading": "Loading quality workbench…",
"queueClear": "Queue is clear",
@@ -33,10 +46,13 @@
"evidenceBacked": "Evidence-backed detection",
"columns": {
"reference": "Reference",
"select": "Select",
"rule": "Rule",
"entity": "Entity",
"severity": "Severity",
"status": "Status"
"status": "Status",
"assignee": "Assignee",
"due": "Due date"
}
},
"ruleTypes": {
@@ -8,6 +8,18 @@
"searchPlaceholder": "Réservation, client ou véhicule",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"fromLabel": "À partir du",
"toLabel": "Jusquau",
"locationLabel": "Site",
"locationPlaceholder": "Site opérationnel exact",
"sortLabel": "Tri",
"sortOperational": "Priorité opérationnelle",
"sortAscending": "Date de début croissante",
"sortDescending": "Date de début décroissante",
"presetsLabel": "Filtres rapides",
"todayPreset": "Aujourdhui",
"upcomingPreset": "Réservations à venir",
"clearFilters": "Effacer les filtres",
"loading": "Chargement du registre des réservations…",
"empty": "Aucune réservation trouvée",
"emptyDetail": "Ajustez le filtre de statut des réservations.",
@@ -8,6 +8,15 @@
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"attentionOnly": "Attention uniquement",
"locationFilter": "Site",
"locationPlaceholder": "Site opérationnel exact",
"serviceDue": "Entretien en retard",
"serviceRemaining": "{{count}} km restants",
"attentionReasons": {
"blocked_status": "Statut bloqué",
"service_due": "Entretien en retard",
"data_quality": "Problème de qualité ouvert"
},
"loading": "Chargement du registre de la flotte…",
"empty": "Aucun véhicule trouvé",
"emptyDetail": "Ajustez les filtres de flotte actuels.",
@@ -22,6 +31,8 @@
"location": "Localisation",
"status": "Statut",
"odometer": "Kilométrage (km)",
"service": "Entretien",
"nextBooking": "Prochaine réservation",
"attention": "Attention"
},
"needsAttention": "Nécessite de l'attention"
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "edit": "Modifier", "editTitle": "Modifier lutilisateur {{ref}}", "newPassword": "Nouveau mot de passe", "passwordUnchanged": "Laisser vide pour ne pas modifier", "saveChanges": "Enregistrer les modifications", "cancel": "Annuler", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
}
+17 -1
View File
@@ -22,6 +22,19 @@
"ruleTypeAll": "Tous les types de règles",
"severityLabel": "Gravité",
"severityAll": "Tous les niveaux de gravité",
"assigneeLabel": "Attribué à",
"assigneeAll": "Tous les membres",
"unassigned": "Non attribué",
"overdueOnly": "En retard uniquement",
"bulkTitle": "Mettre à jour le travail sélectionné",
"selected": "{{count}} sélectionné(s)",
"assignTo": "Attribuer à",
"dueAt": "Échéance",
"bulkApply": "Mettre à jour la file",
"bulkSaving": "Mise à jour…",
"bulkFailed": "La file de travail na pas pu être mise à jour.",
"clearSelection": "Effacer la sélection",
"selectIssue": "Sélectionner le problème {{ref}}",
"demoScenariosOnly": "Scénarios de démo uniquement",
"loading": "Chargement de l'atelier qualité…",
"queueClear": "La file est vide",
@@ -33,10 +46,13 @@
"evidenceBacked": "Détection étayée par des preuves",
"columns": {
"reference": "Référence",
"select": "Sélectionner",
"rule": "Règle",
"entity": "Entité",
"severity": "Gravité",
"status": "Statut"
"status": "Statut",
"assignee": "Responsable",
"due": "Échéance"
}
},
"ruleTypes": {
@@ -8,6 +8,18 @@
"searchPlaceholder": "Boeking, klant of voertuig",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"fromLabel": "Vanaf datum",
"toLabel": "Tot en met datum",
"locationLabel": "Locatie",
"locationPlaceholder": "Exacte standplaats",
"sortLabel": "Sortering",
"sortOperational": "Operationele prioriteit",
"sortAscending": "Startdatum oplopend",
"sortDescending": "Startdatum aflopend",
"presetsLabel": "Snelfilters",
"todayPreset": "Vandaag",
"upcomingPreset": "Aankomende reservaties",
"clearFilters": "Filters wissen",
"loading": "Boekingsoverzicht laden…",
"empty": "Geen boekingen gevonden",
"emptyDetail": "Pas het statusfilter voor boekingen aan.",
@@ -8,6 +8,15 @@
"statusLabel": "Status",
"statusAll": "Alle statussen",
"attentionOnly": "Enkel aandachtspunten",
"locationFilter": "Locatie",
"locationPlaceholder": "Exacte standplaats",
"serviceDue": "Onderhoud vervallen",
"serviceRemaining": "{{count}} km resterend",
"attentionReasons": {
"blocked_status": "Geblokkeerde status",
"service_due": "Onderhoud vervallen",
"data_quality": "Open kwaliteitsprobleem"
},
"loading": "Wagenparkregister laden…",
"empty": "Geen voertuigen gevonden",
"emptyDetail": "Pas de huidige wagenparkfilters aan.",
@@ -22,6 +31,8 @@
"location": "Locatie",
"status": "Status",
"odometer": "Kilometerstand (km)",
"service": "Onderhoud",
"nextBooking": "Volgende boeking",
"attention": "Aandacht"
},
"needsAttention": "Vraagt aandacht"
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "edit": "Bewerken", "editTitle": "Gebruiker {{ref}} bewerken", "newPassword": "Nieuw wachtwoord", "passwordUnchanged": "Leeg laten om niet te wijzigen", "saveChanges": "Wijzigingen opslaan", "cancel": "Annuleren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
}
+17 -1
View File
@@ -22,6 +22,19 @@
"ruleTypeAll": "Alle regeltypes",
"severityLabel": "Ernst",
"severityAll": "Alle ernstniveaus",
"assigneeLabel": "Toegewezen aan",
"assigneeAll": "Alle medewerkers",
"unassigned": "Niet toegewezen",
"overdueOnly": "Enkel over tijd",
"bulkTitle": "Geselecteerde werkvoorraad bijwerken",
"selected": "{{count}} geselecteerd",
"assignTo": "Toewijzen aan",
"dueAt": "Uiterste datum",
"bulkApply": "Werkvoorraad bijwerken",
"bulkSaving": "Bijwerken…",
"bulkFailed": "De werkvoorraad kon niet bijgewerkt worden.",
"clearSelection": "Selectie wissen",
"selectIssue": "Probleem {{ref}} selecteren",
"demoScenariosOnly": "Enkel demoscenario's",
"loading": "Kwaliteitswerkbank laden…",
"queueClear": "Wachtrij is leeg",
@@ -33,10 +46,13 @@
"evidenceBacked": "Evidentie-onderbouwde detectie",
"columns": {
"reference": "Referentie",
"select": "Selecteren",
"rule": "Regel",
"entity": "Entiteit",
"severity": "Ernst",
"status": "Status"
"status": "Status",
"assignee": "Behandelaar",
"due": "Uiterste datum"
}
},
"ruleTypes": {
+29 -1
View File
@@ -10,6 +10,17 @@ import { Pagination } from "../components/Pagination";
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
function localDateString(date = new Date()): string {
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}
function nextLocalDay(value: string): string {
const date = new Date(`${value}T12:00:00`);
date.setDate(date.getDate() + 1);
return localDateString(date);
}
export function Bookings() {
const { t } = useTranslation("bookings");
const { formatShortDate } = useLocaleFormat();
@@ -18,6 +29,10 @@ export function Bookings() {
const [error, setError] = useState<string | null>(null);
const status = searchParams.get("status") ?? "";
const query = searchParams.get("q") ?? "";
const startsFrom = searchParams.get("from") ?? "";
const startsTo = searchParams.get("to") ?? "";
const location = searchParams.get("location") ?? "";
const sort = searchParams.get("sort") ?? "operational";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateFilters(updates: Record<string, string | number | null>) {
@@ -35,11 +50,15 @@ export function Bookings() {
const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (status) params.set("status", status);
if (query) params.set("query", query);
if (startsFrom) params.set("starts_from", new Date(`${startsFrom}T00:00:00`).toISOString());
if (startsTo) params.set("starts_to", new Date(`${nextLocalDay(startsTo)}T00:00:00`).toISOString());
if (location) params.set("location", location);
params.set("sort", sort);
api
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`)
.then(setBookings)
.catch(() => setError(t("list.unavailable")));
}, [page, query, status, t]);
}, [page, query, status, startsFrom, startsTo, location, sort, t]);
return (
<div className="page">
@@ -57,7 +76,16 @@ export function Bookings() {
{STATUS_OPTIONS.map((value) => <option key={value} value={value}>{t(`statuses.${value}`)}</option>)}
</select>
</label>
<label>{t("list.fromLabel")}<input type="date" value={startsFrom} onChange={(event) => updateFilters({ from: event.target.value, page: 1 })} /></label>
<label>{t("list.toLabel")}<input type="date" value={startsTo} onChange={(event) => updateFilters({ to: event.target.value, page: 1 })} /></label>
<label>{t("list.locationLabel")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
<label>{t("list.sortLabel")}<select value={sort} onChange={(event) => updateFilters({ sort: event.target.value, page: 1 })}><option value="operational">{t("list.sortOperational")}</option><option value="starts_asc">{t("list.sortAscending")}</option><option value="starts_desc">{t("list.sortDescending")}</option></select></label>
</form>
<div className="filter-presets" aria-label={t("list.presetsLabel")}>
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: localDateString(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
</div>
{error && <ErrorState message={error} />}
{!error && !bookings && <LoadingState label={t("list.loading")} />}
+78 -3
View File
@@ -3,11 +3,12 @@ import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { DataQualityIssue, Page, ScanResult } from "../api/types";
import type { DataQualityIssue, Page, ScanResult, UserRecord } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
import { useLocaleFormat } from "../i18n/format";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -19,6 +20,7 @@ const RULE_TYPES = [
export function DataQuality() {
const { t } = useTranslation("quality");
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
@@ -26,12 +28,20 @@ export function DataQuality() {
const status = searchParams.get("status") ?? "open";
const ruleType = searchParams.get("rule_type") ?? "";
const severity = searchParams.get("severity") ?? "";
const assignee = searchParams.get("assignee") ?? "";
const overdueOnly = searchParams.get("overdue") === "true";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
const [users, setUsers] = useState<UserRecord[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkAssignee, setBulkAssignee] = useState("");
const [bulkDueAt, setBulkDueAt] = useState("");
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(null);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
@@ -50,18 +60,54 @@ export function DataQuality() {
if (status) params.set("status", status);
if (ruleType) params.set("rule_type", ruleType);
if (severity) params.set("severity", severity);
if (assignee) params.set("assigned_to_ref", assignee);
if (overdueOnly) params.set("overdue", "true");
params.set("page", String(page));
params.set("page_size", "25");
api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues)
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, page, user, t]);
}, [status, ruleType, severity, assignee, overdueOnly, page, user, t]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
api.get<UserRecord[]>("/api/v1/users").then((records) => setUsers(records.filter((record) => record.active))).catch(() => setUsers([]));
}, [user]);
async function applyBulkWork() {
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt)) return;
setBulkSaving(true);
setBulkError(null);
try {
await api.post("/api/v1/data-quality/issues/bulk-work", {
issue_refs: [...selected],
assigned_to_ref: bulkAssignee || undefined,
due_at: bulkDueAt ? new Date(bulkDueAt).toISOString() : undefined,
});
setSelected(new Set());
setBulkAssignee("");
setBulkDueAt("");
load();
} catch (err) {
setBulkError(describeApiError(t, err, "list.bulkFailed"));
} finally {
setBulkSaving(false);
}
}
function toggleSelected(ref: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(ref)) next.delete(ref); else next.add(ref);
return next;
});
}
async function handleScan() {
setScanError(null);
setScanning(true);
@@ -119,6 +165,7 @@ export function DataQuality() {
/>
<ApiErrorNotice error={scanError} />
<ApiErrorNotice error={bulkError} />
{scanResult && (
<p className="quiet-empty" role="status">
{t("list.scanComplete", {
@@ -142,6 +189,18 @@ export function DataQuality() {
<option value="rejected">{t("list.statusRejected")}</option>
</select>
</label>
<label>
{t("list.assigneeLabel")}
<select value={assignee} onChange={(e) => updateFilters({ assignee: e.target.value, page: 1 })}>
<option value="">{t("list.assigneeAll")}</option>
<option value="unassigned">{t("list.unassigned")}</option>
{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}
</select>
</label>
<label className="checkbox-label">
<input type="checkbox" checked={overdueOnly} onChange={(e) => updateFilters({ overdue: e.target.checked, page: 1 })} />
{t("list.overdueOnly")}
</label>
<label>
{t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
@@ -180,20 +239,34 @@ export function DataQuality() {
)}
{visibleIssues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<div className="table-shell">
{selected.size > 0 && (
<div className="bulk-toolbar" role="region" aria-label={t("list.bulkTitle")}>
<strong>{t("list.selected", { count: selected.size })}</strong>
<label>{t("list.assignTo")}<select value={bulkAssignee} onChange={(event) => setBulkAssignee(event.target.value)}><option value=""></option>{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}</select></label>
<label>{t("list.dueAt")}<input type="datetime-local" value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt)} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
</div>
)}
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col"><span className="visually-hidden">{t("list.columns.select")}</span></th>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.rule")}</th>
<th scope="col">{t("list.columns.entity")}</th>
<th scope="col">{t("list.columns.severity")}</th>
<th scope="col">{t("list.columns.status")}</th>
<th scope="col">{t("list.columns.assignee")}</th>
<th scope="col">{t("list.columns.due")}</th>
</tr>
</thead>
<tbody>
{visibleIssues.map((i) => (
<tr key={i.public_ref} className="row-clickable">
<td className="selection-cell"><input type="checkbox" checked={selected.has(i.public_ref)} onChange={() => toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /></td>
<th scope="row" data-label={t("list.columns.reference")}>
{i.public_ref}
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
@@ -206,6 +279,8 @@ export function DataQuality() {
<td data-label={t("list.columns.status")}>
<StatusBadge status={i.status} label={t(`list.status${i.status.charAt(0).toUpperCase()}${i.status.slice(1)}`, { defaultValue: i.status })} />
</td>
<td data-label={t("list.columns.assignee")}>{i.assigned_to_name ?? t("list.unassigned")}</td>
<td data-label={t("list.columns.due")} className={i.overdue ? "is-overdue" : ""}>{i.due_at ? <time dateTime={i.due_at}>{formatDateTime(i.due_at)}</time> : "—"}</td>
</tr>
))}
</tbody>
+115 -20
View File
@@ -16,37 +16,132 @@ export function Users() {
const [role, setRole] = useState<Role>("rental_employee");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [editingRef, setEditingRef] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editRole, setEditRole] = useState<Role>("rental_employee");
const [newPassword, setNewPassword] = useState("");
const load = useCallback(() => {
api.get<UserRecord[]>("/api/v1/users").then(setUsers).catch((err) => setError(describeApiError(t, err)));
api
.get<UserRecord[]>("/api/v1/users")
.then(setUsers)
.catch((err) => setError(describeApiError(t, err)));
}, [t]);
useEffect(load, [load]);
async function create(event: FormEvent) {
event.preventDefault(); setSaving(true); setError(null);
event.preventDefault();
setSaving(true);
setError(null);
try {
await api.post<UserRecord>("/api/v1/users", { email, display_name: displayName, password, role });
setDisplayName(""); setEmail(""); setPassword(""); setRole("rental_employee"); load();
} catch (err) { setError(describeApiError(t, err, "operations:users.createFailed")); }
finally { setSaving(false); }
await api.post<UserRecord>("/api/v1/users", {
email,
display_name: displayName,
password,
role,
});
setDisplayName("");
setEmail("");
setPassword("");
setRole("rental_employee");
load();
} catch (err) {
setError(describeApiError(t, err, "operations:users.createFailed"));
} finally {
setSaving(false);
}
}
function beginEdit(record: UserRecord) {
setEditingRef(record.public_ref);
setEditName(record.display_name);
setEditRole(record.role);
setNewPassword("");
}
async function saveEdit(event: FormEvent) {
event.preventDefault();
if (!editingRef) return;
setSaving(true);
setError(null);
try {
await api.patch(`/api/v1/users/${editingRef}`, {
display_name: editName,
role: editRole,
password: newPassword || undefined,
});
setEditingRef(null);
setNewPassword("");
load();
} catch (err) {
setError(describeApiError(t, err, "operations:users.updateFailed"));
} finally {
setSaving(false);
}
}
async function toggleActive(record: UserRecord) {
setError(null);
try { await api.patch(`/api/v1/users/${record.public_ref}`, { active: !record.active }); load(); }
catch (err) { setError(describeApiError(t, err, "operations:users.updateFailed")); }
try {
await api.patch(`/api/v1/users/${record.public_ref}`, { active: !record.active });
load();
} catch (err) {
setError(describeApiError(t, err, "operations:users.updateFailed"));
}
}
return <div className="page">
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} />
<ApiErrorNotice error={error} />
<section className="record-surface user-create"><h2>{t("users.addTitle")}</h2><form onSubmit={create}><div className="form-grid">
<label>{t("users.name")}<input required minLength={2} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
<label>{t("users.email")}<input required type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
<label>{t("users.role")}<select value={role} onChange={(event) => setRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
<label>{t("users.password")}<input required type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
</div><div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.add")}</button></div></form></section>
{!users && <LoadingState label={t("users.loading")} />}
{users && <div className="table-shell"><table className="data-table"><caption className="visually-hidden">{t("users.title")}</caption><thead><tr><th>{t("users.name")}</th><th>{t("users.email")}</th><th>{t("users.role")}</th><th>{t("users.status")}</th><th>{t("users.action")}</th></tr></thead><tbody>{users.map((record) => <tr key={record.public_ref}><th>{record.display_name}<span className="table-secondary">{record.public_ref}</span></th><td>{record.email ?? "—"}</td><td>{t(`roles.${record.role}`)}</td><td><span className={`badge ${record.active ? "status-available" : "status-blocked"}`}>{t(record.active ? "users.active" : "users.inactive")}</span></td><td><button type="button" className="button button-secondary" disabled={record.public_ref === currentUser?.public_ref} onClick={() => toggleActive(record)}>{t(record.active ? "users.deactivate" : "users.activate")}</button></td></tr>)}</tbody></table></div>}
</div>;
return (
<div className="page">
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} />
<ApiErrorNotice error={error} />
<section className="record-surface user-create">
<h2>{t("users.addTitle")}</h2>
<form onSubmit={create}>
<div className="form-grid">
<label>{t("users.name")}<input required minLength={2} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
<label>{t("users.email")}<input required type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
<label>{t("users.role")}<select value={role} onChange={(event) => setRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
<label>{t("users.password")}<input required type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
</div>
<div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.add")}</button></div>
</form>
</section>
{!users && <LoadingState label={t("users.loading")} />}
{users && (
<div className="table-shell">
<table className="data-table">
<caption className="visually-hidden">{t("users.title")}</caption>
<thead><tr><th>{t("users.name")}</th><th>{t("users.email")}</th><th>{t("users.role")}</th><th>{t("users.status")}</th><th>{t("users.action")}</th></tr></thead>
<tbody>
{users.map((record) => (
<tr key={record.public_ref}>
<th>{record.display_name}<span className="table-secondary">{record.public_ref}</span></th>
<td>{record.email ?? "—"}</td>
<td>{t(`roles.${record.role}`)}</td>
<td><span className={`badge ${record.active ? "status-available" : "status-blocked"}`}>{t(record.active ? "users.active" : "users.inactive")}</span></td>
<td><div className="table-actions"><button type="button" className="button button-secondary" onClick={() => beginEdit(record)}>{t("users.edit")}</button><button type="button" className="button button-secondary" disabled={record.public_ref === currentUser?.public_ref} onClick={() => toggleActive(record)}>{t(record.active ? "users.deactivate" : "users.activate")}</button></div></td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editingRef && (
<section className="record-surface user-edit" aria-labelledby="edit-user-title">
<h2 id="edit-user-title">{t("users.editTitle", { ref: editingRef })}</h2>
<form onSubmit={saveEdit}>
<div className="form-grid">
<label>{t("users.name")}<input required minLength={2} value={editName} onChange={(event) => setEditName(event.target.value)} /></label>
<label>{t("users.role")}<select value={editRole} disabled={editingRef === currentUser?.public_ref} onChange={(event) => setEditRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
<label>{t("users.newPassword")}<input type="password" minLength={8} autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} placeholder={t("users.passwordUnchanged")} /></label>
</div>
<div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.saveChanges")}</button><button type="button" className="button button-secondary" onClick={() => setEditingRef(null)}>{t("users.cancel")}</button></div>
</form>
</section>
)}
</div>
);
}
+10 -3
View File
@@ -12,13 +12,14 @@ const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "block
export function Vehicles() {
const { t } = useTranslation("fleet");
const { formatNumber } = useLocaleFormat();
const { formatDateTime, formatNumber } = useLocaleFormat();
const [searchParams, setSearchParams] = useSearchParams();
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
const [error, setError] = useState<string | null>(null);
const status = searchParams.get("status") ?? "";
const attentionOnly = searchParams.get("attention_only") === "true";
const query = searchParams.get("q") ?? "";
const location = searchParams.get("location") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
@@ -37,13 +38,14 @@ export function Vehicles() {
if (status) params.set("status", status);
if (attentionOnly) params.set("attention_only", "true");
if (query) params.set("query", query);
if (location) params.set("location", location);
params.set("page", String(page));
params.set("page_size", "25");
api
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles)
.catch(() => setError(t("list.unavailable")));
}, [status, attentionOnly, query, page, t]);
}, [status, attentionOnly, query, location, page, t]);
return (
<div className="page">
@@ -73,6 +75,7 @@ export function Vehicles() {
/>
{t("list.attentionOnly")}
</label>
<label>{t("list.locationFilter")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
</form>
{error && <ErrorState message={error} />}
@@ -88,6 +91,8 @@ export function Vehicles() {
<th scope="col">{t("list.columns.location")}</th>
<th scope="col">{t("list.columns.status")}</th>
<th scope="col">{t("list.columns.odometer")}</th>
<th scope="col">{t("list.columns.service")}</th>
<th scope="col">{t("list.columns.nextBooking")}</th>
<th scope="col">{t("list.columns.attention")}</th>
</tr>
</thead>
@@ -106,7 +111,9 @@ export function Vehicles() {
<StatusBadge status={v.operational_status} label={t(`statuses.${v.operational_status}`, { defaultValue: v.operational_status })} />
</td>
<td data-label={t("list.columns.odometer")}>{formatNumber(v.odometer_km)}</td>
<td data-label={t("list.columns.attention")}>{v.attention ? <span className="attention-flag">{t("list.needsAttention")}</span> : "—"}</td>
<td data-label={t("list.columns.service")} className={v.service_remaining_km <= 0 ? "is-overdue" : ""}>{v.service_remaining_km <= 0 ? t("list.serviceDue") : t("list.serviceRemaining", { count: formatNumber(v.service_remaining_km) })}</td>
<td data-label={t("list.columns.nextBooking")}>{v.next_booking_ref && v.next_booking_at ? <Link className="cell-link" to={`/bookings/${v.next_booking_ref}`}>{v.next_booking_ref}<span className="table-secondary">{formatDateTime(v.next_booking_at)}</span></Link> : "—"}</td>
<td data-label={t("list.columns.attention")}>{v.attention && v.attention_reason ? <span className="attention-flag">{t(`list.attentionReasons.${v.attention_reason}`)}</span> : "—"}</td>
</tr>
))}
</tbody>
+12
View File
@@ -406,6 +406,18 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.knowledge-feedback button { min-height: 34px; padding: 5px 10px; }
.knowledge-feedback button.is-selected { color: var(--teal-dark); border-color: var(--teal); background: var(--teal-pale); }
.bulk-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; padding: 12px 14px; background: var(--teal-pale); border-bottom: 1px solid #bfe6df; }
.bulk-toolbar strong { align-self: center; margin-right: auto; font-size: .74rem; }
.bulk-toolbar label { display: grid; gap: 4px; color: var(--muted); font-size: .62rem; font-weight: 700; }
.bulk-toolbar select, .bulk-toolbar input { min-height: 36px; }
.selection-cell { position: relative; z-index: 2; width: 38px; }
.selection-cell input { width: 16px; height: 16px; }
.is-overdue { color: var(--critical); font-weight: 700; }
.table-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.user-edit { margin-top: 18px; }
.filter-presets { display: flex; flex-wrap: wrap; gap: 7px; margin: -6px 0 18px; }
.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }