M12: complete daily operations cycle

This commit is contained in:
NuklearRabbit
2026-08-10 03:15:46 +02:00
parent 4a3c3bd0a9
commit 15bdbe40ac
32 changed files with 802 additions and 18 deletions
+2
View File
@@ -18,6 +18,7 @@ import { Knowledge } from "./pages/Knowledge";
import { Audit } from "./pages/Audit";
import { AboutDemo } from "./pages/AboutDemo";
import { Scenarios } from "./pages/Scenarios";
import { Users } from "./pages/Users";
export function App() {
return (
@@ -44,6 +45,7 @@ export function App() {
<Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} />
<Route path="/users" element={<Users />} />
<Route path="/about" element={<AboutDemo />} />
<Route path="/scenarios" element={<Scenarios />} />
</Route>
+2
View File
@@ -51,4 +51,6 @@ export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
};
+18
View File
@@ -66,6 +66,24 @@ export interface AvailableVehicle {
operational_status: string;
}
export interface CheckoutBookingResult {
booking_ref: string;
vehicle_ref: string;
inspection_ref: string;
booking_status: string;
resulting_vehicle_status: string;
activated: boolean;
attention_reasons: string[];
}
export interface UserRecord {
public_ref: string;
email: string | null;
display_name: string;
role: Role;
active: boolean;
}
export interface Inspection {
public_ref: string;
booking_ref: string;
+65
View File
@@ -0,0 +1,65 @@
import { FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { CheckoutBookingResult, VehicleDetail } from "../api/types";
import { ApiErrorNotice, LoadingState, SectionHeading } from "./PageChrome";
export function CheckoutForm({ bookingRef, vehicleRef, onRecorded }: { bookingRef: string; vehicleRef: string; onRecorded: (result: CheckoutBookingResult) => void }) {
const { t } = useTranslation(["bookings", "errors"]);
const [odometer, setOdometer] = useState<number | null>(null);
const [fuel, setFuel] = useState(100);
const [clean, setClean] = useState(true);
const [damage, setDamage] = useState(false);
const [warning, setWarning] = useState(false);
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
useEffect(() => {
api.get<VehicleDetail>(`/api/v1/vehicles/${vehicleRef}`)
.then((vehicle) => setOdometer(vehicle.odometer_km))
.catch((err) => setError(describeApiError(t, err)));
}, [t, vehicleRef]);
async function submit(event: FormEvent) {
event.preventDefault();
if (odometer === null) return;
setSaving(true);
setError(null);
try {
const result = await api.post<CheckoutBookingResult>(`/api/v1/bookings/${bookingRef}/checkout`, {
start_odometer_km: odometer,
fuel_level_percent: fuel,
cleanliness_ok: clean,
damage_reported: damage,
technical_warning: warning,
notes: notes || null,
});
onRecorded(result);
} catch (err) {
setError(describeApiError(t, err, "bookings:checkout.failed"));
} finally {
setSaving(false);
}
}
if (odometer === null && !error) return <LoadingState label={t("checkout.preparing")} />;
return <form className="return-form record-surface" onSubmit={submit}>
<SectionHeading title={t("checkout.title")} description={t("checkout.description")} />
<div className="return-capture">
<ApiErrorNotice error={error} />
<div className="form-grid">
<label>{t("checkout.odometer")}<input type="number" min={0} required value={odometer ?? ""} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
<label>{t("checkout.fuel")}<input type="number" min={0} max={100} required value={fuel} onChange={(event) => setFuel(Number(event.target.value))} /></label>
</div>
<fieldset className="condition-fieldset"><legend>{t("checkout.condition")}</legend>
<label className="check-card"><input type="checkbox" checked={clean} onChange={(event) => setClean(event.target.checked)} /> {t("checkout.clean")}</label>
<label className="check-card"><input type="checkbox" checked={damage} onChange={(event) => setDamage(event.target.checked)} /> {t("checkout.damage")}</label>
<label className="check-card"><input type="checkbox" checked={warning} onChange={(event) => setWarning(event.target.checked)} /> {t("checkout.warning")}</label>
</fieldset>
<label>{t("checkout.notes")}<textarea maxLength={2000} value={notes} onChange={(event) => setNotes(event.target.value)} /></label>
</div>
<div className="form-actions"><button type="submit" className="button button-primary" disabled={saving || odometer === null}>{saving ? t("checkout.saving") : t("checkout.submit")}</button></div>
</form>;
}
+1
View File
@@ -69,6 +69,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
],
},
];
@@ -0,0 +1,56 @@
import { FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { MaintenanceRecord, VehicleDetail } from "../api/types";
import { ApiErrorNotice } from "./PageChrome";
export function VehicleMaintenanceActions({ vehicle, onSaved }: { vehicle: VehicleDetail; onSaved: () => void }) {
const { t } = useTranslation(["fleet", "errors"]);
const [showRecord, setShowRecord] = useState(false);
const [occurredAt, setOccurredAt] = useState(() => new Date().toISOString().slice(0, 10));
const [odometer, setOdometer] = useState(vehicle.odometer_km);
const [category, setCategory] = useState("periodic_service");
const [summary, setSummary] = useState("");
const [nextService, setNextService] = useState(vehicle.next_service_km);
const [releaseReason, setReleaseReason] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
async function recordMaintenance(event: FormEvent) {
event.preventDefault(); setSaving(true); setError(null);
try {
await api.post<MaintenanceRecord>(`/api/v1/vehicles/${vehicle.public_ref}/maintenance`, {
occurred_at: new Date(`${occurredAt}T12:00:00`).toISOString(), odometer_km: odometer,
category, summary, next_service_km: nextService, mark_maintenance: true,
});
setShowRecord(false); setSummary(""); onSaved();
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.failed")); }
finally { setSaving(false); }
}
async function release(event: FormEvent) {
event.preventDefault(); setSaving(true); setError(null);
try {
await api.post(`/api/v1/vehicles/${vehicle.public_ref}/release`, { reason: releaseReason });
setReleaseReason(""); onSaved();
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.releaseFailed")); }
finally { setSaving(false); }
}
return <section className="record-surface maintenance-actions">
<div className="section-heading"><div><h2>{t("maintenance.actionsTitle")}</h2><p>{t("maintenance.actionsDescription")}</p></div>{!showRecord && <button type="button" className="button button-secondary" onClick={() => setShowRecord(true)}>{t("maintenance.add")}</button>}</div>
<ApiErrorNotice error={error} />
{showRecord && <form onSubmit={recordMaintenance}>
<div className="form-grid">
<label>{t("maintenance.date")}<input type="date" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
<label>{t("maintenance.odometer")}<input type="number" min={vehicle.odometer_km} required value={odometer} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
<label>{t("maintenance.category")}<select value={category} onChange={(event) => setCategory(event.target.value)}>{["periodic_service", "repair", "inspection", "tyres", "other"].map((value) => <option value={value} key={value}>{t(`detail.maintenanceCategories.${value}`, { defaultValue: value })}</option>)}</select></label>
<label>{t("maintenance.nextService")}<input type="number" min={odometer} required value={nextService} onChange={(event) => setNextService(Number(event.target.value))} /></label>
</div>
<label>{t("maintenance.summary")}<textarea required minLength={3} maxLength={2000} value={summary} onChange={(event) => setSummary(event.target.value)} /></label>
<div className="form-actions"><button type="button" className="button button-secondary" onClick={() => setShowRecord(false)}>{t("maintenance.cancel")}</button><button className="button button-primary" disabled={saving}>{saving ? t("maintenance.saving") : t("maintenance.save")}</button></div>
</form>}
{["cleaning", "maintenance", "blocked"].includes(vehicle.operational_status) && <form className="release-form" onSubmit={release}><label>{t("maintenance.releaseReason")}<input required minLength={3} maxLength={500} value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder={t("maintenance.releasePlaceholder")} /></label><button className="button button-primary" disabled={saving || releaseReason.trim().length < 3}>{t("maintenance.release")}</button></form>}
</section>;
}
+7
View File
@@ -15,6 +15,7 @@ import auditNl from "./locales/nl-BE/audit.json";
import demoNl from "./locales/nl-BE/demo.json";
import errorsNl from "./locales/nl-BE/errors.json";
import accessibilityNl from "./locales/nl-BE/accessibility.json";
import operationsNl from "./locales/nl-BE/operations.json";
import commonEn from "./locales/en-GB/common.json";
import authEn from "./locales/en-GB/auth.json";
@@ -30,6 +31,7 @@ import auditEn from "./locales/en-GB/audit.json";
import demoEn from "./locales/en-GB/demo.json";
import errorsEn from "./locales/en-GB/errors.json";
import accessibilityEn from "./locales/en-GB/accessibility.json";
import operationsEn from "./locales/en-GB/operations.json";
import commonFr from "./locales/fr-BE/common.json";
import authFr from "./locales/fr-BE/auth.json";
@@ -45,6 +47,7 @@ import auditFr from "./locales/fr-BE/audit.json";
import demoFr from "./locales/fr-BE/demo.json";
import errorsFr from "./locales/fr-BE/errors.json";
import accessibilityFr from "./locales/fr-BE/accessibility.json";
import operationsFr from "./locales/fr-BE/operations.json";
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
@@ -66,6 +69,7 @@ export const NAMESPACES = [
"demo",
"errors",
"accessibility",
"operations",
] as const;
function readStoredLanguage(): SupportedLanguage {
@@ -117,6 +121,7 @@ void i18n
demo: demoNl,
errors: errorsNl,
accessibility: accessibilityNl,
operations: operationsNl,
},
"en-GB": {
common: commonEn,
@@ -133,6 +138,7 @@ void i18n
demo: demoEn,
errors: errorsEn,
accessibility: accessibilityEn,
operations: operationsEn,
},
"fr-BE": {
common: commonFr,
@@ -149,6 +155,7 @@ void i18n
demo: demoFr,
errors: errorsFr,
accessibility: accessibilityFr,
operations: operationsFr,
},
},
});
@@ -57,6 +57,25 @@
"cancelled": "cancelled",
"blocked": "blocked"
},
"checkout": {
"title": "Check out vehicle",
"description": "Record departure condition. Any exception automatically blocks the rental for follow-up.",
"preparing": "Preparing departure inspection…",
"odometer": "Start odometer",
"fuel": "Fuel level (%)",
"condition": "Departure condition",
"clean": "Vehicle is clean",
"damage": "Damage found",
"warning": "Technical warning",
"notes": "Notes",
"submit": "Record inspection and start rental",
"saving": "Recording checkout…",
"failed": "The departure inspection could not be recorded.",
"activatedTitle": "Rental started",
"activatedDetail": "Departure inspection {{inspection}} was saved and the vehicle is now rented.",
"blockedTitle": "Rental blocked",
"blockedDetail": "Departure inspection {{inspection}} contains an exception. The vehicle was safely removed from service."
},
"detail": {
"backLink": "Booking ledger",
"eyebrow": "Bookings / Rental record",
+6 -2
View File
@@ -70,7 +70,11 @@
},
"maintenanceCategories": {
"periodic_service": "Periodic service",
"repair": "Repair"
"repair": "Repair",
"inspection": "Technical inspection",
"tyres": "Tyres",
"other": "Other"
}
}
},
"maintenance": { "actionsTitle": "Maintenance actions", "actionsDescription": "Record completed work or release a safe vehicle back into service.", "add": "Record maintenance", "date": "Date", "odometer": "Odometer", "category": "Category", "nextService": "Next service (km)", "summary": "Work completed", "cancel": "Cancel", "save": "Save maintenance", "saving": "Saving…", "failed": "Maintenance could not be saved.", "releaseReason": "Release reason", "releasePlaceholder": "Which check confirms the vehicle is safe for service?", "release": "Release vehicle", "releaseFailed": "The vehicle cannot be released yet." }
}
@@ -41,7 +41,8 @@
"quality": "Quality workbench",
"knowledge": "Procedure assistant",
"integrations": "Automation and integration status",
"audit": "Audit history"
"audit": "Audit history",
"users": "Users"
},
"switchRole": "Switch role",
"switchRoleTitle": "Switch demo role",
@@ -0,0 +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." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" }
}
@@ -57,6 +57,25 @@
"cancelled": "annulé",
"blocked": "bloqué"
},
"checkout": {
"title": "Remettre le véhicule",
"description": "Enregistrez l’état de départ. Toute anomalie bloque automatiquement la location pour suivi.",
"preparing": "Préparation de linspection de départ…",
"odometer": "Kilométrage de départ",
"fuel": "Niveau de carburant (%)",
"condition": "État au départ",
"clean": "Le véhicule est propre",
"damage": "Dommage constaté",
"warning": "Alerte technique",
"notes": "Notes",
"submit": "Enregistrer linspection et démarrer la location",
"saving": "Enregistrement du départ…",
"failed": "Linspection de départ na pas pu être enregistrée.",
"activatedTitle": "Location démarrée",
"activatedDetail": "Linspection {{inspection}} est enregistrée et le véhicule est loué.",
"blockedTitle": "Location bloquée",
"blockedDetail": "Linspection {{inspection}} contient une anomalie. Le véhicule a été retiré du service en toute sécurité."
},
"detail": {
"backLink": "Registre des réservations",
"eyebrow": "Réservations / Fiche de location",
+6 -2
View File
@@ -70,7 +70,11 @@
},
"maintenanceCategories": {
"periodic_service": "Entretien périodique",
"repair": "Réparation"
"repair": "Réparation",
"inspection": "Contrôle technique",
"tyres": "Pneus",
"other": "Autre"
}
}
},
"maintenance": { "actionsTitle": "Actions dentretien", "actionsDescription": "Enregistrez les travaux effectués ou remettez un véhicule sûr en service.", "add": "Enregistrer un entretien", "date": "Date", "odometer": "Kilométrage", "category": "Catégorie", "nextService": "Prochain entretien (km)", "summary": "Travaux effectués", "cancel": "Annuler", "save": "Enregistrer", "saving": "Enregistrement…", "failed": "Lentretien na pas pu être enregistré.", "releaseReason": "Motif de remise en service", "releasePlaceholder": "Quel contrôle confirme que le véhicule peut être remis en service ?", "release": "Remettre en service", "releaseFailed": "Le véhicule ne peut pas encore être remis en service." }
}
@@ -41,7 +41,8 @@
"quality": "Atelier qualité",
"knowledge": "Assistant de procédures",
"integrations": "Statut d'automatisation et d'intégration",
"audit": "Historique d'audit"
"audit": "Historique daudit",
"users": "Utilisateurs"
},
"switchRole": "Changer de rôle",
"switchRoleTitle": "Changer de rôle de démo",
@@ -0,0 +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." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Employé de location" }
}
@@ -57,6 +57,25 @@
"cancelled": "geannuleerd",
"blocked": "geblokkeerd"
},
"checkout": {
"title": "Voertuig uitchecken",
"description": "Leg de vertrekstaat vast. Een afwijking blokkeert de huur automatisch voor opvolging.",
"preparing": "Vertrekinspectie voorbereiden…",
"odometer": "Startkilometerstand",
"fuel": "Brandstofniveau (%)",
"condition": "Vertrekstaat",
"clean": "Voertuig is schoon",
"damage": "Schade vastgesteld",
"warning": "Technische melding",
"notes": "Notities",
"submit": "Inspectie vastleggen en huur starten",
"saving": "Vertrek vastleggen…",
"failed": "De vertrekinspectie kon niet worden vastgelegd.",
"activatedTitle": "Huur is gestart",
"activatedDetail": "Vertrekinspectie {{inspection}} is opgeslagen en het voertuig staat op verhuurd.",
"blockedTitle": "Huur is geblokkeerd",
"blockedDetail": "Vertrekinspectie {{inspection}} bevat een afwijking. Het voertuig is veilig uit inzet genomen."
},
"detail": {
"backLink": "Boekingsoverzicht",
"eyebrow": "Boekingen / Huurrecord",
+6 -2
View File
@@ -70,7 +70,11 @@
},
"maintenanceCategories": {
"periodic_service": "Periodiek onderhoud",
"repair": "Herstelling"
"repair": "Herstelling",
"inspection": "Technische inspectie",
"tyres": "Banden",
"other": "Overig"
}
}
},
"maintenance": { "actionsTitle": "Onderhoudsacties", "actionsDescription": "Registreer uitgevoerd werk of geef een veilig voertuig opnieuw vrij.", "add": "Onderhoud registreren", "date": "Datum", "odometer": "Kilometerstand", "category": "Categorie", "nextService": "Volgend onderhoud (km)", "summary": "Uitgevoerd werk", "cancel": "Annuleren", "save": "Onderhoud opslaan", "saving": "Opslaan…", "failed": "Het onderhoud kon niet worden opgeslagen.", "releaseReason": "Reden voor vrijgave", "releasePlaceholder": "Welke controle bevestigt dat het voertuig veilig inzetbaar is?", "release": "Voertuig vrijgeven", "releaseFailed": "Het voertuig kan nog niet worden vrijgegeven." }
}
@@ -11,7 +11,8 @@
"quality": "Datakwaliteit",
"knowledge": "Kennis",
"integrations": "Integraties",
"audit": "Auditgeschiedenis"
"audit": "Auditgeschiedenis",
"users": "Gebruikers"
},
"primaryNavLabel": "Hoofdnavigatie",
"mobileNavLabel": "Mobiele navigatie",
@@ -0,0 +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." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Verhuurmedewerker" }
}
+11 -1
View File
@@ -2,7 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types";
import type { Booking, CheckoutBookingResult, RegisterReturnResult, VehicleDetail } from "../api/types";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
@@ -12,6 +12,7 @@ import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome";
import { CheckoutForm } from "../components/CheckoutForm";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
@@ -25,6 +26,7 @@ export function BookingDetail() {
const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const load = useCallback(() => {
if (!publicRef) return;
@@ -77,6 +79,11 @@ export function BookingDetail() {
}
}
function handleCheckout(result: CheckoutBookingResult) {
setCheckoutResult(result);
load();
}
if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label={t("detail.loading")} />;
@@ -101,6 +108,9 @@ export function BookingDetail() {
<div className="form-actions"><button className="button button-danger" type="submit" disabled={cancelling || cancelReason.trim().length < 3}>{cancelling ? t("detail.cancelling") : t("detail.confirmCancel")}</button></div>
</form>}
{checkoutResult && <section className={`record-surface checkout-result ${checkoutResult.activated ? "success" : "warning"}`} role="status"><h2>{t(checkoutResult.activated ? "checkout.activatedTitle" : "checkout.blockedTitle")}</h2><p>{t(checkoutResult.activated ? "checkout.activatedDetail" : "checkout.blockedDetail", { inspection: checkoutResult.inspection_ref })}</p></section>}
{!checkoutResult && booking.status === "reserved" && <CheckoutForm bookingRef={booking.public_ref} vehicleRef={booking.vehicle_ref} onRecorded={handleCheckout} />}
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
<Icon name="spark" />
+52
View File
@@ -0,0 +1,52 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { Role, UserRecord } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { ApiErrorNotice, LoadingState, PageHeader } from "../components/PageChrome";
export function Users() {
const { t } = useTranslation(["operations", "errors"]);
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<UserRecord[] | null>(null);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("rental_employee");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const load = useCallback(() => {
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);
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); }
}
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")); }
}
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>;
}
+10 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -7,6 +7,8 @@ import { useLocaleFormat } from "../i18n/format";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { VehicleMaintenanceActions } from "../components/VehicleMaintenanceActions";
import { useAuth } from "../context/AuthContext";
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
type Tab = (typeof TABS)[number];
@@ -18,16 +20,18 @@ export function VehicleDetail() {
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
const { user } = useAuth();
useEffect(() => {
const load = useCallback(() => {
if (!publicRef) return;
setVehicle(null);
setError(null);
api
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
.then(setVehicle)
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
}, [publicRef, t]);
useEffect(() => { setVehicle(null); load(); }, [load]);
if (error) return <ErrorState message={error} />;
if (!vehicle) return <LoadingState label={t("detail.loading")} />;
@@ -95,7 +99,7 @@ export function VehicleDetail() {
)}
{tab === "maintenance" && (
<ul className="record-list">
<><ul className="record-list">
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
{vehicle.maintenance.map((m) => (
<li key={m.public_ref}>
@@ -104,7 +108,7 @@ export function VehicleDetail() {
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
</li>
))}
</ul>
</ul>{user?.role === "operations_manager" && <VehicleMaintenanceActions vehicle={vehicle} onSaved={load} />}</>
)}
{tab === "quality" && (
+13
View File
@@ -280,6 +280,19 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.booking-cancel-form h2 { margin: 0 0 14px; font-size: 1rem; }
.booking-cancel-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
.booking-cancel-form textarea { min-height: 92px; padding: 10px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.checkout-result { margin-top: 18px; padding: 18px 22px; }
.checkout-result h2 { margin: 0 0 6px; font-size: 1rem; }
.checkout-result p { margin: 0; color: var(--ink-soft); }
.checkout-result.success { border-left: 4px solid var(--success); }
.checkout-result.warning { border-left: 4px solid var(--warning); }
.maintenance-actions, .user-create { margin-top: 18px; padding: 20px; }
.maintenance-actions > form > label, .release-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
.maintenance-actions textarea { min-height: 92px; padding: 10px 11px; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.release-form { display: flex; align-items: end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--line); }
.release-form label { flex: 1; }
.release-form input { min-height: 44px; padding: 8px 11px; border: 1px solid var(--line-strong); border-radius: var(--radius); }
.user-create h2 { margin: 0 0 14px; font-size: 1rem; }
.table-secondary { display: block; color: var(--ink-soft); font-size: var(--type-meta); font-weight: 400; }
.condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); }
.review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; }