mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 14:45:04 +00:00
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.
200 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|