Files
edr-platform/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx
Nathnael 604710bd25 feat(companies): attach an eTrade business to each company profile
A TIN holds many business licences split by activity — export of coffee,
freight forwarding, import of vehicles — but the company picked one for
its whole record, so every operational role shared it. Each profile now
names the business it actually operates as.

Stored on `company_profiles.etrade_business` as a snapshot (licence
number, trade name, activity, renewal) rather than a bare licence number,
so the portal and backoffice can show it without an eTrade round-trip —
that API is slow, serves a broken TLS chain and is regularly down. Not
unique: one business may legitimately back several roles.

The licence number is a client input, so it is never stored as sent —
`ETradeService.findBusinessOption` looks it up under the company's own
TIN and persists eTrade's record, which makes another company's licence
simply unfindable.

Choosing one is required wherever the customer adds a role with a TIN
already on file. The onboarding wizard is the exception by necessity: it
picks roles on its first step, before a TIN exists, so there is nothing
to choose from yet. There it is enforced through
`getOnboardingRequirements` instead — an unattached role is reported
outstanding and blocks submission — and the picker sits on the documents
step beside that role's licence upload.

Lifted entirely for a co-operative or investment-licence company: eTrade
holds no record for its TIN, so the requirement would be unsatisfiable.
2026-08-27 09:29:15 +00:00

200 lines
6.8 KiB
TypeScript

import { Anchor, Group, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Fragment } from "react";
import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import type { ETradeBusinessOption, IFileUploadSetting } from "@edr/types";
import EtradeBusinessSelect from "@/components/onboarding/EtradeBusinessSelect";
import { api } from "@/services/api";
import { fetchViewableFile } from "@/services/files.service";
import type { LicenseFile } from "@/services/companies.service";
const ROLE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
/** Field key the synthesized per-profile upload setting is keyed on. */
const LICENSE_FILE_KEY = "business_license";
/**
* Build a single-field upload setting so each profile's license input can reuse
* the shared SmartFileInput (same dropzone + "uploaded" state as the documents
* step), instead of a bespoke file picker.
*/
function buildLicenseSetting(
profileId: string,
profileName: string,
): IFileUploadSetting {
return {
id: `license-setting-${profileId}`,
createdAt: "",
updatedAt: "",
deletedAt: null,
code: "business_license",
label: "Business license",
description: null,
entity: "customer",
fields: [
{
id: `${LICENSE_FILE_KEY}-${profileId}`,
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: `license-setting-${profileId}`,
fileKey: LICENSE_FILE_KEY,
fileLabel: `Upload ${profileName} Business license file(s)`,
helpText: null,
isRequired: true,
isMultiple: true,
maxFiles: 10,
allowedExtensions: ["pdf", "png", "jpg", "jpeg"],
maxSizeMb: 25,
order: 1,
},
],
};
}
export interface RoleLicenseProfile {
id: string;
type: string;
reference: string;
/** License files already uploaded for this profile (rehydration). */
existingFiles: LicenseFile[];
/** The eTrade business already attached to this profile, if any. */
etradeBusiness?: ETradeBusinessOption | null;
}
interface RoleLicenseStepProps {
/** One card per operational role/profile. */
profiles: RoleLicenseProfile[];
/** Newly-selected files per profile id (not yet uploaded). */
value: Record<string, File[]>;
onChange: (value: Record<string, File[]>) => void;
/** "Business license is required" style error, keyed by profile id. */
errors?: Record<string, string>;
/** "Choose a business" error, keyed by profile id. */
businessErrors?: Record<string, string>;
}
/**
* Final onboarding step: collect a business license (one or more files) for
* each operational role the company holds. Each role gets its own SmartFileInput
* dropzone; already-uploaded files are listed (with download links) for context
* and surface the input's "uploaded" state.
*/
export default function RoleLicenseStep({
profiles,
value,
onChange,
errors,
businessErrors,
}: RoleLicenseStepProps) {
const queryClient = useQueryClient();
const setFiles = (profileId: string, files: File[]) => {
onChange({ ...value, [profileId]: files });
};
// Attaching saves immediately rather than riding along with the step's
// submit: the roles were created on the wizard's first step, so each already
// has a row to attach to, and persisting on pick means a refresh or a resumed
// draft keeps the choice.
const attach = useMutation({
mutationFn: (vars: { profileId: string; licenceNumber: string }) =>
api.companies.attachEtradeBusiness.call(vars),
onSuccess: () => {
// getInfo FIRST: the wizard reads its role list (and each role's attached
// business) from that query, so skipping it leaves the dropdown showing
// blank right after a successful pick.
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.onboardingRequirements.queryKey(),
});
},
});
return (
<Stack gap="md">
<Text size="sm" c="edr-muted">
For each operational profile, say which of your eTrade business licences
it operates as, and upload that licence. You can attach more than one
document per profile, and the same business can back more than one role.
</Text>
{profiles.map((profile) => {
const label = ROLE_LABELS[profile.type] ?? profile.type;
const selected = value[profile.id] ?? [];
const hasExisting = profile.existingFiles.length > 0;
return (
<Fragment key={profile.id}>
<EtradeBusinessSelect
label={`Which business is your ${label} profile?`}
value={profile.etradeBusiness?.licenceNumber ?? null}
error={businessErrors?.[profile.id]}
// Only the row being saved locks; picking the importer's business
// must not freeze the exporter's dropdown next to it.
disabled={
attach.isPending && attach.variables?.profileId === profile.id
}
onChange={(licenceNumber) =>
attach.mutate({ profileId: profile.id, licenceNumber })
}
/>
{hasExisting && (
<Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => (
<Group key={f.id} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
component="button"
type="button"
onClick={() =>
void fetchViewableFile(f.id, f.name).then((v) =>
window.open(v.url, "_blank"),
)
}
size="xs"
>
{f.name}
</Anchor>
</Group>
))}
</Stack>
)}
<SmartFileInput
file={buildLicenseSetting(profile.id, label)}
value={{ [LICENSE_FILE_KEY]: selected }}
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
errors={
errors?.[profile.id]
? { [LICENSE_FILE_KEY]: errors[profile.id] }
: undefined
}
onChange={(v) => {
const next = v[LICENSE_FILE_KEY];
const files = Array.isArray(next) ? next : next ? [next] : [];
setFiles(profile.id, files);
}}
/>
</Fragment>
);
})}
</Stack>
);
}