mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add shipping line companies management
- Implement ShippingLineCompaniesService for registering and managing shipping line companies. - Create ResendActivationAction component for resending activation links to shipping lines. - Develop ShippingLineCompaniesPage for listing and registering shipping lines with validation. - Introduce shippingLineCompanies.service for API interactions related to shipping lines. - Define types for shipping line companies, including registration and pagination. - Add placeholder pages for shipping line portal, including home, bookings, help, invoices, and settings.
This commit is contained in:
@@ -34,6 +34,7 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai
|
||||
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
||||
@@ -242,6 +243,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipping-lines"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.shippingLines.view}>
|
||||
<ShippingLineCompaniesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
|
||||
@@ -73,6 +73,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
||||
icon: <Building2 />,
|
||||
permission: FREIGHT_PERMS.customers.view,
|
||||
},
|
||||
{
|
||||
label: "Shipping Lines",
|
||||
href: "/dashboard/shipping-lines",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.shippingLines.view,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
href: "/dashboard/contract-requests",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,14 @@ export const QUERY_KEYS = {
|
||||
byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
|
||||
},
|
||||
|
||||
SHIPPING_LINE_COMPANIES: {
|
||||
ROOT: ["shipping-line-companies"] as const,
|
||||
list: (page: number, limit: number) =>
|
||||
["shipping-line-companies", "list", page, limit] as const,
|
||||
byId: (id: string) =>
|
||||
["shipping-line-companies", "detail", id] as const,
|
||||
},
|
||||
|
||||
CUSTOMERS: {
|
||||
ROOT: ["customers"] as const,
|
||||
stats: ["customers", "stats"] as const,
|
||||
|
||||
@@ -77,6 +77,17 @@ export const URL_CONSTANTS = {
|
||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||
},
|
||||
|
||||
/**
|
||||
* Carriers with a portal login. Distinct from `/shipping-lines`, which is the
|
||||
* rule-engine's pricing lookup list (a code/label bookings reference).
|
||||
*/
|
||||
SHIPPING_LINE_COMPANIES: {
|
||||
BASE: "/shipping-line-companies",
|
||||
BY_ID: (id: string) => `/shipping-line-companies/${id}`,
|
||||
RESEND_ACTIVATION: (id: string) =>
|
||||
`/shipping-line-companies/${id}/resend-activation`,
|
||||
},
|
||||
|
||||
COMPANIES: {
|
||||
BASE: "/companies",
|
||||
STATS: "/companies/stats",
|
||||
|
||||
@@ -123,6 +123,12 @@ export const FREIGHT_PERMS = {
|
||||
verify: "edr_freight_app:customers:verify",
|
||||
resetPassword: "edr_freight_app:customers:reset-password",
|
||||
},
|
||||
shippingLines: {
|
||||
view: "edr_freight_app:shipping_lines:view",
|
||||
create: "edr_freight_app:shipping_lines:create",
|
||||
update: "edr_freight_app:shipping_lines:update",
|
||||
resetPassword: "edr_freight_app:shipping_lines:reset-password",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Info, Mail, Phone, Plus, Ship } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ResendActivationAction from "@/components/shipping-lines/ResendActivationAction";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { ShippingLineCompany } from "@/types/shippingLineCompany";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** SCAC is 2-4 letters; the API enforces the same rule. */
|
||||
const SCAC_PATTERN = /^[A-Za-z]{2,4}$/;
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
scacCode: string;
|
||||
imoNumber: string;
|
||||
bicCode: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormValues = {
|
||||
name: "",
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
scacCode: "",
|
||||
imoNumber: "",
|
||||
bicCode: "",
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/**
|
||||
* Shipping line companies — carriers with their own portal login.
|
||||
*
|
||||
* Registration is staff-only: there is no self-signup. Staff never set a
|
||||
* password; the system emails (and texts, when the number is domestic) a
|
||||
* single-use activation link that the carrier uses to choose their own.
|
||||
*/
|
||||
export default function ShippingLineCompaniesPage() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
|
||||
const canCreate = hasPermission(user, FREIGHT_PERMS.shippingLines.create);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.shippingLineCompanies.list.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
limit: pagination.pageSize,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const [values, setValues] = useState<FormValues>(EMPTY_FORM);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const setField = (field: keyof FormValues) => (value: string) =>
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
// Mirrors the API's own validation, so the obvious mistakes are caught before
|
||||
// a round trip. The server still enforces all of it.
|
||||
const errors = {
|
||||
name: values.name.trim() ? null : "Company name is required",
|
||||
// Required, unlike a customer's: the activation link is sent here, so an
|
||||
// account without one could never be signed in to.
|
||||
email: /^\S+@\S+\.\S+$/.test(values.email.trim())
|
||||
? null
|
||||
: "A valid email is required",
|
||||
scacCode:
|
||||
!values.scacCode.trim() || SCAC_PATTERN.test(values.scacCode.trim())
|
||||
? null
|
||||
: "SCAC must be 2-4 letters",
|
||||
};
|
||||
const isValid = !errors.name && !errors.email && !errors.scacCode;
|
||||
|
||||
const closeRegister = () => {
|
||||
setRegisterOpen(false);
|
||||
setValues(EMPTY_FORM);
|
||||
setTouched(false);
|
||||
};
|
||||
|
||||
const { mutate: register, isPending: isRegistering } = useMutation(
|
||||
api.shippingLineCompanies.register.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
closeRegister();
|
||||
toast({
|
||||
title: "Shipping line registered",
|
||||
description: result.activationSentTo
|
||||
? `An activation link was sent to ${result.activationSentTo}. It expires in 24 hours.`
|
||||
: // The account exists and is valid — only delivery failed, and the
|
||||
// link can be resent, so this is a warning rather than an error.
|
||||
"The account was created, but the activation link could not be sent. Use “Resend activation” to try again.",
|
||||
variant: result.activationSentTo ? undefined : "destructive",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Could not register shipping line",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ShippingLineCompany>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Shipping line",
|
||||
cell: ({ row }) => {
|
||||
const sl = row.original;
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-edr-green-1)",
|
||||
color: "var(--mantine-color-edr-green-7)",
|
||||
}}
|
||||
>
|
||||
<Ship size={18} strokeWidth={1.9} />
|
||||
</Box>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={600} c="edr-text" truncate>
|
||||
{sl.name}
|
||||
</Text>
|
||||
{sl.scacCode ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
SCAC {sl.scacCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
header: "Contact",
|
||||
cell: ({ row }) => {
|
||||
const sl = row.original;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Mail size={13} className="shrink-0 text-gray-400" />
|
||||
<Text size="sm" truncate>
|
||||
{sl.email}
|
||||
</Text>
|
||||
</Group>
|
||||
{sl.phoneNumber ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Phone size={13} className="shrink-0 text-gray-400" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{sl.phoneNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "identifiers",
|
||||
header: "Identifiers",
|
||||
cell: ({ row }) => {
|
||||
const { imoNumber, bicCode } = row.original;
|
||||
if (!imoNumber && !bicCode) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{imoNumber ? <Text size="sm">IMO {imoNumber}</Text> : null}
|
||||
{bicCode ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
BIC {bicCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={row.original.status === "active" ? "green" : "red"}
|
||||
>
|
||||
{row.original.status === "active" ? "Active" : "Suspended"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: "Registered",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ResendActivationAction shippingLine={row.original} />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipping Lines"
|
||||
subtitle="Carriers with their own portal access. Registered by staff — there is no self-signup."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
>
|
||||
Register shipping line
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No shipping lines registered yet."
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: error?.message ?? "Failed to load shipping lines.",
|
||||
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}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={registerOpen}
|
||||
onClose={closeRegister}
|
||||
title="Register shipping line"
|
||||
centered
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setTouched(true);
|
||||
if (!isValid) return;
|
||||
register({
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
phoneNumber: values.phoneNumber.trim() || undefined,
|
||||
scacCode: values.scacCode.trim() || undefined,
|
||||
imoNumber: values.imoNumber.trim() || undefined,
|
||||
bicCode: values.bicCode.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<Info size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
p="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
No password is set here. The shipping line receives a single-use
|
||||
activation link and chooses their own.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label="Company name"
|
||||
placeholder="Ethiopian Shipping Lines"
|
||||
withAsterisk
|
||||
value={values.name}
|
||||
onChange={(e) => setField("name")(e.currentTarget.value)}
|
||||
error={touched ? errors.name : null}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="ops@example.com"
|
||||
description="The activation link is sent here."
|
||||
withAsterisk
|
||||
value={values.email}
|
||||
onChange={(e) => setField("email")(e.currentTarget.value)}
|
||||
error={touched ? errors.email : null}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
placeholder="+251911223344"
|
||||
description="Ethiopian numbers also receive the link by SMS."
|
||||
value={values.phoneNumber}
|
||||
onChange={(e) => setField("phoneNumber")(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="SCAC"
|
||||
placeholder="ESLK"
|
||||
value={values.scacCode}
|
||||
onChange={(e) => setField("scacCode")(e.currentTarget.value)}
|
||||
error={touched ? errors.scacCode : null}
|
||||
/>
|
||||
<TextInput
|
||||
label="IMO number"
|
||||
placeholder="IMO9074729"
|
||||
value={values.imoNumber}
|
||||
onChange={(e) => setField("imoNumber")(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="BIC code"
|
||||
placeholder="ESLU"
|
||||
value={values.bicCode}
|
||||
onChange={(e) => setField("bicCode")(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={closeRegister}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isRegistering}>
|
||||
Register & send link
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,12 @@ import type {
|
||||
PaginatedOfflineUsdInvoices,
|
||||
} from "@/types/invoice";
|
||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||
import type {
|
||||
CreateShippingLineCompanyDto,
|
||||
PaginatedShippingLineCompanies,
|
||||
RegisterShippingLineCompanyResult,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
import {
|
||||
RuleEngineListResult,
|
||||
RuleEngineRecord,
|
||||
@@ -150,6 +156,7 @@ import {
|
||||
import { containerTypesService } from "./container-types.service";
|
||||
import { containerService, type Container } from "./containerService";
|
||||
import { customersService } from "./customers.service";
|
||||
import { shippingLineCompaniesService } from "./shippingLineCompanies.service";
|
||||
import { eimsService } from "./eims.service";
|
||||
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
|
||||
import { invoicesService } from "./invoices.service";
|
||||
@@ -2763,6 +2770,43 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
shippingLineCompanies: {
|
||||
list: endpoint<{ page: number; limit: number }, PaginatedShippingLineCompanies>(
|
||||
"shippingLineCompanies",
|
||||
"list",
|
||||
({ page, limit }) => shippingLineCompaniesService.list(page, limit),
|
||||
({ page, limit }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.list(page, limit),
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, ShippingLineCompany>(
|
||||
"shippingLineCompanies",
|
||||
"getById",
|
||||
({ id }) => shippingLineCompaniesService.getById(id),
|
||||
({ id }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.byId(id),
|
||||
),
|
||||
|
||||
register: endpoint<
|
||||
CreateShippingLineCompanyDto,
|
||||
RegisterShippingLineCompanyResult
|
||||
>(
|
||||
"shippingLineCompanies",
|
||||
"register",
|
||||
(dto) => shippingLineCompaniesService.register(dto),
|
||||
undefined,
|
||||
() => [QUERY_KEYS.SHIPPING_LINE_COMPANIES.ROOT],
|
||||
),
|
||||
|
||||
resendActivation: endpoint<
|
||||
{ id: string; channel: ResetChannel },
|
||||
ResetPasswordResult
|
||||
>(
|
||||
"shippingLineCompanies",
|
||||
"resendActivation",
|
||||
({ id, channel }) =>
|
||||
shippingLineCompaniesService.resendActivation(id, channel),
|
||||
),
|
||||
},
|
||||
|
||||
customers: {
|
||||
stats: endpoint<Record<string, never>, CompanyStats>(
|
||||
"customers",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
CreateShippingLineCompanyDto,
|
||||
PaginatedShippingLineCompanies,
|
||||
RegisterShippingLineCompanyResult,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
ShippingLineCompany,
|
||||
} from "@/types/shippingLineCompany";
|
||||
|
||||
export const shippingLineCompaniesService = {
|
||||
list(page = 1, limit = 20): Promise<PaginatedShippingLineCompanies> {
|
||||
return apiClient
|
||||
.get<PaginatedShippingLineCompanies>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||
{ params: { page, limit } },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
getById(id: string): Promise<ShippingLineCompany> {
|
||||
return apiClient
|
||||
.get<ShippingLineCompany>(URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BY_ID(id))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates the carrier's account and record, then sends an activation link.
|
||||
* Staff never set or see a password — the line chooses its own from the link.
|
||||
*/
|
||||
register(
|
||||
dto: CreateShippingLineCompanyDto,
|
||||
): Promise<RegisterShippingLineCompanyResult> {
|
||||
return apiClient
|
||||
.post<RegisterShippingLineCompanyResult>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE,
|
||||
dto,
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
resendActivation(
|
||||
id: string,
|
||||
channel: ResetChannel,
|
||||
): Promise<ResetPasswordResult> {
|
||||
return apiClient
|
||||
.post<ResetPasswordResult>(
|
||||
URL_CONSTANTS.SHIPPING_LINE_COMPANIES.RESEND_ACTIVATION(id),
|
||||
{ channel },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ResetChannel, ResetPasswordResult } from "./customer";
|
||||
|
||||
export type ShippingLineStatus = "active" | "suspended";
|
||||
|
||||
/**
|
||||
* A carrier with its own portal login, registered by staff.
|
||||
*
|
||||
* Not to be confused with the rule-engine's `ShippingLine` (types/rule-engine):
|
||||
* that is a pricing lookup — a code/label a booking points at — with no account
|
||||
* and no login. This one is the account.
|
||||
*/
|
||||
export interface ShippingLineCompany {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
scacCode: string | null;
|
||||
imoNumber: string | null;
|
||||
bicCode: string | null;
|
||||
status: ShippingLineStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateShippingLineCompanyDto {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
scacCode?: string;
|
||||
imoNumber?: string;
|
||||
bicCode?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface RegisterShippingLineCompanyResult {
|
||||
shippingLine: ShippingLineCompany;
|
||||
/**
|
||||
* Masked destination the activation link went to, or null when delivery
|
||||
* failed. The registration still succeeded — the link is resendable.
|
||||
*/
|
||||
activationSentTo: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedShippingLineCompanies {
|
||||
items: ShippingLineCompany[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export type { ResetChannel, ResetPasswordResult };
|
||||
@@ -59,6 +59,13 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import {
|
||||
ShippingLineBookingsPage,
|
||||
ShippingLineHelpPage,
|
||||
ShippingLineHomePage,
|
||||
ShippingLineInvoicesPage,
|
||||
ShippingLineSettingsPage,
|
||||
} from "./pages/shipping-line";
|
||||
import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
@@ -129,12 +136,20 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
* can be dismissed to use those pages. Visiting any other page bounces back to
|
||||
* home and re-opens the wizard. New users (no company yet) are treated the same
|
||||
* as users who haven't completed onboarding.
|
||||
*
|
||||
* Shipping lines are exempt: staff register them with their details already
|
||||
* captured, so there is nothing for them to onboard — they go straight to home.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted } = useAuth();
|
||||
const { company, onboardingCompleted, isShippingLine } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const needsOnboarding = !company || !onboardingCompleted;
|
||||
// Keyed off a positive shipping-line identification, never off "no company":
|
||||
// that is also true mid-fetch and on error, which would let customers slip
|
||||
// past onboarding whenever the request failed.
|
||||
const needsOnboarding = isShippingLine
|
||||
? false
|
||||
: !company || !onboardingCompleted;
|
||||
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
||||
|
||||
// Open by default while onboarding is pending (covers the login case).
|
||||
@@ -176,21 +191,69 @@ function OnboardingGate() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-only routes. A shipping line that lands on one (an old link, a
|
||||
* bookmark, a hand-typed URL) is sent to its own home rather than shown a
|
||||
* contract/company-shaped page that has no meaning for it.
|
||||
*/
|
||||
function RequireCustomer() {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
// RequireCompany already awaits this query, but guard anyway: a refetch can
|
||||
// flip `isPending` back on, and redirecting on a half-loaded account would
|
||||
// throw the user into the wrong app.
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** The mirror of RequireCustomer: shipping-line routes, closed to customers. */
|
||||
function RequireShippingLine() {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (!isShippingLine) return <Navigate to="/portal" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a signed-in account belongs. Shipping lines and customers have separate
|
||||
* apps, so every "you're already logged in" redirect has to pick between them.
|
||||
* Waits for the company query: `isShippingLine` is false while that request is
|
||||
* still in flight, which would land a shipping line on the customer home first.
|
||||
*/
|
||||
function useHomeRoute(): { ready: boolean; href: string } {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
|
||||
return {
|
||||
ready: !customerQuery.isPending,
|
||||
href: isShippingLine ? "/shipping-line" : "/portal",
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps authenticated users off the login/signup pages. */
|
||||
function RedirectIfAuthed() {
|
||||
const { isPending, isAuthenticated } = useAuth();
|
||||
const home = useHomeRoute();
|
||||
|
||||
if (isPending) return <FullScreenSpinner />;
|
||||
if (isAuthenticated) return <Navigate to="/portal" replace />;
|
||||
if (isAuthenticated) {
|
||||
if (!home.ready) return <FullScreenSpinner />;
|
||||
return <Navigate to={home.href} replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** Landing page for visitors; authenticated users go straight to the portal. */
|
||||
function LandingRoute() {
|
||||
const { isPending, isAuthenticated } = useAuth();
|
||||
const home = useHomeRoute();
|
||||
|
||||
if (isPending) return <FullScreenSpinner />;
|
||||
if (isAuthenticated) return <Navigate to="/portal" replace />;
|
||||
if (isAuthenticated) {
|
||||
if (!home.ready) return <FullScreenSpinner />;
|
||||
return <Navigate to={home.href} replace />;
|
||||
}
|
||||
return <EDRFreightLandingPage />;
|
||||
}
|
||||
|
||||
@@ -230,6 +293,37 @@ const sidebarItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar for shipping lines. Intentionally its own list rather than a filtered
|
||||
* view of `sidebarItems`: shipping lines have no contracts, and their Home /
|
||||
* Bookings / Invoices pages are different pages at different routes.
|
||||
*/
|
||||
const shippingLineSidebarItems: SidebarItem[] = [
|
||||
{ label: "Home", href: "/shipping-line", icon: <Home size={18} /> },
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/shipping-line/bookings",
|
||||
icon: <Package size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Invoices",
|
||||
href: "/shipping-line/invoices",
|
||||
icon: <Receipt size={18} />,
|
||||
},
|
||||
{
|
||||
section: "Account",
|
||||
label: "Settings",
|
||||
href: "/shipping-line/settings",
|
||||
icon: <Settings size={18} />,
|
||||
},
|
||||
{
|
||||
section: "Account",
|
||||
label: "Help & Support",
|
||||
href: "/shipping-line/help",
|
||||
icon: <LifeBuoy size={18} />,
|
||||
},
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -310,81 +404,134 @@ const App = () => {
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
{/* Shipping-line app. Its own layout and sidebar, and its own pages
|
||||
at their own routes — nothing here is shared with the customer
|
||||
branch below beyond the shell component itself. Contracts are
|
||||
absent by design: shipping lines request bookings directly. */}
|
||||
<Route element={<RequireShippingLine />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={shippingLineSidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/shipping-line"
|
||||
element={<ShippingLineHomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings"
|
||||
element={<ShippingLineBookingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/invoices"
|
||||
element={<ShippingLineInvoicesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/settings"
|
||||
element={<ShippingLineSettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/help"
|
||||
element={<ShippingLineHelpPage />}
|
||||
/>
|
||||
{/* Old shared links land on the shipping-line equivalents. */}
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Navigate to="/shipping-line/settings" replace />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Customer app — unchanged. */}
|
||||
<Route element={<RequireCustomer />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
browsable here. New-booking entry still routes via a contract. */}
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-confirm"
|
||||
element={<LastMileConfirmPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-contract"
|
||||
element={<LastMileContractPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/shipment-requests/new"
|
||||
element={<NewShipmentRequestPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/edit"
|
||||
element={<EditBookingPage />}
|
||||
/>
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-confirm"
|
||||
element={<LastMileConfirmPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/last-mile-contract"
|
||||
element={<LastMileContractPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/shipment-requests/new"
|
||||
element={<NewShipmentRequestPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
clearance — same form, submits to the complete endpoint. */}
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -107,23 +107,20 @@ function getActivePage(
|
||||
activePath: string,
|
||||
): { label: string } | null {
|
||||
const path = activePath.toLowerCase();
|
||||
for (const item of items) {
|
||||
if (
|
||||
path === item.href.toLowerCase() ||
|
||||
path.startsWith(item.href.toLowerCase() + "/")
|
||||
) {
|
||||
return { label: item.label };
|
||||
}
|
||||
if (item.children) {
|
||||
const childMatch = item.children.find(
|
||||
(c) =>
|
||||
path === c.href.toLowerCase() ||
|
||||
path.startsWith(c.href.toLowerCase() + "/"),
|
||||
);
|
||||
if (childMatch) return { label: childMatch.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
// Longest match wins, for the same reason as the sidebar's isItemActive:
|
||||
// a nested href like "/shipping-line/bookings" must beat its "/shipping-line"
|
||||
// parent, which a first-match-wins scan would report as "Home".
|
||||
const best = items
|
||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||
.filter(
|
||||
(item) =>
|
||||
path === item.href.toLowerCase() ||
|
||||
path.startsWith(item.href.toLowerCase() + "/"),
|
||||
)
|
||||
.sort((a, b) => b.href.length - a.href.length)[0];
|
||||
|
||||
return best ? { label: best.label } : null;
|
||||
}
|
||||
|
||||
const navClassNames = (active: boolean) => {
|
||||
@@ -262,9 +259,19 @@ export function AppLayout({
|
||||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||||
const isSuspendedAppeal = reapplyStatus === "suspended";
|
||||
|
||||
// Longest matching href wins. A plain prefix test would light up every
|
||||
// ancestor: with a "/shipping-line" home item alongside "/shipping-line/
|
||||
// bookings", Home would stay highlighted on every page beneath it. Exact
|
||||
// matches still win outright, so customer routes are unaffected — their
|
||||
// sidebar hrefs are siblings, never nested inside one another.
|
||||
const bestMatchHref = sidebarItems
|
||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||
.map((item) => item.href.toLowerCase())
|
||||
.filter((href) => activePath === href || activePath.startsWith(href + "/"))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
const isItemActive = (item: SidebarItem) =>
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(item.href.toLowerCase() + "/");
|
||||
bestMatchHref === item.href.toLowerCase();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -371,7 +378,9 @@ export function AppLayout({
|
||||
onClick={() =>
|
||||
openServiceModal(p.type as ServiceType, p)
|
||||
}
|
||||
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
|
||||
leftSection={
|
||||
<RefreshCw size={15} strokeWidth={1.8} />
|
||||
}
|
||||
>
|
||||
{serviceLabel(p.type as ServiceType)}
|
||||
</Menu.Item>
|
||||
@@ -893,7 +902,9 @@ export function AppLayout({
|
||||
</Alert>
|
||||
)}
|
||||
<FileInput
|
||||
label={reapplyId ? "Business license (optional)" : "Business license"}
|
||||
label={
|
||||
reapplyId ? "Business license (optional)" : "Business license"
|
||||
}
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
|
||||
@@ -162,7 +162,15 @@ export default function OnboardingResumeBanner({
|
||||
* Self-hides when there's nothing outstanding.
|
||||
*/
|
||||
export function AccountReviewBanner() {
|
||||
const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
|
||||
const { company, companyStatus, reviewStatus, reviewNote, isShippingLine } =
|
||||
useAuth();
|
||||
|
||||
// Shipping lines have no company approval, no operational profiles and no
|
||||
// profile-edit review — every branch below is about customer state they do
|
||||
// not have. Bail explicitly rather than relying on each check happening to
|
||||
// fall through.
|
||||
if (isShippingLine) return null;
|
||||
|
||||
const profiles = company?.company?.companyProfiles ?? [];
|
||||
const pending = profiles.filter((p) => p.status === "pending");
|
||||
const approved = profiles.filter((p) => p.status === "active");
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileTypeValue } from "@/services/companies.service";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import type {
|
||||
CompanyInfoResponse,
|
||||
ProfileTypeValue,
|
||||
} from "@/services/companies.service";
|
||||
import {
|
||||
companiesService,
|
||||
isShippingLineAccount,
|
||||
} from "@/services/companies.service";
|
||||
import type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
@@ -158,7 +164,21 @@ const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||
const accountInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||
|
||||
/**
|
||||
* Shipping lines share the portal with customers but have no company, no
|
||||
* external profile and no onboarding. Identified positively from the
|
||||
* backend's discriminator — never from "company is missing", which is also
|
||||
* true while the fetch is in flight or after it fails.
|
||||
*/
|
||||
const isShippingLine = isShippingLineAccount(accountInfo);
|
||||
const shippingLine = isShippingLine ? accountInfo : null;
|
||||
|
||||
// Every customer-shaped field below is null/empty for a shipping line.
|
||||
const companyInfo = isShippingLine
|
||||
? null
|
||||
: (accountInfo as CompanyInfoResponse | null);
|
||||
const companyType = companyInfo?.company?.type ?? null;
|
||||
const companyStatus = companyInfo?.company?.status ?? null;
|
||||
// A company can create bookings only once an admin has approved it (active).
|
||||
@@ -259,8 +279,11 @@ const useAuth = () => {
|
||||
isPending,
|
||||
isAuthenticated,
|
||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
// The whole getInfo payload, not the `company` field within it (historical
|
||||
// name). Null for a shipping line: consumers read `.company` / `.profile`
|
||||
// off it, and a shipping line has neither.
|
||||
company: companyInfo,
|
||||
customer: companyInfo,
|
||||
canBook,
|
||||
hasActiveProfile,
|
||||
hasPendingProfile,
|
||||
@@ -272,6 +295,8 @@ const useAuth = () => {
|
||||
isUnderReview,
|
||||
onboardingCompleted,
|
||||
onboardingStep,
|
||||
isShippingLine,
|
||||
shippingLine,
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
login,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
@@ -19,6 +19,10 @@ export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordWasReset =
|
||||
(location.state as { passwordReset?: boolean } | null)?.passwordReset ===
|
||||
true;
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -56,6 +60,21 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
{/*
|
||||
Set by the reset flows on their way here. Without it the account
|
||||
holder lands on a bare sign-in form with no sign the reset worked —
|
||||
and the link is single-use, so there is no way back to check.
|
||||
*/}
|
||||
{passwordWasReset ? (
|
||||
<Alert
|
||||
color="green"
|
||||
variant="light"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
>
|
||||
Your password has been updated. Sign in with your new password.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Alert, Button, Loader, PasswordInput, Stack } from "@mantine/core";
|
||||
import { AlertCircle, KeyRound } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, KeyRound, LogIn } from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
@@ -33,6 +33,7 @@ export default function ResetPasswordLinkPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [succeeded, setSucceeded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !token) {
|
||||
@@ -82,7 +83,12 @@ export default function ResetPasswordLinkPage() {
|
||||
newPassword: password,
|
||||
confirmPassword,
|
||||
});
|
||||
navigate("/login", { replace: true, state: { passwordReset: true } });
|
||||
// Confirm in place rather than bouncing to /login: the old redirect
|
||||
// passed `passwordReset: true` in route state that no page ever read, so
|
||||
// the account holder landed on a bare sign-in form with no sign the reset
|
||||
// had worked — and the link is single-use, so there is no way back to
|
||||
// check.
|
||||
setSucceeded(true);
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
@@ -90,6 +96,58 @@ export default function ResetPasswordLinkPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Password set — the link is now spent, so this is the last thing the account
|
||||
// holder sees. It replaces the whole form rather than sitting above it.
|
||||
if (succeeded) {
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Password updated"
|
||||
taglineBody="Your EDR Freight account is ready to use."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-green-100 text-green-600">
|
||||
<CheckCircle2 size={30} strokeWidth={2.2} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 mt-4 space-y-2 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
You're all set
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Your password has been updated
|
||||
{account?.maskedIdentifier ? ` for ${account.maskedIdentifier}` : ""}.
|
||||
Sign in with your new password to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="md"
|
||||
fullWidth
|
||||
leftSection={<LogIn size={18} />}
|
||||
onClick={() =>
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { passwordReset: true },
|
||||
})
|
||||
}
|
||||
>
|
||||
Go to sign in
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs leading-relaxed text-gray-400">
|
||||
For your security, this reset link has now been used and will not
|
||||
work again.
|
||||
</p>
|
||||
</Stack>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Set a new password"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Package } from "lucide-react";
|
||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||
|
||||
/**
|
||||
* Shipping-line bookings. Contracts do not apply to shipping lines, so a
|
||||
* booking is requested directly here rather than being created against a
|
||||
* contract the way the customer flow does it.
|
||||
*/
|
||||
export default function ShippingLineBookingsPage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Bookings"
|
||||
description="Request and track your booking requests."
|
||||
icon={<Package size={28} className="text-slate-300" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LifeBuoy } from "lucide-react";
|
||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||
|
||||
/**
|
||||
* Shipping-line help & support. Separate from the customer `/help` page, which
|
||||
* is public (the auth screens link to it) and carries its own doc chrome; this
|
||||
* one lives inside the shipping-line app layout and will hold guidance written
|
||||
* for shipping lines.
|
||||
*/
|
||||
export default function ShippingLineHelpPage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Help & Support"
|
||||
description="Guides and support for shipping lines."
|
||||
icon={<LifeBuoy size={28} className="text-slate-300" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Home } from "lucide-react";
|
||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||
|
||||
/**
|
||||
* Shipping-line home / dashboard. Deliberately separate from the customer
|
||||
* dashboard (`MyPortalPage`): shipping lines have no company, no operational
|
||||
* profiles and no contracts, so almost none of that page's data applies.
|
||||
*/
|
||||
export default function ShippingLineHomePage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Home"
|
||||
description="Overview of your shipping-line activity."
|
||||
icon={<Home size={28} className="text-slate-300" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Receipt } from "lucide-react";
|
||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||
|
||||
/** Shipping-line payments / invoices. */
|
||||
export default function ShippingLineInvoicesPage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Payments"
|
||||
description="Your invoices and payment history."
|
||||
icon={<Receipt size={28} className="text-slate-300" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Card, Stack, Text, Title } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ShippingLinePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell for the shipping-line pages while they are still being built out.
|
||||
* Each page owns its own file so it can be filled in independently; this only
|
||||
* supplies the shared empty-state chrome and is meant to be deleted from a page
|
||||
* once that page has real content.
|
||||
*/
|
||||
export default function ShippingLinePlaceholder({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: ShippingLinePlaceholderProps) {
|
||||
return (
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>{title}</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Card withBorder radius="md" py={64}>
|
||||
<Stack align="center" gap="xs">
|
||||
{icon}
|
||||
<Text c="dimmed" size="sm">
|
||||
Nothing here yet — this page is still being built.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||
|
||||
/**
|
||||
* Shipping-line settings. Separate from the customer `SettingsPage`, which is
|
||||
* built around company profiles, business licenses and contact-person review —
|
||||
* none of which a shipping line has.
|
||||
*/
|
||||
export default function ShippingLineSettingsPage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Settings"
|
||||
description="Manage your account and preferences."
|
||||
icon={<Settings size={28} className="text-slate-300" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ShippingLineHomePage } from "./ShippingLineHomePage";
|
||||
export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage";
|
||||
export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage";
|
||||
export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage";
|
||||
export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage";
|
||||
@@ -89,7 +89,18 @@ export interface CompanyProfileResponse {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which kind of account is signed in.
|
||||
*
|
||||
* Read this instead of inferring from a missing `company`: a failed or
|
||||
* in-flight company fetch also leaves `company` empty, and treating that as
|
||||
* "shipping line" would skip onboarding for customers whenever the request
|
||||
* failed. Absent (older responses) means `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
|
||||
export interface CompanyInfoResponse {
|
||||
accountKind?: AccountKind;
|
||||
profile: ExternalProfileResponse;
|
||||
company: CompanyResponse;
|
||||
/**
|
||||
@@ -104,6 +115,30 @@ export interface CompanyInfoResponse {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-in shipping line. It has no company, no external profile and no
|
||||
* onboarding — the carrier record itself is the account.
|
||||
*/
|
||||
export interface ShippingLineInfoResponse {
|
||||
accountKind: "shipping_line";
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
scacCode: string | null;
|
||||
status: string;
|
||||
company: null;
|
||||
profile: null;
|
||||
review: null;
|
||||
}
|
||||
|
||||
/** `GET /companies/getInfo` serves both portal audiences. */
|
||||
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse;
|
||||
|
||||
export const isShippingLineAccount = (
|
||||
info: AccountInfoResponse | null | undefined,
|
||||
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
|
||||
|
||||
/** A staged profile-edit review request (portal view). */
|
||||
export interface ChangeRequestResponse {
|
||||
id: string;
|
||||
@@ -249,9 +284,9 @@ export interface DashboardSummary {
|
||||
}
|
||||
|
||||
export const companiesService = {
|
||||
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
||||
getInfo: async (): Promise<AccountInfoResponse | null> => {
|
||||
try {
|
||||
const response = await client.get<ApiResponse<CompanyInfoResponse>>(
|
||||
const response = await client.get<ApiResponse<AccountInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.GET_INFO,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
|
||||
Reference in New Issue
Block a user