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

@@ -46,6 +46,7 @@ import {
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
@@ -124,6 +125,7 @@ import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import IntercityPage from "./pages/warehouses/IntercityPage";
import TrucksOnSitePage from "./pages/warehouses/TrucksOnSitePage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
@@ -464,6 +466,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
// Yard-wide, not per-direction: the gate sees import and export
// trucks at the same barrier.
label: "Trucks on Site",
href: "/dashboard/trucks-on-site",
icon: <Truck />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
@@ -696,6 +705,7 @@ const App = () => {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route path="um/set-password" element={<SetPassword />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
@@ -958,6 +968,7 @@ const App = () => {
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route

View File

@@ -1,5 +1,13 @@
import { api } from "./http";
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
import type {
AuthTokens,
AuthUser,
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
LoginResponse,
ResetTicket,
SetPasswordPayload,
} from "./types";
export const loginRequest = async (payload: {
email: string;
@@ -21,3 +29,31 @@ export const getMeRequest = async () => {
const response = await api.get<AuthUser>("/me");
return response.data;
};
// The three calls below drive the unauthenticated forgot-password flow.
// Responses under /api/auth are *flattened* by the API's response
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
export const requestPasswordResetRequest = async (
payload: ForgotPasswordRequestPayload,
) => {
await api.post("/auth/forgot-password/request", payload);
};
export const verifyPasswordResetOtpRequest = async (
payload: ForgotPasswordVerifyPayload,
) => {
const response = await api.post<ResetTicket>(
"/auth/forgot-password/verify",
payload,
);
return response.data;
};
/**
* Spend the reset ticket minted by {@link verifyPasswordResetOtpRequest}.
* Carries its own userId/verificationCode and never touches the session.
*/
export const resetPasswordRequest = async (payload: SetPasswordPayload) => {
await api.patch("/auth/set-password", payload);
};

View File

@@ -1,10 +1,7 @@
import axios from "axios";
import toast from "react-hot-toast";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
import {
AUTH_TOKEN_COOKIE,
@@ -14,14 +11,16 @@ import {
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
* When true, the response interceptor does NOT raise the global error
* toast for this request's failure. For calls the caller handles itself —
* e.g. a probe that is expected to 404 before falling back (GL clearance
* detail tries /contracts/:id then /bookings/:id). The rejection still
* propagates.
*/
suppressErrorModal?: boolean;
}
@@ -96,9 +95,8 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// Report the failure to PostHog, including on suppressErrorModal paths —
// those opt out of the user-facing toast, not of reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
@@ -112,19 +110,25 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
// Surface the server's actual error message in a global toast — never
// the error modal (401s are handled by the session-refresh flow, so skip
// them). A request may opt out via `suppressErrorModal` when it handles
// the failure itself.
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
// Normalize the error's own `message` to the SERVER's actual message so
// every downstream `toast.error(err.message)` / MutationCache handler
// shows the real cause instead of "Request failed with status code NNN".
// Applies even on suppressErrorModal paths — only the modal is opted out.
// every downstream `toast.error(err.message)` handler shows the real
// cause instead of "Request failed with status code NNN". Applies even
// on suppressErrorModal paths — only the toast is opted out.
if (payload?.messages.length) {
(error as { message?: string }).message = payload.messages.join("\n");
const message = payload.messages.join("\n");
(error as { message?: string }).message = message;
// Keyed by message so a retried request replaces its toast instead
// of stacking duplicates.
if (!originalRequest?.suppressErrorModal) {
toast.error(message, { id: message });
}
}
if (payload && !originalRequest?.suppressErrorModal) emitApiError(payload);
}
return Promise.reject(error);
}

View File

@@ -58,6 +58,29 @@ export interface LoginResponse extends Partial<AuthTokens> {
mfaRequired?: boolean;
}
export interface ForgotPasswordRequestPayload {
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
identifier: string;
}
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
otp: string;
}
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
export interface ResetTicket {
userId: string;
verificationCode: string;
}
export interface SetPasswordPayload {
newPassword: string;
confirmPassword: string;
userId: string;
email: string;
verificationCode: string;
}
// Additional types for Matrix form test
export interface User {
id: string;

View File

@@ -0,0 +1,145 @@
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
import { maskEmail, maskPhone } from "@/utils/identifier";
export const OTP_LENGTH = 6;
export interface OtpChannelStepProps {
/**
* Raw contacts the code was sent to; masked before display. The API sends one
* code to every contact on the account, so both are usually set — pass only
* what the client actually knows. Omit both when the client cannot know them
* (the forgot-password flow deliberately never reveals an account's contacts)
* and a generic line is shown instead.
*/
email?: string;
phone?: string;
value: string;
onChange: (otp: string) => void;
onVerify: () => void;
onBack: () => void;
onResend: () => void;
/** Seconds until resend is allowed; 0 enables the button. */
resendIn: number;
sending: boolean;
verifying: boolean;
error: string | null;
title?: string;
description?: string;
submitLabel: string;
}
/**
* The "enter the code we sent you" stage. Shared by signup and the
* forgot-password flow — both send through the same `/api/otp/*` service, which
* delivers a single code to the account's email AND phone; whichever message
* arrives first can be typed here.
*/
export default function OtpChannelStep({
email,
phone,
value,
onChange,
onVerify,
onBack,
onResend,
resendIn,
sending,
verifying,
error,
title,
description,
submitLabel,
}: OtpChannelStepProps) {
const maskedTargets = [
email ? maskEmail(email) : null,
phone ? maskPhone(phone) : null,
].filter(Boolean) as string[];
const busy = sending || verifying;
return (
<Stack gap="md">
<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">
<ShieldCheck size={22} />
</span>
</div>
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
{title ?? "Verify it's you"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a {OTP_LENGTH}-digit code to{" "}
{maskedTargets.length ? (
maskedTargets.map((target, index) => (
<span key={target}>
{index > 0 ? " and " : null}
<span className="font-medium text-gray-700">{target}</span>
</span>
))
) : (
<span className="font-medium text-gray-700">
the email and phone on your account
</span>
)}
. {description ?? "Enter it to continue."}
</p>
</div>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Stack gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={OTP_LENGTH}
type="number"
oneTimeCode
value={value}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={onChange}
/>
</Stack>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || value.trim().length !== OTP_LENGTH}
onClick={onVerify}
>
{submitLabel}
</Button>
<div className="flex items-center justify-between">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={busy}
onClick={onBack}
>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || busy}
onClick={onResend}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,41 @@
import { Check, X } from "lucide-react";
import { passwordRequirements } from "@/utils/passwordSchema";
export interface PasswordChecklistProps {
/** The current password value; the checklist hides itself when empty. */
value: string;
}
/** Live pass/fail list of the password rules, shown under a password field. */
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
if (!value) return null;
return (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(value);
return (
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met
? "bg-primary text-primary-foreground"
: "bg-gray-200 text-gray-500"
}`}
>
{met ? (
<Check className="h-2.5 w-2.5" />
) : (
<X className="h-2.5 w-2.5" />
)}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</span>
</div>
);
})}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
@@ -72,6 +72,7 @@ export type ClearanceViewLike = Pick<
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
| "riskHistory"
| "secondDuty"
| "importReleaseGranted"
> & { operationReady?: boolean };
@@ -1040,11 +1041,28 @@ function RiskStep({
done: boolean;
onChanged?: () => void;
}) {
const [level, setLevel] = useState<string>("GREEN");
const assigned = done || Boolean(clearance.riskLevel);
// Duty is advised off the risk level, so once that is done the decision is
// final. Until then a mis-assigned level must stay correctable — the server
// overwrites the milestone metadata on reassignment. Mirrors AssignRiskCard.
const locked = isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED");
const [level, setLevel] = useState<string>(clearance.riskLevel ?? "GREEN");
const [loading, setLoading] = useState(false);
if (done || clearance.riskLevel) {
return (
// The clearance view loads (and refetches after a reassignment) after first
// render, so mirror the persisted level onto the control whenever it changes —
// otherwise reopening the step offers GREEN whatever is actually assigned.
useEffect(() => {
if (clearance.riskLevel) setLevel(clearance.riskLevel);
}, [clearance.riskLevel]);
// Only the decisions before the current one — the badge above already states
// the level in force, so repeating it as a trail entry reads as a duplicate.
const priorDecisions = (clearance.riskHistory ?? []).slice(0, -1);
const assignedSummary = assigned ? (
<Stack gap={6}>
<Group gap="sm">
<Badge
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
@@ -1061,12 +1079,35 @@ function RiskStep({
. The customer can see this level.
</Text>
</Group>
);
{priorDecisions.length > 0 ? (
<Stack gap={2} pl="xs">
<Text size="xs" c="dimmed" fw={600}>
Previously
</Text>
{priorDecisions.map((entry, index) => (
<Text key={`${entry.assignedAt}-${index}`} size="xs" c="dimmed">
{entry.level}
{" · "}
{new Date(entry.assignedAt).toLocaleString()}
{entry.assignedBy ? ` · ${entry.assignedBy}` : ""}
{entry.note ? ` · ${entry.note}` : ""}
</Text>
))}
</Stack>
) : null}
</Stack>
) : null;
// Assigned and final: the badge is all that is left to show.
if (assigned && (locked || !canAct || !bookingId)) {
return assignedSummary;
}
// Customs cannot rate cargo still under transit — the server rejects the
// assignment until the T1 is closed, so do not offer the control yet.
if (!clearance.t1?.closed) {
// assignment until the T1 is closed, so do not offer the control yet. Skipped
// once a level exists: risk cannot have been assigned without a closed T1, so
// a still-open T1 here is stale data and must not hide the assigned badge.
if (!assigned && !clearance.t1?.closed) {
return (
<StepStatus
done={false}
@@ -1088,6 +1129,7 @@ function RiskStep({
return (
<Stack gap="sm">
{assignedSummary}
<SegmentedControl
fullWidth
value={level}
@@ -1100,19 +1142,24 @@ function RiskStep({
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
The customer sees the assigned risk level.
{assigned
? "Correctable until duty is advised. The customer sees the assigned risk level."
: "The customer sees the assigned risk level."}
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={loading}
disabled={assigned && level === clearance.riskLevel}
onClick={async () => {
setLoading(true);
try {
await contractsService.assignRisk(bookingId, {
riskLevel: level as Freight.CustomsRiskLevel,
});
toast.success("Customs risk assigned");
toast.success(
assigned ? "Customs risk reassigned" : "Customs risk assigned",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
@@ -1121,7 +1168,7 @@ function RiskStep({
}
}}
>
Assign risk
{assigned ? "Reassign risk" : "Assign risk"}
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,101 @@
import {
Alert,
Button,
Group,
Modal,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { FilePen } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "@/services/api";
import type { CustomerDocument } from "@/types/customer";
export interface RequestDocumentChangeModalProps {
/** The document under review; `null` closes the modal. */
document: CustomerDocument | null;
companyId: string;
onClose: () => void;
}
/**
* Ask the customer to correct one uploaded document.
*
* Deliberately narrower than rejecting a whole role: the customer keeps every
* other document and only re-uploads this one. The role cannot be approved
* while the request is open, so the note has to say what is actually wrong —
* it is shown to the customer verbatim.
*/
export function RequestDocumentChangeModal({
document,
companyId,
onClose,
}: RequestDocumentChangeModalProps) {
const requestChange = useMutation(
api.customers.requestDocumentChange.mutationOptions(),
);
const [note, setNote] = useState("");
// Re-opening on a document that already has an open request should show what
// was asked for, so the reviewer edits the reason rather than retyping it.
useEffect(() => {
setNote(document?.reviewNote ?? "");
}, [document?.id, document?.reviewNote]);
const submit = () => {
if (!document) return;
requestChange.mutate(
{ companyId, fileId: document.id, note: note.trim() },
{ onSuccess: onClose },
);
};
return (
<Modal
opened={document !== null}
onClose={onClose}
title="Request a change"
centered
radius="lg"
>
<Stack gap="md">
<Alert color="orange" variant="light" icon={<FilePen size={18} />}>
The customer is notified and sees this note verbatim. This role cannot
be approved until they upload a corrected document.
</Alert>
<Text size="sm" c="dimmed">
Document: <strong>{document?.name}</strong>
</Text>
<Textarea
label="What needs correcting?"
placeholder="e.g. The trade license scan is cut off — please re-upload the full page."
autosize
minRows={3}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={onClose}
disabled={requestChange.isPending}
>
Cancel
</Button>
<Button
color="orange"
loading={requestChange.isPending}
disabled={note.trim().length === 0}
onClick={submit}
>
Request change
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,5 +1,5 @@
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { useState } from "react";
@@ -10,32 +10,48 @@ import { api } from "@/services/api";
import type { Company, ResetChannel } from "@/types/customer";
export interface ResetPasswordActionProps {
company: Pick<Company, "id" | "email" | "phone">;
company: Pick<Company, "id">;
}
/**
* Staff-triggered password reset. Sends a one-time code to the customer's
* primary contact; the customer picks their own new password. No credential is
* ever shown to or handled by staff.
* Staff-triggered password reset. Sends a single-use link to the customer's
* primary contact; the customer opens it and picks their own new password. No
* credential is ever shown to or handled by staff.
*/
export default function ResetPasswordAction({ company }: ResetPasswordActionProps) {
export default function ResetPasswordAction({
company,
}: ResetPasswordActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("phone");
const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
// The destination is the primary contact's IAM account, not the company
// record — those are different fields and routinely hold different values, so
// showing `company.phone` here would tell staff the wrong number. Only fetched
// once the modal is open.
const targetQuery = useQuery(
api.customers.resetTarget.queryOptions({
input: { companyId: company.id },
enabled: allowed && opened,
}),
);
const target = targetQuery.data;
const { mutate, isPending } = useMutation(
api.customers.resetPassword.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Reset code sent",
description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
title: "Reset link sent",
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
});
},
onError: (error) => {
toast({
title: "Could not send reset code",
title: "Could not send reset link",
description: error.message,
variant: "destructive",
});
@@ -43,7 +59,10 @@ export default function ResetPasswordAction({ company }: ResetPasswordActionProp
}),
);
if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
if (!allowed) return null;
const channelMissing =
!!target && (channel === "email" ? !target.email : !target.phone);
return (
<>
@@ -58,46 +77,66 @@ export default function ResetPasswordAction({ company }: ResetPasswordActionProp
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Send a password-reset code"
title="Send a password-reset link"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a one-time code to this customer&apos;s primary contact.
They choose their own new password you will not see it.
We&apos;ll send a single-use link to this customer&apos;s primary
contact. They choose their own new password you will not see it.
The link expires in 24 hours.
</Text>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label="Send the code via"
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
description={company.phone ?? "No phone on the company record"}
/>
<Radio
value="email"
label="Email"
description={company.email ?? "No email on the company record"}
/>
{targetQuery.isLoading ? (
<Stack align="center" py="md">
<Loader size="sm" />
</Stack>
</Radio.Group>
) : targetQuery.isError ? (
<Alert color="red" variant="light">
{targetQuery.error.message}
</Alert>
) : target ? (
<>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label={`Send the link to ${target.name || "the primary contact"} via`}
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
disabled={!target.phone}
description={
target.phone ?? "No phone number on this account"
}
/>
<Radio
value="email"
label="Email"
disabled={!target.email}
description={
target.email ?? "No email address on this account"
}
/>
</Stack>
</Radio.Group>
<Text size="xs" c="dimmed">
The code goes to the primary contact&apos;s own email or phone, which
may differ from the company contact details shown above.
</Text>
<Text size="xs" c="dimmed">
These are the primary contact&apos;s own login details, which may
differ from the company contact details on the profile.
</Text>
<Button
color="edr-green"
loading={isPending}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset code
</Button>
<Button
color="edr-green"
loading={isPending}
disabled={channelMissing}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset link
</Button>
</>
) : null}
</Stack>
</Modal>
</>

View File

@@ -13,6 +13,10 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,
} from "./RequestDocumentChangeModal";
export {
default as ResetPasswordAction,
type ResetPasswordActionProps,

View File

@@ -1,6 +1,7 @@
import { Card, Skeleton, Text } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { ElementType, ReactNode } from "react";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
@@ -22,6 +23,11 @@ export interface KpiItem {
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
*/
delta?: number;
/**
* Optional route the cell links to — its detail view. When set the cell
* becomes clickable (pointer, hover tint); when absent it stays static.
*/
href?: string;
}
export interface KpiStripProps {
@@ -47,13 +53,22 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{cells.map((item, index) => {
const Icon = item.icon;
const color = item.color ?? "edr-green";
// A cell with an href becomes a link to its detail; without one it
// stays a plain div. Same layout classes either way.
const Cell: ElementType = item.href ? Link : "div";
const linkProps = item.href
? { to: item.href, "aria-label": `${item.label} — view detail` }
: {};
return (
<div
<Cell
key={item.label}
{...(linkProps as Record<string, unknown>)}
className={cn(
"flex flex-1 items-center gap-3 px-5 py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
item.href &&
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
)}
>
{Icon ? (
@@ -102,7 +117,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{item.hint ? ` · ${item.hint}` : ""}
</Text>
</div>
</div>
</Cell>
);
})}
</div>

View File

@@ -202,6 +202,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
);
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
// The same for a customer self-haul truck. The prefill above reads the
// booking.customer_truck_* columns, but multi-truck self-haul writes the plate
// and driver to customer_truck_assignments and leaves those columns null — so
// a booking with a truck on file still opened this form blank. Only auto-fills
// a single truck: with several, the operator picks which one is at the gate.
useEffect(() => {
if (!opened || truckPrefill || isExitStep) return;
if (customerTrucks.length !== 1) return;
const [truck] = customerTrucks;
setTruckPlateNumber((p) => p || truck.plateNumber || '');
setDriverName((p) => p || truck.driverName || '');
setTruckType((p) => p || truck.truckType || '');
setContainerNumbers((prev) => {
const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean);
return prev.every((n) => !n) && loaded.length ? loaded : prev;
});
}, [opened, truckPrefill, isExitStep, customerTrucks]);
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [

View File

@@ -0,0 +1,113 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Group, Loader, Table, Text } from "@mantine/core";
import { warehouseService } from "@/services/warehouse.service";
/**
* The trucks carrying one booking's cargo, and which containers ride each.
*
* A self-haul booking can have several trucks, each with 12 containers, but
* the inventory table has one row per inventory item — so which container sits
* on which truck was never visible without opening a document. Fetched lazily:
* only an expanded row costs a request.
*/
export function TruckBreakdownRow({
bookingId,
colSpan,
}: {
bookingId: string;
colSpan: number;
}) {
const { data: trucks = [], isLoading } = useQuery({
queryKey: ["booking-customer-trucks", bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
});
return (
<Table.Tr>
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group gap="xs" py="xs">
<Loader size="xs" />
<Text size="xs" c="dimmed">
Loading trucks
</Text>
</Group>
) : trucks.length === 0 ? (
<Text size="xs" c="dimmed" py="xs">
No customer trucks assigned to this booking.
</Text>
) : (
<Table verticalSpacing={4} withRowBorders={false}>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Text size="xs" c="dimmed">
Truck
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Driver
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Type
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Containers
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Status
</Text>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trucks.map((truck) => (
<Table.Tr key={truck.id}>
<Table.Td>
<Text size="xs" fw={600}>
{truck.plateNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{truck.driverName}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{truck.truckType}</Text>
</Table.Td>
<Table.Td>
{/* Bulk trucks carry loose tonnage, not containers. */}
<Text size="xs">
{truck.containers?.length
? truck.containers.map((c) => c.containerNumber).join(", ")
: "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="xs"
radius="sm"
variant="light"
color={truck.arrivedAt ? "edr-green" : "gray"}
>
{truck.arrivedAt
? `Arrived ${new Date(truck.arrivedAt).toLocaleString()}`
: "Not arrived"}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Table.Td>
</Table.Tr>
);
}

View File

@@ -13,6 +13,19 @@ import { ReceiveInventoryModal } from './ReceiveInventoryModal';
interface WarehouseInfoCardProps {
bookingId: string;
bookingReference?: string;
/**
* Booking payment status. Export cargo is received into the warehouse only
* after the booking is paid — receiving an unpaid booking starts storage and
* GRN against cargo the customer has not settled. Optional so existing callers
* that do not have the booking to hand keep their current behaviour.
*/
paymentStatus?: string | null;
/**
* IMPORT | EXPORT | DOMESTIC. The payment gate is export-only: import cargo
* arrives OFF a train, so blocking its receive would strand cargo already at
* the yard.
*/
tradeDirection?: string | null;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
@@ -28,7 +41,12 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
);
}
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
export function WarehouseInfoCard({
bookingId,
bookingReference,
paymentStatus,
tradeDirection,
}: WarehouseInfoCardProps) {
const [modalOpen, setModalOpen] = useState(false);
const { data, isLoading } = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
@@ -46,6 +64,13 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
const wagon = scheduleView?.wagon;
const isLoadedOrDispatched =
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
// Export only, and only when we were actually told the status — an absent prop
// means the caller cannot answer, and guessing "unpaid" would disable a valid
// action. Mirrors the server guard on receive().
const awaitingPayment =
tradeDirection?.toUpperCase() === 'EXPORT' &&
paymentStatus != null &&
paymentStatus.toUpperCase() !== 'PAID';
return (
<Card withBorder radius="md" padding="lg">
@@ -127,8 +152,12 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
)}
<Tooltip
label="This booking is already received at the warehouse"
disabled={!latest}
label={
latest
? 'This booking is already received at the warehouse'
: 'This booking is not paid yet — cargo can only be received once payment is settled'
}
disabled={!latest && !awaitingPayment}
withArrow
>
{/* span wrapper so the tooltip still fires on the disabled button */}
@@ -138,7 +167,7 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
leftSection={<PackagePlus size={16} />}
onClick={() => setModalOpen(true)}
fullWidth
disabled={Boolean(latest)}
disabled={Boolean(latest) || awaitingPayment}
>
{latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
</Button>

View File

@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react';
import { Fragment, useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { ArrowRightLeft, ChevronDown, ChevronRight, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
@@ -10,6 +10,7 @@ import {
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { TruckBreakdownRow } from './TruckBreakdownRow';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
@@ -120,6 +121,16 @@ export function WarehouseInventoryTable({
someSelected,
}: WarehouseInventoryTableProps) {
const selectable = Boolean(onToggleSelect);
// Bookings whose truck breakdown is open. Expanded rows fetch on demand, so a
// closed table costs nothing extra.
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (bookingId: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(bookingId)) next.delete(bookingId);
else next.add(bookingId);
return next;
});
if (items.length === 0) {
return (
@@ -144,6 +155,8 @@ export function WarehouseInventoryTable({
/>
</Table.Th>
)}
{/* Expander for the per-truck breakdown. */}
<Table.Th w={32} />
<Table.Th>Booking</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Facility</Table.Th>
@@ -174,8 +187,16 @@ export function WarehouseInventoryTable({
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
const handoverReference = handoverDocumentReference(item);
// Only offer the breakdown where there is one: a plate means at
// least one customer truck is on the booking.
const hasCustomerTrucks = Boolean(
item.bookingId && item.booking?.customerTruckPlateNumber?.trim(),
);
const isExpanded = Boolean(item.bookingId && expanded.has(item.bookingId));
return (
<Table.Tr key={item.id}>
<Fragment key={item.id}>
<Table.Tr>
{selectable && (
<Table.Td>
<Checkbox
@@ -185,6 +206,23 @@ export function WarehouseInventoryTable({
/>
</Table.Td>
)}
<Table.Td>
{hasCustomerTrucks ? (
<Tooltip
label={isExpanded ? 'Hide trucks' : 'Show which containers ride which truck'}
withArrow
>
<ActionIcon
variant="subtle"
size="sm"
aria-label={isExpanded ? 'Hide trucks' : 'Show trucks'}
onClick={() => toggleExpanded(item.bookingId as string)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Tooltip>
) : null}
</Table.Td>
<Table.Td>
{item.bookingReference || item.booking?.reference || item.bookingId ? (
<Tooltip label={item.bookingId ?? ''} withArrow disabled={!item.bookingId}>
@@ -309,6 +347,15 @@ export function WarehouseInventoryTable({
</Group>
</Table.Td>
</Table.Tr>
{isExpanded && item.bookingId ? (
<TruckBreakdownRow
bookingId={item.bookingId}
// Expander + every data column + actions, plus the checkbox
// when the table is selectable.
colSpan={selectable ? 14 : 13}
/>
) : null}
</Fragment>
);
})}
</Table.Tbody>

View File

@@ -23,18 +23,23 @@ export function WarehouseOpsKpiStrip() {
delta:
data != null ? data.receivedToday - data.receivedYesterday : undefined,
hint: "vs yesterday",
// The received cargo itself, on the inventory board.
href: "/dashboard/warehouse-inventory?status=RECEIVED",
},
{
label: "Pending inspection",
value: data?.pendingInspection ?? 0,
icon: ClipboardCheck,
color: "yellow",
// Received cargo still awaiting inspection lives in the RECEIVED bucket.
href: "/dashboard/warehouse-inventory?status=RECEIVED",
},
{
label: "Trucks on-site",
value: data?.trucksOnSite ?? 0,
icon: Truck,
color: "blue",
href: "/dashboard/trucks-on-site",
},
{
label: "Items aging (>7d)",
@@ -42,6 +47,8 @@ export function WarehouseOpsKpiStrip() {
icon: AlertTriangle,
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
hint: "In warehouse over 7 days",
// No aging filter on the board; the inventory list is the landing.
href: "/dashboard/warehouse-inventory",
},
]}
/>

View File

@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
resetTarget: (id: string) =>
["customers", "detail", id, "reset-target"] as const,
changeRequests: (id: string) =>
["customers", "detail", id, "change-requests"] as const,
},

View File

@@ -82,12 +82,16 @@ export const URL_CONSTANTS = {
`/companies/change-requests/${id}/approve`,
CHANGE_REQUEST_REJECT: (id: string) =>
`/companies/change-requests/${id}/reject`,
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
`/companies/documents/${fileId}/request-change`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
`/payments/by-company/${id}/customer-view`,
RESET_PASSWORD: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-password`,
RESET_TARGET: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-target`,
},
BILLING: {
@@ -493,6 +497,7 @@ export const URL_CONSTANTS = {
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
TRUCKS_ON_SITE: "/warehouse-inventory/trucks-on-site",
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
`/warehouse-inventory/throughput?granularity=${granularity}`,
DWELL_STATS: "/warehouse-inventory/dwell-stats",

View File

@@ -0,0 +1,78 @@
import { ActionIcon, Box, Group, Image, Paper, Text } from "@mantine/core";
import { FileText, X } from "lucide-react";
import { formatBytes } from "./MessageAttachments";
import type { PendingAttachment } from "./useAttachmentDraft";
/**
* The staged-files strip above the composer. Shows what will be sent and lets
* the agent drop any of it before hitting send.
*/
export function AttachmentDraftBar({
attachments,
onRemove,
}: {
attachments: PendingAttachment[];
onRemove: (id: string) => void;
}) {
if (attachments.length === 0) return null;
return (
<Group gap="xs" mb="xs" wrap="wrap">
{attachments.map((a) => (
<Paper
key={a.id}
withBorder
radius="md"
p={4}
style={{ position: "relative" }}
>
<Group gap={6} wrap="nowrap" pr={16}>
{a.previewUrl ? (
<Image
src={a.previewUrl}
alt={a.file.name}
w={36}
h={36}
radius="sm"
fit="cover"
/>
) : (
<Box
w={36}
h={36}
style={{
display: "grid",
placeItems: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
<FileText size={16} />
</Box>
)}
<Box style={{ minWidth: 0, maxWidth: 120 }}>
<Text size="xs" fw={600} truncate>
{a.file.name}
</Text>
<Text size="10px" c="dimmed">
{formatBytes(a.file.size)}
</Text>
</Box>
</Group>
<ActionIcon
size="xs"
radius="xl"
color="gray"
variant="filled"
aria-label={`Remove ${a.file.name}`}
onClick={() => onRemove(a.id)}
style={{ position: "absolute", top: -6, right: -6 }}
>
<X size={10} />
</ActionIcon>
</Paper>
))}
</Group>
);
}

View File

@@ -0,0 +1,122 @@
import {
isSupportAttachmentImage,
type SupportAttachmentDto,
} from "@edr/types";
import { Box, Group, Image, Loader, Paper, Stack, Text } from "@mantine/core";
import { FileText, ImageOff } from "lucide-react";
import { useAttachmentObjectUrl } from "./useAttachmentObjectUrl";
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Cap the bubble: a tall screenshot would push the conversation off-screen. */
const THUMB = { maxHeight: 220, maxWidth: 260 } as const;
/**
* One image attachment. Its own component because the bytes are fetched through
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
* be called from inside a `.map()`.
*/
function ImageAttachment({
a,
onView,
}: {
a: SupportAttachmentDto;
onView: (a: SupportAttachmentDto, src: string) => void;
}) {
const { src, failed } = useAttachmentObjectUrl(a.url);
if (failed) {
return (
<Group gap={6} c="dimmed">
<ImageOff size={14} />
<Text size="xs">Couldn't load {a.name}</Text>
</Group>
);
}
if (!src) {
return (
<Box
style={{
width: THUMB.maxWidth,
height: 140,
display: "grid",
placeItems: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 8,
}}
>
<Loader size="xs" color="edr-green" />
</Box>
);
}
return (
<Box
onClick={() => onView(a, src)}
style={{ cursor: "zoom-in", borderRadius: 8, overflow: "hidden" }}
>
<Image src={src} alt={a.name} radius="md" fit="cover" style={THUMB} />
</Box>
);
}
/**
* Attachments inside a message bubble: images as thumbnails, everything else as
* a labelled file row. Non-images are not fetched until opened — pulling every
* document in a thread just to draw a filename would be wasteful.
*/
export function MessageAttachments({
attachments,
mine,
onView,
onOpenFile,
}: {
attachments: SupportAttachmentDto[];
mine: boolean;
onView: (a: SupportAttachmentDto, src: string) => void;
onOpenFile: (a: SupportAttachmentDto) => void;
}) {
if (attachments.length === 0) return null;
return (
<Stack gap={6} mt={6}>
{attachments.map((a) =>
isSupportAttachmentImage(a.mimeType) ? (
<ImageAttachment key={a.id} a={a} onView={onView} />
) : (
<Paper
key={a.id}
onClick={() => onOpenFile(a)}
px="sm"
py={6}
radius="md"
style={{
cursor: "pointer",
background: mine ? "rgba(255,255,255,0.16)" : "white",
border: mine ? "none" : "1px solid var(--mantine-color-gray-3)",
}}
>
<Group gap={8} wrap="nowrap">
<FileText size={16} style={{ flexShrink: 0 }} />
<Box style={{ minWidth: 0 }}>
<Text size="xs" fw={600} truncate>
{a.name}
</Text>
<Text size="10px" opacity={0.75}>
{formatBytes(a.size)}
</Text>
</Box>
</Group>
</Paper>
),
)}
</Stack>
);
}

View File

@@ -1,8 +1,8 @@
import type {
SendSupportMessageDto,
SupportConversationDto,
SupportConversationListResult,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import { api } from "@/auth/http";
@@ -14,6 +14,33 @@ export interface ListConversationsParams {
limit?: number;
}
export interface ListMessagesParams {
/** Opaque cursor from the previous page's `nextCursor`. */
before?: string;
limit?: number;
}
/** What the composer hands over: text, files, or both (never neither). */
export interface SendMessageInput {
body?: string;
attachments?: File[];
}
/**
* A message with files goes as multipart so the server can persist them against
* the message it creates in the same request; text-only stays JSON. Letting
* axios set the multipart boundary itself is deliberate — setting
* `Content-Type` by hand omits the boundary and the request fails to parse.
*/
function toRequestBody(input: SendMessageInput): FormData | { body?: string } {
if (!input.attachments?.length) return { body: input.body };
const form = new FormData();
if (input.body) form.append("body", input.body);
for (const file of input.attachments) form.append("attachments", file);
return form;
}
/**
* Backoffice (agent) support-chat REST calls. The backoffice axios `api`
* response interceptor already unwraps the `{ success, data }` envelope, so
@@ -29,19 +56,39 @@ export const supportApi = {
);
return data;
},
listMessages: async (id: string): Promise<SupportMessageDto[]> => {
const { data } = await api.get<SupportMessageDto[]>(
listMessages: async (
id: string,
params: ListMessagesParams = {},
): Promise<SupportMessageListResult> => {
const { data } = await api.get<SupportMessageListResult>(
`/support/agent/conversations/${id}/messages`,
{ params },
);
return data;
},
/**
* Attachment bytes, fetched through the authenticated client.
*
* Deliberately not a direct `<img src={url}>`: the API guard reads the bearer
* token from the Authorization header only — there is no cookie fallback — and
* an `<img>` request cannot carry one, so a direct src is an unavoidable 401.
* Same reason `filesService.download` exists for booking documents. The caller
* wraps this blob in an object URL.
*/
fetchAttachment: async (relativeUrl: string): Promise<Blob> => {
// The DTO path is absolute from the API root (`/api/...`), but this client's
// baseURL already ends in `/api` — drop the duplicate prefix.
const path = relativeUrl.replace(/^\/api/, "");
const { data } = await api.get(path, { responseType: "blob" });
return data as unknown as Blob;
},
sendMessage: async (
id: string,
body: SendSupportMessageDto,
input: SendMessageInput,
): Promise<SupportMessageDto> => {
const { data } = await api.post<SupportMessageDto>(
`/support/agent/conversations/${id}/messages`,
body,
toRequestBody(input),
);
return data;
},

View File

@@ -0,0 +1,130 @@
import {
isSupportAttachmentImage,
SUPPORT_ATTACHMENT_MAX_BYTES,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
isSupportAttachmentAllowed,
} from "@edr/types";
import { useCallback, useEffect, useRef, useState } from "react";
/** A file staged in the composer, not yet sent. */
export interface PendingAttachment {
/** Local-only id; the server id doesn't exist until the message is sent. */
id: string;
file: File;
/** Object URL, images only. Revoked when the entry goes away. */
previewUrl?: string;
}
let nextId = 0;
/**
* Staging area for files being attached to a message.
*
* Files are held client-side until send, then posted alongside the text in one
* multipart request — there's no upload-then-reference step, so nothing to
* garbage-collect if the agent changes their mind.
*
* Object URLs for image previews are revoked on removal and unmount; without
* that, pasting screenshots into a long-lived chat page leaks the full bytes of
* every image for the life of the tab.
*/
export function useAttachmentDraft(onReject?: (reason: string) => void) {
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const rejectRef = useRef(onReject);
rejectRef.current = onReject;
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
// still-live URLs) on every change to the list.
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
useEffect(
() => () => {
for (const a of attachmentsRef.current) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
},
[],
);
const add = useCallback((files: File[]) => {
if (files.length === 0) return;
setAttachments((current) => {
const accepted: PendingAttachment[] = [];
for (const file of files) {
if (
current.length + accepted.length >=
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE
) {
rejectRef.current?.(
`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
);
break;
}
if (!isSupportAttachmentAllowed(file.type)) {
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
continue;
}
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
rejectRef.current?.(
`${file.name} is over the ${
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
}MB limit.`,
);
continue;
}
accepted.push({
id: `pending-${nextId++}`,
file,
previewUrl: isSupportAttachmentImage(file.type)
? URL.createObjectURL(file)
: undefined,
});
}
return accepted.length ? [...current, ...accepted] : current;
});
}, []);
const remove = useCallback((id: string) => {
setAttachments((current) => {
const target = current.find((a) => a.id === id);
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
return current.filter((a) => a.id !== id);
});
}, []);
const clear = useCallback(() => {
setAttachments((current) => {
for (const a of current) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
return [];
});
}, []);
/**
* Pull files off a paste. Returns true if anything was taken, so the caller
* can suppress the default paste — otherwise pasting a screenshot also drops
* its filename (or nothing) into the textarea.
*
* Copying an image in most apps puts BOTH the bitmap and some text/html on the
* clipboard, so check for files first and only then let the text through.
*/
const addFromPaste = useCallback(
(clipboard: DataTransfer | null): boolean => {
const files = Array.from(clipboard?.files ?? []);
if (files.length === 0) return false;
add(files);
return true;
},
[add],
);
return {
attachments,
files: attachments.map((a) => a.file),
add,
addFromPaste,
remove,
clear,
};
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { supportApi } from "./supportApi";
/**
* Blob object URL for an attachment, or `undefined` while it loads / on failure.
*
* Chat attachments cannot be rendered with a direct `<img src={a.url}>`. The API
* guard takes the bearer token from the `Authorization` header and has no cookie
* fallback, and an `<img>` request cannot carry that header — a direct src is an
* unavoidable 401. So the bytes are fetched through the authenticated client and
* handed to the browser as an object URL, the same way booking documents are
* downloaded.
*
* The URL is revoked on unmount and whenever the attachment changes, so a thread
* scrolled through hundreds of images doesn't pin all of them in memory.
*/
export function useAttachmentObjectUrl(relativeUrl: string): {
src?: string;
failed: boolean;
} {
const [src, setSrc] = useState<string>();
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let created: string | undefined;
setSrc(undefined);
setFailed(false);
supportApi
.fetchAttachment(relativeUrl)
.then((blob) => {
// The component may have unmounted mid-flight; creating a URL then would
// leak it, since the cleanup below has already run.
if (cancelled) return;
created = URL.createObjectURL(blob);
setSrc(created);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [relativeUrl]);
return { src, failed };
}
/**
* On-demand variant for files that aren't previewed inline (documents): fetch
* only when the user actually opens one, rather than pulling every attachment in
* the thread down just to render a filename row.
*
* Holds a single slot — opening another file revokes the previous URL, as does
* unmounting.
*/
export function useLazyAttachmentObjectUrl(): (
relativeUrl: string,
) => Promise<string> {
const current = useRef<string>();
useEffect(
() => () => {
if (current.current) URL.revokeObjectURL(current.current);
},
[],
);
return useCallback(async (relativeUrl: string) => {
const blob = await supportApi.fetchAttachment(relativeUrl);
if (current.current) URL.revokeObjectURL(current.current);
current.current = URL.createObjectURL(blob);
return current.current;
}, []);
}

View File

@@ -1,6 +1,23 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
SupportConversationDto,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import { useMemo } from "react";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type InfiniteData,
type QueryClient,
} from "@tanstack/react-query";
import { supportApi, type ListConversationsParams } from "./supportApi";
import {
supportApi,
type ListConversationsParams,
type SendMessageInput,
} from "./supportApi";
export const SUPPORT_KEY = ["support"] as const;
export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] as const;
@@ -8,20 +25,145 @@ export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
export const supportMessagesKey = (id: string) =>
["support", "messages", id] as const;
/** Shared inbox: every thread, filterable by unread + company-name search. */
/** Threads per page in the inbox. */
const CONVERSATIONS_PAGE_SIZE = 20;
/** Messages per page in a thread. */
const MESSAGES_PAGE_SIZE = 30;
/**
* Shared inbox: every thread, filterable by unread + company-name search.
*
* Pages on scroll. This previously asked for `limit: 100` and rendered whatever
* came back — which silently truncated the inbox at the server's own max of 100
* with no way to reach the rest.
*/
export function useConversations(params: ListConversationsParams = {}) {
return useQuery({
const query = useInfiniteQuery({
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
queryFn: () => supportApi.listConversations({ limit: 100, ...params }),
queryFn: ({ pageParam }) =>
supportApi.listConversations({
...params,
page: pageParam,
limit: CONVERSATIONS_PAGE_SIZE,
}),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((n, page) => n + page.items.length, 0);
return loaded < lastPage.count ? allPages.length + 1 : undefined;
},
});
/**
* Flatten for rendering, keeping the page-level fields (count/unreadCount)
* from the newest fetch so badges don't go stale as more pages load.
*
* De-duplicated by id because this list pages by OFFSET over a sort key that
* moves: a thread jumps to rank 1 the moment it gets a message, shifting
* everything down, so a row already shown on page 1 can be served again on
* page 2 — and React would then see two children with the same key. The
* conversations invalidate that rides along with every such event heals the
* ordering a beat later; this just stops the intervening render from breaking.
*
* Keyset wouldn't help here, unlike the message list: the sort key itself
* mutates, so no cursor over it is stable either.
*/
const items = useMemo(() => {
const seen = new Set<string>();
const flat: SupportConversationDto[] = [];
for (const page of query.data?.pages ?? []) {
for (const conversation of page.items) {
if (seen.has(conversation.id)) continue;
seen.add(conversation.id);
flat.push(conversation);
}
}
return flat;
}, [query.data]);
return {
...query,
items,
count: query.data?.pages[0]?.count ?? 0,
unreadCount: query.data?.pages[0]?.unreadCount ?? 0,
};
}
/**
* A thread's messages, paged backwards from newest.
*
* react-query's "next page" is *older* history here, so `pages` runs
* newest-block-first and has to be reversed to render top-to-bottom in time
* order. Cursor-based rather than offset so a message arriving mid-scroll
* doesn't shift the pages already loaded.
*/
export function useMessages(conversationId: string | null) {
return useQuery({
const query = useInfiniteQuery({
queryKey: supportMessagesKey(conversationId ?? ""),
queryFn: () => supportApi.listMessages(conversationId as string),
queryFn: ({ pageParam }) =>
supportApi.listMessages(conversationId as string, {
before: pageParam,
limit: MESSAGES_PAGE_SIZE,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
enabled: !!conversationId,
});
const messages = useMemo(
() => [...(query.data?.pages ?? [])].reverse().flatMap((p) => p.items),
[query.data],
);
// Exposed so the view can tell a prepended history page from a message
// appended at the bottom — `messages` changing says nothing about which.
return { ...query, messages, pageCount: query.data?.pages.length ?? 0 };
}
/**
* Splice a newly-arrived message into a cached thread.
*
* Deliberately not `invalidateQueries`: that refetches *every* page the agent
* has scrolled back through, so the cost of each inbound message would grow with
* how far they've read. Page 0 is the newest block and its items are oldest-first
* within the block, so the new message belongs on its end.
*
* No-ops when the thread isn't cached — nothing is rendering it, and seeding a
* partial cache here would leave a thread whose "first page" is one message and
* whose `nextCursor` is missing.
*/
/**
* Why this reports an outcome rather than a boolean: the two ways it can decline
* to append need opposite handling. A duplicate is the sender's own echo and must
* be ignored — refetching there would undo the whole point. "Uncached" means the
* thread's first page is still in flight and may have been read on the server
* *before* this message existed, so dropping it silently would lose it until
* something else happened to refetch; the caller refetches instead. That's cheap
* precisely because nothing is loaded yet.
*/
export type AppendOutcome = "appended" | "duplicate" | "uncached";
export function appendMessageToCache(
qc: QueryClient,
message: SupportMessageDto,
): AppendOutcome {
let outcome: AppendOutcome = "uncached";
qc.setQueryData<InfiniteData<SupportMessageListResult>>(
supportMessagesKey(message.conversationId),
(current) => {
if (!current?.pages.length) return current;
const [newest, ...rest] = current.pages;
if (newest.items.some((m) => m.id === message.id)) {
outcome = "duplicate";
return current;
}
outcome = "appended";
return {
...current,
pages: [{ ...newest, items: [...newest.items, message] }, ...rest],
};
},
);
return outcome;
}
export function useSupportUnreadCount(enabled = true) {
@@ -36,10 +178,13 @@ export function useSupportUnreadCount(enabled = true) {
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: string) =>
supportApi.sendMessage(conversationId, { body }),
mutationFn: (input: SendMessageInput) =>
supportApi.sendMessage(conversationId, input),
// The gateway echoes our own message back over the socket, which appends it
// to the cache — so don't invalidate the thread here or every send would
// refetch every page the agent has scrolled through. The conversation list
// still needs a refresh for its last-message preview and ordering.
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});

View File

@@ -12,6 +12,7 @@ import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
appendMessageToCache,
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
@@ -45,14 +46,25 @@ export function useSupportSocket(
withCredentials: true,
});
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
});
socket.on(
SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW,
(event: SupportMessageEvent) => {
// Append rather than invalidate: the thread is paginated, and invalidating
// it would refetch every page the agent has scrolled back through on every
// single inbound message.
if (appendMessageToCache(qc, event.message) === "uncached") {
// The thread's first page is still loading and may have been read before
// this message existed — without this it would go missing until some
// unrelated refetch. Cheap: there are no pages to re-fetch yet.
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
}
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
},
);
socket.on(
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
/** Seconds a user must wait before another OTP can be requested. */
const DEFAULT_COOLDOWN_SECONDS = 60;
/**
* Countdown that gates the "Resend code" button. Ticks with setTimeout rather
* than wall-clock arithmetic, so it needs no Date.now().
*/
export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) {
const [secondsLeft, setSecondsLeft] = useState(0);
useEffect(() => {
if (secondsLeft <= 0) return;
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [secondsLeft]);
return {
secondsLeft,
start: () => setSecondsLeft(seconds),
reset: () => setSecondsLeft(0),
};
}

View File

@@ -154,6 +154,15 @@ export function useWarehouseOpsStats() {
});
}
/** Trucks in the yard right now — refreshes with the rest of the ops widgets. */
export function useTrucksOnSite() {
return useQuery({
queryKey: ['warehouse-inventory', 'trucks-on-site'],
queryFn: () => warehouseService.trucksOnSite().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
/** How often the live warehouse dashboard widgets auto-refresh (ms). */
export const DASHBOARD_REFETCH_MS = 60_000;

View File

@@ -1,10 +1,5 @@
import { MutationCache, QueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
extractApiErrorPayload,
isGlobalErrorModalSuppressed,
} from "@/components/errors/ApiErrorModal";
import type { InvalidatesMeta } from "@/utils/endpoint";
/**
@@ -28,19 +23,9 @@ export const queryClient = new QueryClient({
void queryClient.invalidateQueries({ queryKey });
}
},
// Global mutation-failure surface: show the SERVER's actual message instead
// of a page's hardcoded "Failed to …". On pages where the global error
// modal shows (non-suppressed), it already carries the message, so a toast
// here would double up — fire the toast only on modal-suppressed paths
// (warehouse / first-last mile / onboarding). Opt a single mutation out with
// meta.skipGlobalErrorToast when it handles the error inline (e.g. a modal).
onError: (error, _variables, _context, mutation) => {
if (mutation.meta?.skipGlobalErrorToast) return;
if (!isGlobalErrorModalSuppressed(window.location.pathname)) return;
const payload = extractApiErrorPayload(error);
if (!payload?.messages.length) return;
toast.error(payload.messages.join("\n"));
},
// Mutation failures are surfaced globally by the axios interceptor in
// auth/http.ts (server-message toast on every non-401 failure), so no
// onError toast here — it would double up.
}),
defaultOptions: {
queries: {

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>
);
}

View File

@@ -10,6 +10,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
@@ -2567,6 +2568,13 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
),
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
"customers",
"resetTarget",
({ companyId }) => customersService.resetTarget(companyId),
({ companyId }) => QUERY_KEYS.CUSTOMERS.resetTarget(companyId),
),
resetPassword: endpoint<
{ companyId: string; channel: ResetChannel },
ResetPasswordResult
@@ -2623,6 +2631,25 @@ export const api = {
],
),
/**
* Ask the customer to correct one document. Invalidates the documents list
* and the company itself, since an open request blocks role approval.
*/
requestDocumentChange: endpoint<
{ companyId: string; fileId: string; note: string },
CustomerDocument
>(
"customers",
"requestDocumentChange",
({ fileId, note }) =>
customersService.requestDocumentChange(fileId, note),
undefined,
({ companyId }) => [
QUERY_KEYS.CUSTOMERS.documents(companyId),
QUERY_KEYS.CUSTOMERS.byId(companyId),
],
),
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers",
"setCompanyStatus",

View File

@@ -9,6 +9,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
@@ -89,8 +90,20 @@ export const customersService = {
},
/**
* Send a password-reset code to the company's primary contact. Staff never
* receive a credential the customer sets their own password from the code.
* The IAM account a reset link would go to. Read before offering the action
* so staff see the credentials the link actually reaches, not the company's
* business contact details.
*/
resetTarget(companyId: string): Promise<CustomerResetTarget> {
return apiClient
.get<CustomerResetTarget>(URL_CONSTANTS.COMPANIES.RESET_TARGET(companyId))
.then((r) => r.data);
},
/**
* Send a password-reset link to the company's primary contact. Staff never
* receive a credential — the customer opens the link and sets their own
* password.
*/
resetPassword(
companyId: string,
@@ -151,4 +164,21 @@ export const customersService = {
)
.then((r) => r.data);
},
/**
* Ask the customer to correct one uploaded document. Narrower than rejecting
* the whole role: the customer keeps their other documents and only re-uploads
* this one, but the role cannot be approved until they do.
*/
requestDocumentChange(
fileId: string,
note: string,
): Promise<CustomerDocument> {
return apiClient
.post<CustomerDocument>(
URL_CONSTANTS.COMPANIES.DOCUMENT_REQUEST_CHANGE(fileId),
{ note },
)
.then((r) => r.data);
},
};

View File

@@ -5,6 +5,7 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
TruckOnSite,
WarehouseOpsStats,
WarehouseThroughputPoint,
WarehouseDwellStats,
@@ -404,6 +405,9 @@ export const warehouseService = {
),
opsStats: () =>
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
/** Trucks inside the yard right now — the list behind the trucksOnSite figure. */
trucksOnSite: () =>
apiClient.get<TruckOnSite[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.TRUCKS_ON_SITE),
throughput: (granularity: 'week' | 'month' | 'year') =>
apiClient.get<WarehouseThroughputPoint[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),

View File

@@ -110,13 +110,27 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
/** The channel a customer's password-reset code is delivered over. */
/** The channel a customer's password-reset link is delivered over. */
export type ResetChannel = "email" | "phone";
export interface ResetPasswordResult {
channel: ResetChannel;
/** Where the code went, e.g. `+251•••4821` — safe to show to staff. */
/** Where the link went, e.g. `+251•••4821` — safe to show to staff. */
maskedTarget: string;
/** ISO timestamp after which the link stops working. */
expiresAt: string;
}
/**
* The IAM account a reset link would reach — the company's primary contact.
* Distinct from `Company.email` / `Company.phone`, which are business contact
* details and routinely differ from the credentials the customer logs in with.
*/
export interface CustomerResetTarget {
userId: string;
name: string;
email: string | null;
phone: string | null;
}
/** Mirrors backend `Company` (+ its `companyProfiles`). */
@@ -188,6 +202,13 @@ export interface CompanyListFilter {
status?: CompanyStatus;
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
onboardingCompleted?: boolean;
/**
* `true` = only customers with an open profile change request. They are
* already `active`, so `status` alone can never surface them.
*/
hasPendingChangeRequest?: boolean;
sortBy?: "name" | "createdAt" | "updatedAt";
sortOrder?: "ASC" | "DESC";
}
/** Standard paginated list envelope (matches the bookings service shape). */
@@ -206,6 +227,8 @@ export interface CompanyStats {
onboarding: number;
suspended: number;
blacklisted: number;
/** Approved customers whose submitted profile edits are awaiting review. */
pendingChanges: number;
}
/* ------------------------------------------------------------------ *
@@ -248,6 +271,15 @@ export interface CustomerDocument {
size: number;
uploadedAt: string;
url?: string | null;
/**
* Reviewer verdict. `change_requested` means the customer has been asked to
* re-upload a corrected version and the role cannot be approved until they do;
* `null` means nobody has reviewed this document.
*/
reviewStatus?: "change_requested" | "approved" | null;
/** The reviewer's reason, shown to the customer verbatim. */
reviewNote?: string | null;
reviewedAt?: string | null;
}
export type CustomerPaymentStatus =

View File

@@ -1121,6 +1121,26 @@ export interface WarehouseOpsStats {
itemsAging: number;
}
/**
* One truck inside the yard. Both haulage paths appear here because the gate
* handles both: `CUSTOMER` is the customer's own truck, `EDR` a last-mile truck.
*/
export interface TruckOnSite {
source: "CUSTOMER" | "EDR";
assignmentId: string;
/** INBOUND = assigned, not yet arrived; ON_SITE = arrived, not yet departed. */
status: "INBOUND" | "ON_SITE";
plateNumber: string | null;
driverName: string | null;
truckType: string | null;
arrivedAt: string | null;
bookingId: string;
bookingReference: string | null;
customerName: string | null;
/** Comma-separated container numbers; null for bulk. */
containers: string | null;
}
/** One bucket of the received-vs-dispatched throughput time series. */
export interface WarehouseThroughputPoint {
periodStart: string;

View File

@@ -0,0 +1,22 @@
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
export function 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();
}
/** Mask all but the first 7 chars of an E.164 phone for display. */
export const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
/** Mask the local part of an email for display (j***e@example.com). */
export const maskEmail = (email: string) => {
const [local, domain] = email.split("@");
if (!local || !domain) return email;
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
return `${local[0]}***${local[local.length - 1]}@${domain}`;
};

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
/** Live checklist shown under the password field. Mirrors {@link passwordField}. */
export const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{
label: "One special character",
test: (v: string) => /[^A-Za-z0-9]/.test(v),
},
] as const;
/**
* Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto`
* — a password this accepts but the API rejects surfaces as an opaque 400.
*/
export const passwordField = z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character");
export const confirmPasswordField = z
.string()
.min(1, "Please confirm your password");
export const samePassword = (data: {
password: string;
confirmPassword: string;
}) => data.password === data.confirmPassword;
/** Every requirement in {@link passwordRequirements} is satisfied. */
export const meetsAllRequirements = (value: string) =>
passwordRequirements.every((r) => r.test(value));