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

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