Implement intercity document handling and rejection notes for contracts

This commit is contained in:
Marshal
2026-07-21 10:20:36 +00:00
226 changed files with 13854 additions and 2973 deletions

View File

@@ -0,0 +1,300 @@
import { type FormEvent, useState } from "react";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import {
requestPasswordResetRequest,
resetPasswordRequest,
verifyPasswordResetOtpRequest,
} from "@/auth/api";
import type { ResetTicket } from "@/auth/types";
import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { useResendCooldown } from "@/hooks/useResendCooldown";
import { normaliseIdentifier } from "@/utils/identifier";
import { meetsAllRequirements } from "@/utils/passwordSchema";
import { extractApiError } from "@/utils/result";
type Stage = "identify" | "otp" | "password";
const ForgotPasswordPage = () => {
const navigate = useNavigate();
const [stage, setStage] = useState<Stage>("identify");
const [identifier, setIdentifier] = useState("");
const [otpCode, setOtpCode] = useState("");
// The reset ticket lives in memory only — persisting it would leave a
// password-change credential sitting in localStorage.
const [ticket, setTicket] = useState<ResetTicket | null>(null);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(null);
const resendCooldown = useResendCooldown();
/** The identifier as the API will see it — normalised once, reused everywhere. */
const normalised = normaliseIdentifier(identifier);
const sendCode = async () => {
await requestPasswordResetRequest({ identifier: normalised });
setOtpCode("");
resendCooldown.start();
};
// Stage 1 — ask for a code. The API answers identically for unknown accounts,
// so we always advance; a non-existent identifier simply never receives a code.
const handleIdentify = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSending(true);
try {
await sendCode();
setStage("otp");
} catch (err) {
setError(extractApiError(err).message);
} finally {
setSending(false);
}
};
const handleResend = async () => {
setError(null);
setSending(true);
try {
await sendCode();
} catch (err) {
setError(extractApiError(err).message);
} finally {
setSending(false);
}
};
// Stage 2 — trade the code for a single-use ticket.
const handleVerify = async () => {
setError(null);
if (otpCode.trim().length !== OTP_LENGTH) {
setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
return;
}
setVerifying(true);
try {
const result = await verifyPasswordResetOtpRequest({
identifier: normalised,
otp: otpCode.trim(),
});
setTicket(result);
setStage("password");
} catch (err) {
setError(extractApiError(err).message);
} finally {
setVerifying(false);
}
};
// Stage 3 — spend the ticket on IAM's set-password.
const handleReset = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (!ticket) {
setError("Your reset session expired. Start again.");
setStage("identify");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
setVerifying(true);
try {
await resetPasswordRequest({
userId: ticket.userId,
// The API matches this against email / username / phone, so the typed
// identifier works regardless of which one it is.
email: normalised,
verificationCode: ticket.verificationCode,
newPassword: password,
confirmPassword,
});
navigate("/auth", {
replace: true,
state: { passwordReset: true },
});
} catch (err) {
setError(extractApiError(err).message);
} finally {
setVerifying(false);
}
};
return (
<AuthShell
tagline="Recover your account"
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email and phone."
>
<div className="flex w-full flex-col">
{stage === "identify" ? (
<form onSubmit={handleIdentify} className="flex w-full flex-col">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<KeyRound size={22} />
</span>
</div>
<div className="mb-4 mt-3 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Forgot your password?
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Enter your email or phone number and we&apos;ll send you a code to
reset it.
</p>
</div>
<Stack gap="md">
<TextInput
label="Email or Phone"
placeholder="name@company.com or 09XXXXXXXX"
autoComplete="username"
required
disabled={sending}
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
/>
<p className="text-xs text-gray-500">
The code goes to the email and phone on your account, which may
differ from what you typed above.
</p>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={sending}
disabled={!identifier.trim()}
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
>
Send code
</Button>
<p className="text-center text-sm text-gray-500">
Remembered it?{" "}
<Link to="/auth" className="font-semibold text-primary hover:underline">
Back to sign in
</Link>
</p>
</Stack>
</form>
) : null}
{stage === "otp" ? (
<OtpChannelStep
value={otpCode}
onChange={setOtpCode}
onVerify={handleVerify}
onBack={() => {
setStage("identify");
setError(null);
}}
onResend={handleResend}
resendIn={resendCooldown.secondsLeft}
sending={sending}
verifying={verifying}
error={error}
title="Enter your reset code"
description="Enter it to choose a new password."
submitLabel="Verify code"
/>
) : null}
{stage === "password" ? (
<form onSubmit={handleReset} className="flex w-full flex-col">
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Choose a new password
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Pick something strong you haven&apos;t used before.
</p>
</div>
<Stack gap="md">
<div>
<PasswordInput
label="New password"
placeholder="Create a strong password"
required
disabled={verifying}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<PasswordChecklist value={password} />
</div>
<PasswordInput
label="Confirm new password"
placeholder="Re-enter your password"
required
disabled={verifying}
error={
confirmPassword && confirmPassword !== password
? "Passwords do not match"
: undefined
}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
/>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={verifying}
disabled={
verifying ||
!meetsAllRequirements(password) ||
password !== confirmPassword
}
>
Reset password
</Button>
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={verifying}
onClick={() => {
setStage("otp");
setError(null);
}}
>
Back
</Button>
</Stack>
</form>
) : null}
</div>
</AuthShell>
);
};
export default ForgotPasswordPage;

