mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -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'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'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;
|
||||
@@ -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} />}>
|
||||
|
||||
@@ -260,6 +260,8 @@ export default function BookingRequestDetailPage() {
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
paymentStatus={booking.paymentStatus}
|
||||
tradeDirection={booking.tradeDirection}
|
||||
/>
|
||||
</Box>
|
||||
<BookingActionsToolbar
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (A–Z)" },
|
||||
{ value: "name:DESC", label: "Name (Z–A)" },
|
||||
] 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user