M12: complete daily operations cycle
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’inspection 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 l’inspection et démarrer la location",
|
||||
"saving": "Enregistrement du départ…",
|
||||
"failed": "L’inspection de départ n’a pas pu être enregistrée.",
|
||||
"activatedTitle": "Location démarrée",
|
||||
"activatedDetail": "L’inspection {{inspection}} est enregistrée et le véhicule est loué.",
|
||||
"blockedTitle": "Location bloquée",
|
||||
"blockedDetail": "L’inspection {{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",
|
||||
|
||||
@@ -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 d’entretien", "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": "L’entretien n’a 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 d’audit",
|
||||
"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",
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user