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:
marshalyordanos
2026-08-13 08:54:20 +03:00
parent e37f1e0807
commit 9aae132dd4
42 changed files with 2463 additions and 117 deletions

View File

@@ -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={

View File

@@ -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",

View File

@@ -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&apos;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&apos;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>
</>
);
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",
},

View File

@@ -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 &amp; send link
</Button>
</Group>
</Stack>
</form>
</Modal>
</PageContainer>
);
}

View File

@@ -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",

View File

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

View File

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