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
+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" && (