Merge pull request #1429 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-27 12:40:16 +03:00
committed by GitHub
48 changed files with 1663 additions and 93 deletions

View File

@@ -0,0 +1,188 @@
import {
Avatar,
Badge,
Box,
Card,
Divider,
Group,
Stack,
Text,
} from "@mantine/core";
import { AtSign, Phone, ShieldAlert, UserRound } from "lucide-react";
import type { CustomerAccount } from "@/types/customer";
import { formatDate, humanize } from "./format";
/** First letters of the person's name; falls back to the login initial. */
function initials(account: CustomerAccount): string {
const letters = [account.firstName, account.lastName]
.map((n) => n?.trim()?.[0])
.filter(Boolean)
.join("");
return (letters || account.username?.[0] || "?").toUpperCase();
}
/** A labelled value; rendered only when there is something to show. */
function Field({
icon,
label,
value,
after,
}: {
icon: React.ReactNode;
label: string;
value?: string | null;
after?: React.ReactNode;
}) {
if (!value?.trim()) return null;
return (
<Group gap={10} wrap="nowrap" align="flex-start">
<Box c="edr-muted" mt={2}>
{icon}
</Box>
<Box style={{ minWidth: 0 }}>
<Text size="xs" c="edr-muted">
{label}
</Text>
<Group gap={6} wrap="wrap">
<Text size="sm" c="edr-text" style={{ wordBreak: "break-word" }}>
{value}
</Text>
{after}
</Group>
</Box>
</Group>
);
}
/**
* One portal login belonging to a customer.
*
* Distinct from the contact details on the Overview tab: those are the business
* contact info on the company row, this is the credential someone actually
* signs in with — the two drift apart routinely, and staff answering "the
* customer can't log in" need this one.
*/
export function AccountCard({ account }: { account: CustomerAccount }) {
const name =
`${account.firstName ?? ""} ${account.lastName ?? ""}`.trim() ||
account.username ||
"Unnamed account";
// No IAM row at all — the profile points at a user that is gone. Treated as a
// fault rather than a status: nothing below it can be trusted, so the card
// says so once, loudly, instead of drawing empty credential fields.
const orphaned = account.username === null;
return (
<Card>
<Stack gap="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar radius="xl" color="edr-green" variant="light">
{initials(account)}
</Avatar>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="wrap">
<Text fw={600} c="edr-text" style={{ wordBreak: "break-word" }}>
{name}
</Text>
{account.isPrimaryContact && (
<Badge size="xs" color="edr-green" variant="light">
Primary contact
</Badge>
)}
</Group>
{account.jobTitle && (
<Text size="xs" c="edr-muted">
{account.jobTitle}
</Text>
)}
</Box>
</Group>
<Group gap={6} wrap="wrap">
{orphaned ? (
<Badge
size="xs"
color="red"
variant="light"
leftSection={<ShieldAlert size={11} />}
>
No IAM account
</Badge>
) : (
<>
<Badge
size="xs"
color={account.isActive ? "edr-green" : "orange"}
variant="light"
>
{account.isActive ? "Active" : "Inactive"}
</Badge>
{account.status && (
<Badge size="xs" color="gray" variant="light">
{humanize(account.status)}
</Badge>
)}
{/* Created but never activated by its owner — usually the actual
answer to "they say they never got in". */}
{account.hasSetPassword === false && (
<Badge size="xs" color="yellow" variant="light">
Password never set
</Badge>
)}
</>
)}
{account.onboardingCompleted ? (
<Badge size="xs" color="edr-green" variant="light">
Onboarding submitted
</Badge>
) : (
<Badge size="xs" color="yellow" variant="light">
Onboarding
{account.onboardingStep
? ` · ${humanize(account.onboardingStep)}`
: " in progress"}
</Badge>
)}
</Group>
{!orphaned && (
<>
<Divider />
<Stack gap="xs">
<Field
icon={<UserRound size={14} />}
label="Username"
value={account.username}
/>
<Field
icon={<AtSign size={14} />}
label="Email"
value={account.email}
/>
<Field
icon={<Phone size={14} />}
label="Phone"
value={account.phoneNumber}
after={
account.phoneVerified === false ? (
<Badge size="xs" color="gray" variant="light">
Unverified
</Badge>
) : undefined
}
/>
</Stack>
</>
)}
<Text size="xs" c="edr-muted">
Created {formatDate(account.createdAt)}
</Text>
</Stack>
</Card>
);
}
export default AccountCard;

