mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user