mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
Merge pull request #1279 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Package } from "lucide-react";
|
||||
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
@@ -16,6 +16,19 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const { tons, items } = cargoTonsAndItems(booking);
|
||||
|
||||
// Booking-level flags OR any container line carrying a count — the flag can
|
||||
// lag the lines (per-line opt-ins), so either alone must light the tile.
|
||||
const isHazardous =
|
||||
booking.isHazardous ||
|
||||
containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
|
||||
const isReefer =
|
||||
booking.isReefer ||
|
||||
containers.some((c) => Number(c.reeferQuantity ?? 0) > 0);
|
||||
const showHandlingColumns = containers.some(
|
||||
(c) =>
|
||||
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
@@ -27,11 +40,33 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
value={booking.isHazardous ? "Yes" : "No"}
|
||||
highlight={booking.isHazardous}
|
||||
value={isHazardous ? "Yes" : "No"}
|
||||
highlight={isHazardous}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Refrigerated"
|
||||
value={isReefer ? "Yes" : "No"}
|
||||
highlight={isReefer}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Handling that changes how the yard treats the shipment is flagged
|
||||
loudly, not buried in the grid. */}
|
||||
{(isHazardous || isReefer) && (
|
||||
<Box mt="sm">
|
||||
{isHazardous && (
|
||||
<Badge color="red" variant="filled" radius="sm" mr={8}>
|
||||
Hazardous cargo
|
||||
</Badge>
|
||||
)}
|
||||
{isReefer && (
|
||||
<Badge color="blue" variant="filled" radius="sm">
|
||||
Refrigerated cargo
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{containers.length > 0 && (
|
||||
<>
|
||||
<Divider my="lg" color="var(--mantine-color-gray-2)" />
|
||||
@@ -42,6 +77,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
<Table.Th>Container type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>VGM / unit</Table.Th>
|
||||
{showHandlingColumns && <Table.Th>Hazardous</Table.Th>}
|
||||
{showHandlingColumns && <Table.Th>Reefer</Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -54,6 +91,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
</Table.Td>
|
||||
<Table.Td>{c.quantity}</Table.Td>
|
||||
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
|
||||
{showHandlingColumns && (
|
||||
<Table.Td>
|
||||
{Number(c.hazardousQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="red" size="sm">
|
||||
{c.hazardousQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
{showHandlingColumns && (
|
||||
<Table.Td>
|
||||
{Number(c.reeferQuantity ?? 0) > 0 ? (
|
||||
<Text fw={700} c="blue" size="sm">
|
||||
{c.reeferQuantity}
|
||||
</Text>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
HandCoins,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
@@ -70,6 +71,18 @@ export const buildSidebarSections = (
|
||||
icon: <Building2 />,
|
||||
permission: FREIGHT_PERMS.customers.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Lines",
|
||||
href: "/dashboard/shipping-lines",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.shippingLines.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Line Credits",
|
||||
href: "/dashboard/shipping-line-credits",
|
||||
icon: <HandCoins />,
|
||||
permission: FREIGHT_PERMS.shippingLineCredits.view,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
href: "/dashboard/contract-requests",
|
||||
|
||||
@@ -227,6 +227,12 @@ const RuleEngineFormDialog = ({
|
||||
current[name] ? { ...current, [name]: "" } : current,
|
||||
);
|
||||
setValues((current) => {
|
||||
// Mantine fires onChange even when the same option is re-picked, and the
|
||||
// cascades below clear dependent answers (yards, unit, scope). Re-picking
|
||||
// an unchanged value must be a no-op, or an untouched direction silently
|
||||
// wipes the yard pair and the submit fails with "origin/destination
|
||||
// missing" data the admin did fill in.
|
||||
if (current[name] === value) return current;
|
||||
const next = { ...current, [name]: value };
|
||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||
@@ -257,6 +263,34 @@ const RuleEngineFormDialog = ({
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Turning the shipping-line toggle on or off swaps the entire form, so
|
||||
// nothing answered under the other shape may survive into the payload.
|
||||
if (name === "isShippingLineRate") {
|
||||
next.shippingLineCompanyId = "";
|
||||
next.shippingLineRateKind = "";
|
||||
next.shippingLineCargoKind = "";
|
||||
next.appliesTo = "";
|
||||
next.trigger = "";
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Base-vs-surcharge and container-vs-bulk each decide the scope field and
|
||||
// the legal units for a shipping-line rate, exactly as appliesTo and
|
||||
// cargoKind do on the customer form.
|
||||
if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") {
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
if (name === "shippingLineRateKind") {
|
||||
next.shippingLineCargoKind = "";
|
||||
next.trigger = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -347,12 +381,23 @@ const RuleEngineFormDialog = ({
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
{field.description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{field.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Switch
|
||||
checked={Boolean(values[field.name])}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
||||
// A toggle that re-targets what an existing record means (e.g. who
|
||||
// a rate is priced for) is create-only — flipping it on a saved row
|
||||
// would silently change every booking that prices off it.
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
|
||||
size="md"
|
||||
color="edr-green"
|
||||
/>
|
||||
@@ -493,6 +538,12 @@ const RuleEngineFormDialog = ({
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||
// A derived select shows (and submits) its computed value and is locked,
|
||||
// matching the text-input branch — used by fields the shape decides on the
|
||||
// admin's behalf, e.g. a shipping-line rate's import-only direction.
|
||||
const computedSelect = field.computeValue
|
||||
? String(field.computeValue(values) ?? "")
|
||||
: undefined;
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
@@ -501,9 +552,18 @@ const RuleEngineFormDialog = ({
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
}
|
||||
value={resolveSelectValue(field, values)}
|
||||
value={
|
||||
computedSelect !== undefined
|
||||
? computedSelect
|
||||
: resolveSelectValue(field, values)
|
||||
}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
disabled={
|
||||
selectOptionsLoading ||
|
||||
field.disabled ||
|
||||
(field.disabledOnEdit && !!initialRecord) ||
|
||||
computedSelect !== undefined
|
||||
}
|
||||
// Mantine's Select is not a native input, so `required` only marks it
|
||||
// visually — handleSubmit is what actually blocks an empty one.
|
||||
required={field.required}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Ban, Check, HandCoins, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { formatMoney } from "@/components/customers";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CreditInvoiceActionType,
|
||||
CreditInvoicePendingAction,
|
||||
} from "@/types/shippingLineCredit";
|
||||
|
||||
/** The slice of an invoice row the actions need — both list pages have it. */
|
||||
export interface CreditInvoiceActionTarget {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
totalAmount: string | number;
|
||||
paidAmount: string | number;
|
||||
balanceAmount: string | number;
|
||||
}
|
||||
|
||||
/** Statuses an offline payment can still be recorded against. */
|
||||
const MARK_PAID_STATUSES = new Set([
|
||||
"ISSUED",
|
||||
"PENDING",
|
||||
"PAYMENT_PROCESSING",
|
||||
"PARTIALLY_PAID",
|
||||
"OVERDUE",
|
||||
]);
|
||||
|
||||
const ACTION_LABEL: Record<CreditInvoiceActionType, string> = {
|
||||
MARK_PAID: "Mark paid",
|
||||
CANCEL: "Cancel invoice",
|
||||
};
|
||||
|
||||
export interface CreditInvoiceActionsProps {
|
||||
invoice: CreditInvoiceActionTarget;
|
||||
pendingAction: CreditInvoicePendingAction | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-step actions for ONE shipping-line credit invoice, embeddable in any
|
||||
* invoice list. Gated purely by permission: the request grants raise
|
||||
* mark-paid / cancel, the approve/reject grants decide ANY pending request —
|
||||
* the holder's own included. Renders only the buttons the signed-in user's
|
||||
* grants allow; the API enforces the same gates server-side.
|
||||
*/
|
||||
export default function CreditInvoiceActions({
|
||||
invoice,
|
||||
pendingAction,
|
||||
}: CreditInvoiceActionsProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const canRequestPaid = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid,
|
||||
);
|
||||
const canRequestCancel = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceCancel,
|
||||
);
|
||||
const canApprove = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceApprove,
|
||||
);
|
||||
const canReject = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.shippingLineCredits.invoiceReject,
|
||||
);
|
||||
|
||||
const [requestAction, setRequestActionModal] =
|
||||
useState<CreditInvoiceActionType | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [paymentReference, setPaymentReference] = useState("");
|
||||
const [decideApprove, setDecideApprove] = useState<boolean | null>(null);
|
||||
const [decisionNote, setDecisionNote] = useState("");
|
||||
|
||||
const closeRequest = () => {
|
||||
setRequestActionModal(null);
|
||||
setReason("");
|
||||
setPaymentReference("");
|
||||
};
|
||||
const closeDecide = () => {
|
||||
setDecideApprove(null);
|
||||
setDecisionNote("");
|
||||
};
|
||||
|
||||
const { mutate: submitRequest, isPending: isRequesting } = useMutation(
|
||||
api.shippingLineCredits.requestInvoiceAction.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
closeRequest();
|
||||
toast({
|
||||
title: "Request submitted",
|
||||
description: `${ACTION_LABEL[variables.action]} on ${invoice.invoiceNumber} now awaits a chief's approval.`,
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not submit request",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { mutate: submitDecision, isPending: isDeciding } = useMutation(
|
||||
api.shippingLineCredits.decideInvoiceAction.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
closeDecide();
|
||||
toast({
|
||||
title: variables.approve ? "Request approved" : "Request rejected",
|
||||
description: variables.approve
|
||||
? pendingAction?.action === "MARK_PAID"
|
||||
? "The offline payment was recorded; the invoice and its credits are now paid."
|
||||
: "The invoice was cancelled; its credits returned to the unbilled pool."
|
||||
: "The request was rejected and nothing was changed.",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not decide request",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
let body = null;
|
||||
if (pendingAction) {
|
||||
body = (
|
||||
<Stack gap={6} py={4}>
|
||||
<Badge variant="light" color="orange" title={pendingAction.reason}>
|
||||
{ACTION_LABEL[pendingAction.action]} — awaiting approval
|
||||
</Badge>
|
||||
{canApprove || canReject ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canApprove ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={12} />}
|
||||
onClick={() => setDecideApprove(true)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{canReject ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<X size={12} />}
|
||||
onClick={() => setDecideApprove(false)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
const showMarkPaid =
|
||||
canRequestPaid && MARK_PAID_STATUSES.has(invoice.status);
|
||||
const showCancel =
|
||||
canRequestCancel &&
|
||||
invoice.status !== "CANCELLED" &&
|
||||
invoice.status !== "PAID" &&
|
||||
invoice.status !== "REFUNDED" &&
|
||||
Number(invoice.paidAmount) === 0;
|
||||
body =
|
||||
!showMarkPaid && !showCancel ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{showMarkPaid ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<HandCoins size={12} />}
|
||||
onClick={() => setRequestActionModal("MARK_PAID")}
|
||||
>
|
||||
Mark paid
|
||||
</Button>
|
||||
) : null}
|
||||
{showCancel ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Ban size={12} />}
|
||||
onClick={() => setRequestActionModal("CANCEL")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{body}
|
||||
|
||||
{/* Maker: raise the request. */}
|
||||
<Modal
|
||||
opened={requestAction !== null}
|
||||
onClose={closeRequest}
|
||||
title={
|
||||
requestAction
|
||||
? `${ACTION_LABEL[requestAction]} — ${invoice.invoiceNumber}`
|
||||
: ""
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{requestAction === "MARK_PAID"
|
||||
? "Records a full offline settlement of the outstanding balance. Takes effect only after a chief approves."
|
||||
: "Voids the invoice and returns its credits to the unbilled pool. Takes effect only after a chief approves."}
|
||||
</Text>
|
||||
{requestAction === "MARK_PAID" ? (
|
||||
<TextInput
|
||||
label="Payment reference"
|
||||
description="Bank slip / transfer number, if any."
|
||||
value={paymentReference}
|
||||
onChange={(e) => setPaymentReference(e.currentTarget.value)}
|
||||
/>
|
||||
) : null}
|
||||
<Textarea
|
||||
label="Reason"
|
||||
withAsterisk
|
||||
minRows={2}
|
||||
placeholder={
|
||||
requestAction === "MARK_PAID"
|
||||
? "Paid by bank transfer, slip #…"
|
||||
: "Raised in error / rebilling with corrections…"
|
||||
}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeRequest}
|
||||
disabled={isRequesting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isRequesting}
|
||||
disabled={reason.trim().length < 3}
|
||||
onClick={() =>
|
||||
requestAction &&
|
||||
submitRequest({
|
||||
invoiceId: invoice.id,
|
||||
action: requestAction,
|
||||
reason: reason.trim(),
|
||||
paymentReference: paymentReference.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Submit for approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Checker: decide the request. */}
|
||||
<Modal
|
||||
opened={decideApprove !== null}
|
||||
onClose={closeDecide}
|
||||
title={
|
||||
pendingAction
|
||||
? `${decideApprove ? "Approve" : "Reject"}: ${ACTION_LABEL[pendingAction.action]} — ${invoice.invoiceNumber}`
|
||||
: ""
|
||||
}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{pendingAction ? (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<Text component="span" c="dimmed">
|
||||
Requested reason:{" "}
|
||||
</Text>
|
||||
{pendingAction.reason}
|
||||
</Text>
|
||||
{pendingAction.paymentReference ? (
|
||||
<Text size="sm">
|
||||
<Text component="span" c="dimmed">
|
||||
Payment reference:{" "}
|
||||
</Text>
|
||||
{pendingAction.paymentReference}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
{decideApprove && pendingAction ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{pendingAction.action === "MARK_PAID"
|
||||
? `Approving records ${formatMoney(
|
||||
Number(invoice.balanceAmount ?? invoice.totalAmount),
|
||||
invoice.currency,
|
||||
)} as paid offline and settles the invoice's credits.`
|
||||
: "Approving cancels the invoice and returns its credits to the unbilled pool."}
|
||||
</Text>
|
||||
) : null}
|
||||
<Textarea
|
||||
label={decideApprove ? "Note (optional)" : "Rejection note"}
|
||||
withAsterisk={!decideApprove}
|
||||
minRows={2}
|
||||
value={decisionNote}
|
||||
onChange={(e) => setDecisionNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeDecide}
|
||||
disabled={isDeciding}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color={decideApprove ? "edr-green" : "red"}
|
||||
loading={isDeciding}
|
||||
disabled={!decideApprove && !decisionNote.trim()}
|
||||
onClick={() =>
|
||||
pendingAction &&
|
||||
decideApprove !== null &&
|
||||
submitDecision({
|
||||
approvalId: pendingAction.id,
|
||||
approve: decideApprove,
|
||||
note: decisionNote.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{decideApprove ? "Approve & execute" : "Reject request"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
Radio,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
ResetChannel,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
/**
|
||||
* Whether the SMS gateway can actually reach this number.
|
||||
*
|
||||
* The carrier integration is domestic-only: anything else is queued and
|
||||
* silently lost, so a foreign number counts as unavailable rather than as a
|
||||
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service.
|
||||
*/
|
||||
function isDomesticPhone(rawPhone: string): boolean {
|
||||
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
|
||||
const normalized = digits.startsWith("+")
|
||||
? digits
|
||||
: /^251\d{9}$/.test(digits)
|
||||
? `+${digits}`
|
||||
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
|
||||
? `+251${digits.replace(/^0+/, "")}`
|
||||
: digits;
|
||||
return /^\+2519\d{8}$/.test(normalized);
|
||||
}
|
||||
|
||||
export interface ResendActivationActionProps {
|
||||
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a shipping line's activation link.
|
||||
*
|
||||
* The same single-use link registration sends: the carrier opens it and picks
|
||||
* their own password, so no credential is ever shown to or handled by staff.
|
||||
* Needed whenever the original send failed, expired (24h), or never arrived.
|
||||
*/
|
||||
export default function ResendActivationAction({
|
||||
shippingLine,
|
||||
}: ResendActivationActionProps) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [channel, setChannel] = useState<ResetChannel>("email");
|
||||
|
||||
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword);
|
||||
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.shippingLineCompanies.resendActivation.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setOpened(false);
|
||||
toast({
|
||||
title: "Activation link sent",
|
||||
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Could not send activation link",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
const phoneUsable =
|
||||
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber);
|
||||
const channelMissing = channel === "phone" && !phoneUsable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Resend activation link" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Resend activation link to ${shippingLine.name}`}
|
||||
onClick={(event) => {
|
||||
// The row itself is not clickable today, but stop here anyway so
|
||||
// adding a detail-page navigation later cannot swallow this click.
|
||||
event.stopPropagation();
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Resend activation link"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
We'll send a single-use link to {shippingLine.name}. They choose
|
||||
their own password — you will not see it. The link expires in 24
|
||||
hours, and sending a new one invalidates nothing they haven't
|
||||
already used.
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as ResetChannel)}
|
||||
label="Send the link via"
|
||||
>
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio
|
||||
value="email"
|
||||
label="Email"
|
||||
description={shippingLine.email}
|
||||
/>
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
!shippingLine.phoneNumber
|
||||
? "No phone number on this account"
|
||||
: !phoneUsable
|
||||
? `${shippingLine.phoneNumber} — foreign number, SMS unavailable; use email`
|
||||
: shippingLine.phoneNumber
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{channelMissing ? (
|
||||
<Alert color="yellow" variant="light" p="sm">
|
||||
<Text size="sm">
|
||||
This account has no number the SMS gateway can reach. Send the
|
||||
link by email instead.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
disabled={channelMissing}
|
||||
onClick={() => mutate({ id: shippingLine.id, channel })}
|
||||
>
|
||||
Send activation link
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,8 @@ import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
// Equal to open ⇒ 24-hour desk (the default).
|
||||
windowCloseHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -22,7 +22,8 @@ import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
// Equal to open ⇒ 24-hour desk (the default).
|
||||
windowCloseHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
Reference in New Issue
Block a user