mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { AccountCard } from "./AccountCard";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user