View File

@@ -14,25 +14,13 @@ import {
Title,
} from "@mantine/core";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import AuthShell from "@/components/auth/AuthShell";
import { normaliseIdentifier } from "@/utils/identifier";
import { extractApiError } from "@/utils/result";
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
const normaliseIdentifier = (raw: string): string => {
const v = raw.trim();
const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
const local = digits.startsWith("251")
? digits.slice(3)
: digits.replace(/^0/, "");
return `+251${local}`;
}
return v.toLowerCase();
};
const EDR_LOGO = "/assets/logo.svg";
const LoginPage = () => {
@@ -111,14 +99,24 @@ const LoginPage = () => {
onChange={(event) => setIdentifier(event.target.value)}
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
required
disabled={submitting}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium">Password</span>
<Link
to="/forgot-password"
className="text-xs font-semibold text-primary hover:underline"
>
Forgot password?
</Link>
</div>
<PasswordInput
placeholder="Enter your password"
required
disabled={submitting}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>

View File

@@ -260,6 +260,8 @@ export default function BookingRequestDetailPage() {
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
paymentStatus={booking.paymentStatus}
tradeDirection={booking.tradeDirection}
/>
</Box>
<BookingActionsToolbar

View File

@@ -27,11 +27,12 @@ import {
IdCard,
LayoutGrid,
Package,
FilePen,
Paperclip,
Receipt,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
@@ -46,6 +47,7 @@ import {
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
RequestDocumentChangeModal,
ResetPasswordAction,
TableCard,
formatBytes,
@@ -179,6 +181,10 @@ export default function CustomerDetailPage() {
const stillOnboarding = company ? isOnboardingDraft(company) : false;
const canReview = company ? hasSubmittedOnboarding(company) : true;
/** Document the reviewer is asking the customer to correct; null = closed. */
const [changeRequestDoc, setChangeRequestDoc] =
useState<CustomerDocument | null>(null);
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
() => [
{
@@ -355,14 +361,31 @@ export default function CustomerDetailPage() {
{
id: "name",
header: "Document",
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<FileText size={16} className="shrink-0 text-edr-muted" />
<Text size="sm" c="edr-text" truncate>
{row.original.name}
</Text>
</Group>
),
cell: ({ row }) => {
const doc = row.original;
return (
<Stack gap={2}>
<Group gap="sm" wrap="nowrap">
<FileText size={16} className="shrink-0 text-edr-muted" />
<Text size="sm" c="edr-text" truncate>
{doc.name}
</Text>
{doc.reviewStatus === "change_requested" && (
<Badge size="xs" color="orange" variant="light">
Change requested
</Badge>
)}
</Group>
{/* The note is the whole point of the request — show it inline so a
second reviewer sees what was already asked for. */}
{doc.reviewStatus === "change_requested" && doc.reviewNote && (
<Text size="xs" c="dimmed" pl={24}>
{doc.reviewNote}
</Text>
)}
</Stack>
);
},
},
{
id: "code",
@@ -423,11 +446,29 @@ export default function CustomerDetailPage() {
>
<Download size={16} />
</ActionIcon>
{canReview && (
<ActionIcon
component="button"
type="button"
variant="subtle"
color="orange"
aria-label="Request change"
title={
row.original.reviewStatus === "change_requested"
? "Update the requested change"
: "Request a change from the customer"
}
data-stop-row-click
onClick={() => setChangeRequestDoc(row.original)}
>
<FilePen size={16} />
</ActionIcon>
)}
</Group>
),
},
],
[view],
[view, canReview],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
@@ -1063,6 +1104,12 @@ export default function CustomerDetailPage() {
</Tabs.Panel>
</Tabs>
<RequestDocumentChangeModal
document={changeRequestDoc}
companyId={company.id}
onClose={() => setChangeRequestDoc(null)}
/>
{viewer}
</PageContainer>
);

View File

@@ -5,6 +5,7 @@ import {
Card,
Group,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
@@ -16,6 +17,7 @@ import {
Building2,
CheckCircle2,
Clock,
FilePen,
Hourglass,
Mail,
Phone,
@@ -30,7 +32,6 @@ import { useNavigate } from "react-router-dom";
import {
CompanyStatusBadge,
CompanyTypeBadge,
ProfileChips,
formatDate,
} from "@/components/customers";
@@ -51,36 +52,67 @@ import {
* wizard's first click and would otherwise pad the review queue. Those drafts
* get their own view instead of disappearing, so staff can still chase them.
*/
type CustomerView = "all" | "pending" | "onboarding" | "active";
type CustomerView =
| "all"
| "pending"
| "pendingChanges"
| "onboarding"
| "active";
/**
* "Pending changes" is deliberately not folded into "Pending approval". A
* customer who edits their profile after being approved stays `status = active`,
* so the pending filter can never match them — their resubmission would only
* ever be visible by opening their detail page. This view is that queue.
*/
const VIEW_FILTERS: Record<
CustomerView,
{ status?: CompanyStatus; onboardingCompleted?: boolean }
{
status?: CompanyStatus;
onboardingCompleted?: boolean;
hasPendingChangeRequest?: boolean;
}
> = {
all: {},
pending: { status: "pending", onboardingCompleted: true },
pendingChanges: { hasPendingChangeRequest: true },
onboarding: { onboardingCompleted: false },
active: { status: "active" },
};
const SORT_OPTIONS = [
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
{ value: "name:ASC", label: "Name (AZ)" },
{ value: "name:DESC", label: "Name (ZA)" },
] as const;
export default function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [view, setView] = useState<CustomerView>("all");
const [sort, setSort] = useState<string>("createdAt:DESC");
const filter = useMemo(
() => ({
const filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [
"name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
sortBy,
sortOrder,
...VIEW_FILTERS[view],
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
);
};
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.customers.list.queryOptions({ input: { filter } }),
@@ -113,7 +145,6 @@ export default function CustomersPage() {
<Text fw={600} c="edr-text" truncate>
{c.name}
</Text>
<CompanyTypeBadge type={c.type} />
</Group>
<Text size="xs" c="dimmed">
TIN {c.tin}
@@ -127,7 +158,9 @@ export default function CustomersPage() {
{
id: "profiles",
header: "Profiles",
cell: ({ row }) => <ProfileChips profiles={row.original.companyProfiles} />,
cell: ({ row }) => (
<ProfileChips profiles={row.original.companyProfiles} />
),
},
{
id: "status",
@@ -233,9 +266,30 @@ export default function CustomersPage() {
<KpiStrip
items={[
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
{
label: "Companies",
value: stats?.total ?? "—",
icon: Users,
color: "edr-green",
},
{
label: "Active",
value: stats?.active ?? "—",
icon: CheckCircle2,
color: "edr-green",
},
{
label: "Pending",
value: stats?.pending ?? "—",
icon: Clock,
color: "yellow",
},
{
label: "Pending changes",
value: stats?.pendingChanges ?? "—",
icon: FilePen,
color: "yellow",
},
{
label: "Onboarding",
value: stats?.onboarding ?? "—",
@@ -287,10 +341,25 @@ export default function CustomersPage() {
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Pending changes", value: "pendingChanges" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
/>
<Select
size="sm"
radius="md"
w={160}
allowDeselect={false}
aria-label="Sort customers"
value={sort}
onChange={(v) => {
if (!v) return;
setSort(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={SORT_OPTIONS.map((o) => ({ ...o }))}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
@@ -299,39 +368,39 @@ export default function CustomersPage() {
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={980}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
debouncedQuery
? "No companies match your search."
: "No companies yet."
}
error={
isError
? {
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
debouncedQuery
? "No companies match your search."
: "No companies yet."
}
error={
isError
? {
message: "Failed to load customers.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>

View File

@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core';
import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/auth/http';
@@ -51,28 +52,46 @@ interface StatCardProps {
value: string | number;
color?: string;
change?: number;
/** Detail route the card opens. When set the card is a link; otherwise static. */
href?: string;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}` }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change, href }: StatCardProps) => {
const card = (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}`, height: '100%' }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
</Stack>
</Card>
);
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
</Group>
</Stack>
</Card>
);
// Wrap in a link to the detail view rather than morphing the Card itself —
// keeps Mantine's Card typing clean. Static when no href.
return href ? (
<Link
to={href}
aria-label={`${label} — view detail`}
className="block h-full cursor-pointer no-underline transition-opacity hover:opacity-90"
>
{card}
</Link>
) : (
card
);
};
export function FleetDashboard() {
const { data: vehicles = [] } = useQuery({
@@ -160,16 +179,16 @@ export function FleetDashboard() {
{/* Primary Metrics */}
<Grid mb="xl">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" />
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" href="/dashboard/vehicles" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" />
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" href="/dashboard/drivers" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" />
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" href="/dashboard/fuel-purchases" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" />
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" href="/dashboard/maintenance" />
</Grid.Col>
</Grid>

View File

@@ -1,8 +1,11 @@
import {
SUPPORT_ATTACHMENT_ACCEPT,
SupportAuthorRole,
type SupportAttachmentDto,
type SupportConversationDto,
type SupportMessageDto,
} from "@edr/types";
import { useFileViewer } from "@edr/ui-common";
import {
ActionIcon,
Avatar,
@@ -23,10 +26,22 @@ import {
ThemeIcon,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Building2, Headset, Plus, Search, Send, User } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Building2,
Headset,
Paperclip,
Plus,
Search,
Send,
User,
} from "lucide-react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useLazyAttachmentObjectUrl } from "@/features/support/useAttachmentObjectUrl";
import { AttachmentDraftBar } from "@/features/support/AttachmentDraftBar";
import { MessageAttachments } from "@/features/support/MessageAttachments";
import { useAttachmentDraft } from "@/features/support/useAttachmentDraft";
import {
useConversations,
useMarkConversationRead,
@@ -39,6 +54,9 @@ import { customersService } from "@/services/customers.service";
type ReadFilter = "ALL" | "UNREAD";
/** Distance from an edge (px) that counts as "at" it. */
const SCROLL_EDGE_SLOP = 120;
function formatTime(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
@@ -54,12 +72,24 @@ export default function SupportInboxPage() {
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const inboxViewport = useRef<HTMLDivElement>(null);
/**
* The thread just opened from the company picker.
*
* Selection resolves against the *loaded* pages, and a company picked from the
* modal may well have a thread that sits far enough down the list to not be
* loaded yet — in which case the lookup below would find nothing and the pane
* would sit blank. Hold onto the conversation the server handed back so the
* pane can open immediately, regardless of where it falls in the inbox.
*/
const [startedConversation, setStartedConversation] =
useState<SupportConversationDto | null>(null);
const { data, isLoading } = useConversations({
search,
unreadOnly: readFilter === "UNREAD",
});
const items = data?.items ?? [];
const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
useConversations({
search,
unreadOnly: readFilter === "UNREAD",
});
useSupportSocket(true, (event) => {
if (event.message.authorRole === SupportAuthorRole.CUSTOMER) {
@@ -70,10 +100,13 @@ export default function SupportInboxPage() {
}
});
const selected = useMemo(
() => items.find((c) => c.id === selectedId) ?? null,
[items, selectedId],
);
// Prefer the live row from the list (its unread count and last message stay
// current); fall back to the picker's copy while its page is still unloaded.
const selected = useMemo(() => {
const fromList = items.find((c) => c.id === selectedId);
if (fromList) return fromList;
return startedConversation?.id === selectedId ? startedConversation : null;
}, [items, selectedId, startedConversation]);
return (
<Box p="md">
@@ -109,7 +142,10 @@ export default function SupportInboxPage() {
borderRight: "1px solid var(--mantine-color-gray-2)",
}}
>
<Box p="sm" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
<Box
p="sm"
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
>
<Button
fullWidth
color="edr-green"
@@ -140,24 +176,47 @@ export default function SupportInboxPage() {
]}
/>
</Box>
<ScrollArea style={{ flex: 1 }} type="hover">
<ScrollArea
style={{ flex: 1 }}
type="hover"
// Pull the next page in as the agent nears the end of the list.
// Previously the hook asked for 100 rows and stopped there, so any
// company past the hundredth was simply unreachable.
onScrollPositionChange={({ y }) => {
const el = inboxViewport.current;
if (!el || !hasNextPage || isFetchingNextPage) return;
if (el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP) {
fetchNextPage();
}
}}
viewportRef={inboxViewport}
>
{isLoading ? (
<Group justify="center" p="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : items.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" p="xl">
{readFilter === "UNREAD" ? "Nothing unread." : "No conversations."}
{readFilter === "UNREAD"
? "Nothing unread."
: "No conversations."}
</Text>
) : (
items.map((c) => (
<InboxRow
key={c.id}
c={c}
active={c.id === selectedId}
onClick={() => setSelectedId(c.id)}
/>
))
<>
{items.map((c) => (
<InboxRow
key={c.id}
c={c}
active={c.id === selectedId}
onClick={() => setSelectedId(c.id)}
/>
))}
{isFetchingNextPage && (
<Group justify="center" p="sm">
<Loader size="xs" color="edr-green" />
</Group>
)}
</>
)}
</ScrollArea>
</Stack>
@@ -168,7 +227,12 @@ export default function SupportInboxPage() {
<ConversationThread conversation={selected} />
) : (
<Stack align="center" justify="center" h="100%" c="dimmed" gap="xs">
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
<ThemeIcon
variant="light"
color="edr-green"
radius="xl"
size={56}
>
<Headset size={28} />
</ThemeIcon>
<Text size="sm">Select a conversation, or start a new chat.</Text>
@@ -180,8 +244,9 @@ export default function SupportInboxPage() {
<CompanyPicker
opened={pickerOpen}
onClose={() => setPickerOpen(false)}
onStarted={(id) => {
setSelectedId(id);
onStarted={(conversation) => {
setStartedConversation(conversation);
setSelectedId(conversation.id);
setPickerOpen(false);
}}
/>
@@ -200,7 +265,7 @@ function CompanyPicker({
}: {
opened: boolean;
onClose: () => void;
onStarted: (conversationId: string) => void;
onStarted: (conversation: SupportConversationDto) => void;
}) {
const [companyId, setCompanyId] = useState<string | null>(null);
const start = useStartConversation();
@@ -224,15 +289,23 @@ function CompanyPicker({
if (!companyId) return;
const conversation = await start.mutateAsync(companyId);
setCompanyId(null);
onStarted(conversation.id);
onStarted(conversation);
};
return (
<Modal opened={opened} onClose={onClose} title="Start a chat" radius="md" centered>
<Modal
opened={opened}
onClose={onClose}
title="Start a chat"
radius="md"
centered
>
<Stack gap="md">
<Select
label="Customer"
placeholder={isLoading ? "Loading companies…" : "Search for a company"}
placeholder={
isLoading ? "Loading companies…" : "Search for a company"
}
data={options}
value={companyId}
onChange={setCompanyId}
@@ -319,26 +392,133 @@ function ConversationThread({
}: {
conversation: SupportConversationDto;
}) {
const { data: messages, isLoading } = useMessages(conversation.id);
const {
messages,
pageCount,
isLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useMessages(conversation.id);
const send = useSendMessage(conversation.id);
const markRead = useMarkConversationRead();
const [draft, setDraft] = useState("");
const [dragging, setDragging] = useState(false);
const viewport = useRef<HTMLDivElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const { view, viewer } = useFileViewer();
const attach = useAttachmentDraft((reason) => toast.error(reason));
/**
* Scroll height captured just before an older page was requested, tagged with
* the page count at that moment.
*
* The page count is what makes this safe. Keyed on presence alone, a message
* arriving over the socket while history was still in flight would consume the
* snapshot on a one-bubble append, and the real 30-message prepend would then
* land with nothing to correct against — throwing the reader exactly as far as
* this exists to prevent. Comparing counts means only an actual new page can
* claim it.
*/
const pendingRestore = useRef<{ height: number; atPageCount: number } | null>(
null,
);
/** Whether the agent is parked at the bottom and wants to follow new messages. */
const stick = useRef(true);
/** Which thread the refs above describe; a switch resets them. */
const anchoredThread = useRef(conversation.id);
const messageCount = messages.length;
useEffect(() => {
markRead.mutate(conversation.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversation.id, messages?.length]);
}, [conversation.id, messageCount]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [messages?.length, conversation.id]);
/**
* Keep the viewport sensible as the list changes underneath it.
*
* Two different things change `messages`, and they want opposite behaviour: a
* new message at the bottom should follow (if the agent is already there),
* while an older page prepended at the top must NOT move what they're reading.
* Layout effect, not effect — this must run before paint or the prepend
* visibly jumps.
*/
useLayoutEffect(() => {
const el = viewport.current;
if (!el) return;
// Thread switch: start a fresh read at the bottom and drop the previous
// thread's anchoring state.
if (anchoredThread.current !== conversation.id) {
anchoredThread.current = conversation.id;
pendingRestore.current = null;
stick.current = true;
el.scrollTo({ top: el.scrollHeight });
return;
}
const restore = pendingRestore.current;
if (restore && pageCount > restore.atPageCount) {
// An older page went in above: push the scroll down by exactly the height
// that was added, so the same message stays under the cursor.
el.scrollTop += el.scrollHeight - restore.height;
pendingRestore.current = null;
return;
}
if (stick.current) el.scrollTo({ top: el.scrollHeight });
}, [messages, pageCount, conversation.id]);
const onScroll = ({ y }: { y: number }) => {
const el = viewport.current;
if (!el) return;
stick.current = el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP;
if (y < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) {
// A failed fetch leaves this set, which is harmless: the list didn't
// change, so the height is still accurate for the retry, and the count
// tag stops it being mistaken for a landed page in the meantime.
pendingRestore.current = {
height: el.scrollHeight,
atPageCount: pageCount,
};
fetchNextPage();
}
};
const submit = async () => {
const body = draft.trim();
if (!body) return;
if (!body && attach.attachments.length === 0) return;
const files = attach.files;
// Clear optimistically so the composer feels instant; on failure the text is
// restored below rather than silently lost.
setDraft("");
await send.mutateAsync(body);
attach.clear();
stick.current = true;
try {
await send.mutateAsync({ body: body || undefined, attachments: files });
} catch (error) {
setDraft(body);
toast.error(
error instanceof Error ? error.message : "Couldn't send that message.",
);
}
};
const loadAttachment = useLazyAttachmentObjectUrl();
// Images already hold their bytes as an object URL from rendering the
// thumbnail, so reuse it rather than fetching the same file twice.
const openAttachment = (a: SupportAttachmentDto, src: string) =>
view({ name: a.name, url: src, mimeType: a.mimeType });
// Documents aren't fetched until opened.
const openFile = async (a: SupportAttachmentDto) => {
try {
const src = await loadAttachment(a.url);
view({ name: a.name, url: src, mimeType: a.mimeType });
} catch {
toast.error(`Couldn't open ${a.name}.`);
}
};
return (
@@ -361,36 +541,108 @@ function ConversationThread({
</Group>
{/* Messages */}
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
<ScrollArea
style={{ flex: 1 }}
viewportRef={viewport}
type="hover"
onScrollPositionChange={onScroll}
>
{isLoading ? (
<Group justify="center" p="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : (messages ?? []).length === 0 ? (
) : messages.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" p="xl">
No messages yet say hello.
</Text>
) : (
<Stack gap="sm" p="md">
{(messages ?? []).map((m) => (
<AgentBubble key={m.id} m={m} />
{isFetchingNextPage && (
<Group justify="center" py="xs">
<Loader size="xs" color="edr-green" />
</Group>
)}
{!hasNextPage && (
<Text size="10px" c="dimmed" ta="center">
Start of conversation
</Text>
)}
{messages.map((m) => (
<AgentBubble
key={m.id}
m={m}
onView={openAttachment}
onOpenFile={openFile}
/>
))}
</Stack>
)}
</ScrollArea>
{/* Composer */}
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<Box
p="sm"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: dragging ? "var(--mantine-color-edr-green-0)" : undefined,
outline: dragging
? "2px dashed var(--mantine-color-edr-green-6)"
: undefined,
outlineOffset: -4,
}}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
attach.add(Array.from(e.dataTransfer.files));
}}
>
<AttachmentDraftBar
attachments={attach.attachments}
onRemove={attach.remove}
/>
<Group gap="xs" align="flex-end" wrap="nowrap">
<input
ref={fileInput}
type="file"
multiple
accept={SUPPORT_ATTACHMENT_ACCEPT}
hidden
onChange={(e) => {
attach.add(Array.from(e.currentTarget.files ?? []));
// Reset so picking the same file twice in a row still fires change.
e.currentTarget.value = "";
}}
/>
<ActionIcon
size={38}
radius="md"
variant="subtle"
color="gray"
aria-label="Attach files"
onClick={() => fileInput.current?.click()}
>
<Paperclip size={18} />
</ActionIcon>
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder="Type your message… (Enter to send, Shift+Enter for newline)"
placeholder="Type a message, or paste an image… (Enter to send, Shift+Enter for newline)"
autosize
minRows={1}
maxRows={5}
radius="md"
style={{ flex: 1 }}
// Screenshots land on the clipboard as files. Take them and suppress
// the default, which would otherwise also paste the image's name (or
// nothing) as text.
onPaste={(e) => {
if (attach.addFromPaste(e.clipboardData)) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -404,18 +656,27 @@ function ConversationThread({
color="edr-green"
variant="filled"
loading={send.isPending}
disabled={!draft.trim()}
disabled={!draft.trim() && attach.attachments.length === 0}
onClick={submit}
>
<Send size={18} />
</ActionIcon>
</Group>
</Box>
{viewer}
</Stack>
);
}
function AgentBubble({ m }: { m: SupportMessageDto }) {
function AgentBubble({
m,
onView,
onOpenFile,
}: {
m: SupportMessageDto;
onView: (a: SupportAttachmentDto, src: string) => void;
onOpenFile: (a: SupportAttachmentDto) => void;
}) {
const mine = m.authorRole === SupportAuthorRole.AGENT;
return (
<Group
@@ -430,7 +691,13 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
</Avatar>
)}
<Box style={{ maxWidth: "70%" }}>
<Text size="xs" c="dimmed" mb={2} ml={mine ? 0 : 4} ta={mine ? "right" : "left"}>
<Text
size="xs"
c="dimmed"
mb={2}
ml={mine ? 0 : 4}
ta={mine ? "right" : "left"}
>
{mine ? m.authorName || "You" : m.authorName || "Customer"}
</Text>
<Paper
@@ -446,9 +713,20 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
borderBottomLeftRadius: mine ? undefined : 4,
}}
>
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{m.body}
</Text>
{m.body && (
<Text
size="sm"
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{m.body}
</Text>
)}
<MessageAttachments
attachments={m.attachments}
mine={mine}
onView={onView}
onOpenFile={onOpenFile}
/>
</Paper>
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
{formatTime(m.createdAt)}

View File

@@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) =>
const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT";
const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED";
/** Yards with no equipment can never load/unload — surface it before the train arrives. */
function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) {
/**
* A yard that can't handle THIS booking's cargo can never work it — surface that
* while the train is still coming, not when the load is refused. Containers need
* a facility with a stacker (Indode, Modjo, Dire Dawa); bulk is handled at all of
* them.
*/
function FacilityCell({
yard,
has,
freightType,
}: {
yard: string | null;
has: boolean | null;
freightType: string | null;
}) {
if (!yard) return <Text size="sm"></Text>;
if (has) return <Text size="sm">{yard}</Text>;
return (
<Tooltip
label="This yard has no load/unload facility — cargo cannot be handled here"
label={`${yard} cannot handle ${(freightType ?? "this").toLowerCase()} cargo — no facility here, or no equipment for it`}
withArrow
multiline
w={240}
w={260}
>
<Group gap={4} wrap="nowrap">
<AlertTriangle size={13} color="var(--mantine-color-red-6)" />
@@ -95,7 +108,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Td>{r.customer ?? "—"}</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FacilityCell yard={r.origin} has={r.originHasFacility} />
<FacilityCell yard={r.origin} has={r.originHasFacility} freightType={r.freightType} />
{atOrigin(r) && isWaiting(r) && (
<Badge size="xs" color="edr-green" variant="light">
train here
@@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FacilityCell yard={r.destination} has={r.destinationHasFacility} />
<FacilityCell yard={r.destination} has={r.destinationHasFacility} freightType={r.freightType} />
{atDestination(r) && isRiding(r) && (
<Badge size="xs" color="edr-green" variant="light">
train here
@@ -215,7 +228,7 @@ export default function IntercityPage() {
<Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} />
<Stat
icon={<AlertTriangle size={18} />}
label="No facility"
label="Cannot handle"
value={blocked.length}
color={blocked.length > 0 ? "red" : undefined}
/>
@@ -229,8 +242,9 @@ export default function IntercityPage() {
title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`}
mb="md"
>
Their origin or destination yard has no load/unload facility. Mark the yard as
a facility in Configuration Yards, or the cargo can never be worked there.
Their origin or destination yard cannot handle that cargo no facility, or no
equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at
any facility. Adjust the yard in Configuration Yards.
</Alert>
)}

View File

@@ -0,0 +1,207 @@
import { useMemo, useState } from "react";
import {
Alert,
Badge,
Group,
SegmentedControl,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { Search } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useTrucksOnSite } from "@/hooks/useWarehouses";
import type { TruckOnSite } from "@/types/warehouse";
/**
* Every truck inside the yard right now, across all bookings.
*
* The gate's question is "which trucks are here", not "which bookings have
* trucks" — the ops dashboard could only count them, never open the list. Both
* haulage paths appear because the same barrier handles both: a customer's own
* truck and an EDR last-mile truck.
*/
/** How long the truck has been on site — the number the gate actually chases. */
function dwell(arrivedAt: string | null): string {
if (!arrivedAt) return "—";
const minutes = Math.floor((Date.now() - new Date(arrivedAt).getTime()) / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ${minutes % 60}m`;
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
}
/** Long dwell means a truck is sitting at the gate — worth flagging, not hiding. */
const LONG_DWELL_HOURS = 4;
function isLongDwell(arrivedAt: string | null): boolean {
if (!arrivedAt) return false;
return Date.now() - new Date(arrivedAt).getTime() > LONG_DWELL_HOURS * 3_600_000;
}
function Rows({ rows }: { rows: TruckOnSite[] }) {
if (rows.length === 0) {
return (
<Alert variant="light" color="gray">
No trucks assigned or on site.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={1040}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Plate</Table.Th>
<Table.Th>Haulage</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Truck type</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th>On site</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={`${row.source}-${row.assignmentId}`}>
<Table.Td>
<Badge
size="sm"
radius="sm"
variant={row.status === "ON_SITE" ? "filled" : "light"}
color={row.status === "ON_SITE" ? "edr-green" : "gray"}
>
{row.status === "ON_SITE" ? "On site" : "Inbound"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>
{row.plateNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
radius="sm"
variant="light"
color={row.source === "CUSTOMER" ? "blue" : "edr-green"}
>
{row.source === "CUSTOMER" ? "Customer" : "EDR"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{row.driverName ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.truckType ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.bookingReference ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customerName ?? "—"}</Text>
</Table.Td>
<Table.Td>
{/* Bulk trucks carry no containers — they haul loose tonnage. */}
<Text size="sm">{row.containers ?? "Bulk"}</Text>
</Table.Td>
<Table.Td>
{row.arrivedAt == null ? (
<Text size="sm" c="dimmed">
</Text>
) : isLongDwell(row.arrivedAt) ? (
<Tooltip
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt).toLocaleString()}`}
withArrow
>
<Text size="sm" c="red" fw={600}>
{dwell(row.arrivedAt)}
</Text>
</Tooltip>
) : (
<Text size="sm">{dwell(row.arrivedAt)}</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
export default function TrucksOnSitePage() {
const { data: trucks = [], isLoading } = useTrucksOnSite();
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">("ALL");
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
const [search, setSearch] = useState("");
const rows = useMemo(() => {
const term = search.trim().toLowerCase();
return trucks
.filter((t) => scope === "ALL" || t.status === scope)
.filter((t) => source === "ALL" || t.source === source)
.filter((t) =>
!term
? true
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
.some((field) => field?.toLowerCase().includes(term)),
);
}, [trucks, scope, source, search]);
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
const inboundCount = trucks.length - onSiteCount;
const customerCount = trucks.filter((t) => t.source === "CUSTOMER").length;
const edrCount = trucks.length - customerCount;
return (
<PageContainer>
<PageHeader
title="Trucks on site"
subtitle="Customer self-haul and EDR last-mile trucks — assigned (inbound) or arrived, until they leave the yard."
/>
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
value={scope}
onChange={(v) => setScope(v as typeof scope)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
{ label: `Inbound (${inboundCount})`, value: "INBOUND" },
]}
/>
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: "All", value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
</Group>
<TextInput
size="xs"
w={280}
placeholder="Plate, driver, booking, container…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group>
{isLoading ? <Text size="sm">Loading</Text> : <Rows rows={rows} />}
</PageContainer>
);
}