View File

@@ -3,6 +3,12 @@ import type { ReactNode } from "react";
export interface TableCardProps {
children: ReactNode;
/**
* Optional heading row (title, chips, actions). Rendered in its own padded
* section above the table and OUTSIDE the scroll region — a header inside it
* would slide away from its own table on a narrow viewport.
*/
header?: ReactNode;
/**
* Minimum width (px) the table is forced to occupy. The Mantine `Table` is
* always `width: 100%`, so without a floor it can never overflow its
@@ -14,14 +20,27 @@ export interface TableCardProps {
}
/**
* Flush card shell for a `DataTable`: a borderless, padding-less card whose
* single child is a horizontally scrollable region. Pair with the table's
* `containerClassName="border-0 shadow-none bg-transparent"` so every table on
* the customer pages reads identically (same surface, same scroll behaviour).
* Flush card shell for a `DataTable`: a padding-less card whose table region
* runs edge to edge. Padding is applied per section rather than to the card, so
* the optional {@link TableCardProps.header} is inset like any other card
* content while the table's own rows and header cells reach both edges.
*
* Pair with the table's `containerClassName="border-0 shadow-none bg-transparent"`
* so every table on the customer pages reads identically (same surface, same
* scroll behaviour).
*/
export function TableCard({ children, minWidth = 860 }: TableCardProps) {
export function TableCard({
children,
minWidth = 860,
header,
}: TableCardProps) {
return (
<Card p={0}>
{header && (
<Box p="md" style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}>
{header}
</Box>
)}
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={minWidth}>{children}</Box>
</Box>

View File

@@ -15,6 +15,7 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { AccountCard } from "./AccountCard";
export { CompanyTimeline } from "./CompanyTimeline";
export {
RequestDocumentChangeModal,

View File

@@ -80,6 +80,7 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
accounts: (id: string) => ["customers", "detail", id, "accounts"] as const,
resetTarget: (id: string) =>
["customers", "detail", id, "reset-target"] as const,
changeRequests: (id: string) =>

View File

@@ -138,6 +138,8 @@ export const URL_CONSTANTS = {
`/backoffice/customers/${companyId}/reset-password`,
RESET_TARGET: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-target`,
ACCOUNTS: (companyId: string) =>
`/backoffice/customers/${companyId}/accounts`,
},
BILLING: {

View File

@@ -29,6 +29,7 @@ import {
FileText,
History,
Hourglass,
KeyRound,
IdCard,
LayoutGrid,
Package,
@@ -43,6 +44,7 @@ import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AccountCard,
BookingStatusBadge,
ChangeRequestPendingBadge,
ChangeRequestReview,
@@ -157,6 +159,12 @@ export default function CustomerDetailPage() {
enabled: Boolean(id),
}),
);
const accountsQuery = useQuery(
api.customers.accounts.queryOptions({
input: { companyId: id ?? "" },
enabled: Boolean(id),
}),
);
const documentsQuery = useQuery(
api.customers.documents.queryOptions({
input: { id: id ?? "" },
@@ -240,12 +248,61 @@ export default function CustomerDetailPage() {
<div className="space-y-2">
<ProfileTypeBadge type={row.original.type} />
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
{/* The profile reference (EX-A00001). Minted only when a reviewer
approves the role, so an unapproved one has none — say so
rather than rendering an empty line that reads as a bug. */}
{row.original.reference ? (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
) : (
<Text size="xs" c="dimmed" fs="italic">
Ref. issued on approval
</Text>
)}
</div>
),
},
{
id: "etradeBusiness",
header: "eTrade business",
cell: ({ row }) => {
const business = row.original.etradeBusiness;
// Not attached is a review finding, not a blank: the role names no
// business, so there is nothing to check the uploaded licence
// against. Companies with no eTrade record legitimately show this,
// which is why it reads as a warning rather than an error.
if (!business) {
return (
<Badge size="xs" color="yellow" variant="light">
Not attached
</Badge>
);
}
return (
<Stack gap={2} maw={230}>
<Text size="sm" fw={600} c="edr-text" lineClamp={2}>
{business.tradeName || "(no trade name on this licence)"}
</Text>
{business.activity && (
<Text size="xs" c="dimmed" lineClamp={2}>
{business.activity}
</Text>
)}
{/* The licence number is what the reviewer matches against the
uploaded document — trade names repeat across licences. */}
<Text size="xs" c="dimmed">
{business.licenceNumber}
</Text>
{business.renewedTo && (
<Text size="xs" c="dimmed">
Renewed to {business.renewedTo}
</Text>
)}
</Stack>
);
},
},
{
id: "licenseFiles",
header: "License documents",
@@ -774,6 +831,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
<Tabs.Tab value="accounts" leftSection={<KeyRound size={16} />}>
Account
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}>
History
</Tabs.Tab>
@@ -981,29 +1041,29 @@ export default function CustomerDetailPage() {
</Stack>
</Card>
<Card>
<Stack gap="md">
{/* Padding sits on the header section, not the card, so the
table runs edge to edge. minWidth carries the eTrade
business column; the region scrolls rather than squashing
the other columns. */}
<TableCard
minWidth={980}
header={
<Group justify="space-between">
<Text fw={600} c="edr-text">
Role profiles
</Text>
<ProfileChips profiles={profiles} />
</Group>
{/* Narrower than the old full-width layout — the table
shares the row with the people column now. */}
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={760}>
<DataTable
columns={profileColumns}
data={profiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
</Box>
</Stack>
</Card>
}
>
<DataTable
columns={profileColumns}
data={profiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</TableCard>
</Stack>
</Grid.Col>
@@ -1439,6 +1499,51 @@ export default function CustomerDetailPage() {
</Tabs.Panel>
{/* HISTORY */}
{/* ACCOUNT — the IAM logins behind this customer. Distinct from the
contact details on Overview: those are business contact info on the
company row, these are the credentials someone actually signs in
with, and the two drift apart routinely. Cards rather than a table:
it is a handful of rows of mostly-optional detail, which a table
renders as a field of dashes. */}
<Tabs.Panel value="accounts" pt="lg">
{accountsQuery.isLoading ? (
<Center py="xl">
<Loader size="sm" color="edr-green" />
</Center>
) : accountsQuery.isError ? (
<Alert
color="red"
icon={<AlertTriangle size={16} />}
title="Failed to load accounts"
>
<Group justify="space-between" align="center">
<Text size="sm">
We couldn't load this customer's portal logins.
</Text>
<Button
size="xs"
variant="light"
onClick={() => void accountsQuery.refetch()}
>
Retry
</Button>
</Group>
</Alert>
) : (accountsQuery.data?.length ?? 0) === 0 ? (
<Card>
<Text size="sm" c="edr-muted" ta="center" py="md">
This customer has no portal login yet.
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{accountsQuery.data?.map((account) => (
<AccountCard key={account.profileId} account={account} />
))}
</SimpleGrid>
)}
</Tabs.Panel>
<Tabs.Panel value="history" pt="lg">
<CompanyTimeline company={company} />
</Tabs.Panel>

View File

@@ -108,6 +108,24 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
// The operational role, not `type` above: one `customer` company routinely
// holds importer AND exporter, so this asks "who does X?" rather than
// "what kind of company is this?".
key: "profileType",
label: "Role",
type: "enum",
multiple: false,
options: (
[
"importer",
"exporter",
"freight_forwarder",
"dj_freight_forwarder",
"transporter",
] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
key: "kind",
label: "Sector",
@@ -348,7 +366,7 @@ export default function CustomersPage() {
<FilterBar
defs={CUSTOMER_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by company, TIN, email or profile reference…"
searchPlaceholder="Search by company, trade name, TIN, email, licence no. or profile ref…"
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
viewId="customers"
>

View File

@@ -13,6 +13,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerAccount,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
@@ -3222,6 +3223,13 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
),
accounts: endpoint<{ companyId: string }, CustomerAccount[]>(
"customers",
"accounts",
({ companyId }) => customersService.accounts(companyId),
({ companyId }) => QUERY_KEYS.CUSTOMERS.accounts(companyId),
),
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
"customers",
"resetTarget",

View File

@@ -10,6 +10,7 @@ import type {
CustomerBooking,
CustomerDocument,
CustomerPayment,
CustomerAccount,
CustomerResetTarget,
PaginatedCompanies,
ProfileStatus,
@@ -90,6 +91,18 @@ export const customersService = {
.then((r) => r.data);
},
/**
* Every portal login belonging to this customer, primary contact first.
*
* Not filtered to active accounts — a suspended or never-activated login is
* exactly what staff are checking when a customer says they cannot sign in.
*/
accounts(companyId: string): Promise<CustomerAccount[]> {
return apiClient
.get<CustomerAccount[]>(URL_CONSTANTS.COMPANIES.ACCOUNTS(companyId))
.then((r) => r.data);
},
/**
* The IAM account a reset link would go to. Read before offering the action
* so staff see the credentials the link actually reaches, not the company's

View File

@@ -8,6 +8,8 @@
* API so the data layer can be swapped to live endpoints with no UI changes.
*/
import type { ETradeBusinessOption } from "@edr/types";
/** Mirrors backend `CompanyType`. */
export type CompanyType =
| "customer"
@@ -65,6 +67,15 @@ export interface CompanyProfile {
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
attributes?: Record<string, unknown> | null;
/**
* Which of the TIN's eTrade business licences this role operates as.
*
* A TIN routinely holds a dozen licences split by activity, so "exporter" and
* "freight forwarder" are usually two different businesses under one company.
* Null when the customer has not attached one, or when the company registered
* without eTrade at all (co-operative / investment licence).
*/
etradeBusiness?: ETradeBusinessOption | null;
/** Reviewer note when the role is rejected. */
reviewNote?: string | null;
createdAt: string;
@@ -166,6 +177,34 @@ export interface ResetPasswordResult {
* Distinct from `Company.email` / `Company.phone`, which are business contact
* details and routinely differ from the credentials the customer logs in with.
*/
/**
* One portal login belonging to a customer: the company-side profile joined to
* the IAM account that actually signs in. Mirrors the API's `CustomerAccount`.
*
* The IAM fields are null when the profile points at a user row that no longer
* exists — surfaced rather than hidden, since that is itself a fault worth
* seeing.
*/
export interface CustomerAccount {
profileId: string;
userId: string;
firstName: string;
lastName: string;
jobTitle: string | null;
isPrimaryContact: boolean;
onboardingStep: string | null;
onboardingCompleted: boolean;
username: string | null;
email: string | null;
phoneNumber: string | null;
phoneVerified: boolean | null;
status: string | null;
isActive: boolean | null;
/** False means the account exists but its owner never set a password. */
hasSetPassword: boolean | null;
createdAt: string;
}
export interface CustomerResetTarget {
userId: string;
name: string;
@@ -314,6 +353,13 @@ export interface CompanyListFilter {
kind?: CompanyKind;
status?: CompanyStatus;
nationality?: CompanyNationality;
/**
* Only companies holding this operational role. Distinct from `type`, which
* is the company's own kind — a `customer` company can hold importer,
* exporter and forwarder roles at once, and its other roles still come back
* on the row.
*/
profileType?: ProfileType;
/** ISO instants — inclusive bounds on the registration date. */
createdFrom?: string;
createdTo?: string;