M18: implement operational workspaces
This commit is contained in:
@@ -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")} />}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user