mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'll send a one-time code to this customer's primary contact.
|
||||
They choose their own new password — you will not see it.
|
||||
We'll send a single-use link to this customer'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'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'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>
|
||||
</>
|
||||
|
||||
@@ -13,6 +13,10 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
type RequestDocumentChangeModalProps,
|
||||
} from "./RequestDocumentChangeModal";
|
||||
export {
|
||||
default as ResetPasswordAction,
|
||||
type ResetPasswordActionProps,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 1–2 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user