merge conflict

This commit is contained in:
marshal
2026-06-29 12:45:40 +03:00
202 changed files with 16895 additions and 3799 deletions

View File

@@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({
(company?.company?.nationality as CompanyNationality | null) ?? null;
// Resume position from the backend-persisted step.
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
const resumeFormStep: FormStep = FORM_STEPS.includes(
onboardingStep as FormStep,
)
? (onboardingStep as FormStep)
: "company";
@@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({
const idx = FORM_STEPS.indexOf(step as FormStep);
if (idx < 0 || idx <= furthestIdxRef.current) return;
furthestIdxRef.current = idx;
api.companies.setOnboardingStep.call({ step }).catch(() => {});
api.companies.setOnboardingStep.call({ step }).catch(() => { });
}, []);
// Mirror the form's step locally (for the header/pill) and persist it.
@@ -321,7 +323,7 @@ export default function OnboardingWizardDialog({
// Note: no "back to role selection" — once the draft is created the role(s)
// are fixed; the form's first-step Back is a no-op so progress never resets.
const handleBackToRoles = useCallback(() => {}, []);
const handleBackToRoles = useCallback(() => { }, []);
// Save the current step's fields to the draft (PATCH /profile). Returns the
// server error message on failure so the form can show it (e.g. duplicate TIN).
@@ -339,6 +341,31 @@ export default function OnboardingWizardDialog({
[],
);
// Auto-upload the documents the user just selected as they leave the documents
// step. Only the in-memory selections are sent; once uploaded they're cleared
// (so the final submit never re-uploads them) and the requirements query is
// refreshed so the "Already uploaded" badges light up. Partial uploads are
// allowed — the user may continue even with required docs still outstanding.
const handleUploadDocuments = useCallback(async (): Promise<
{ ok: true } | { ok: false; error: string }
> => {
const companyId = company?.company?.id;
const hasNew = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
);
if (!companyId || !hasNew) return { ok: true };
try {
await companiesService.uploadDocuments(companyId, documentFiles);
setDocumentFiles({});
await queryClient.invalidateQueries({
queryKey: api.companies.onboardingRequirements.queryKey(),
});
return { ok: true };
} catch (err) {
return { ok: false, error: extractApiError(err).message };
}
}, [company?.company?.id, documentFiles, queryClient]);
// Final confirm step → finalize onboarding (no company create; it already
// exists as a draft that's been filled in step-by-step).
const handleSubmit = useCallback(
@@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({
requirementsQuery.data?.documentSettingCode ??
documentSettingCode(effectiveNationality);
// Server-confirmed document state, used both to badge already-uploaded fields
// and to keep a refreshed resume from over-shooting the documents step.
const requirementDocuments = requirementsQuery.data?.documents ?? [];
const uploadedDocumentKeys = requirementDocuments
.filter((d) => d.uploaded)
.map((d) => d.fileKey);
// If any REQUIRED document is still missing, the resume must not rest past the
// documents step (don't skip to Business License) — clamp it back. This only
// changes the target once requirements load; the form follows the correction
// as long as the user hasn't navigated yet.
const requiredDocsMissing = requirementDocuments.some(
(d) => d.isRequired && !d.uploaded,
);
const effectiveResumeStep: FormStep =
requiredDocsMissing &&
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
? "documents"
: resumeFormStep;
const formProps = {
documentSettingCode: resolvedDocumentSettingCode,
documentFiles,
@@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({
isPending: finishMutation.isPending,
onBack: handleBackToRoles,
hideFirstStepBack: true,
initialStep: resumeFormStep,
initialStep: effectiveResumeStep,
resyncOpen: opened,
onStepChange: handleStepChange,
onSaveStep: saveStep,
@@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({
roleProfiles,
licenseFiles,
onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
// Surface a failed final submit (license/document upload or complete) inside
// the form — otherwise the server message (e.g. a 500) would be invisible on
// the submit step.
@@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({
withCloseButton={!completed}
closeOnClickOutside={false}
closeOnEscape={!completed}
size={720}
size={1440}
radius="lg"
padding="xl"
centered
@@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
styles={{
header: {
alignItems:"flex-start"
alignItems: "flex-start",
},
title: {
flex: 1
}
flex: 1,
},
}}
title={
completed ? null : (
@@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({
{completed ? (
<OnboardingCompletePanel onClose={handleClose} />
) : (
<Stack gap="xl">
{phase === "nationality" ? (
<Stack gap="lg">
<NationalitySelect
value={nationality}
onChange={setNationality}
embedded
/>
<Group justify="flex-end" pt="xs">
<Button
color="edr-green"
onClick={handleNationalityContinue}
disabled={!nationality}
rightSection={<ArrowRight size={16} />}
>
Continue
</Button>
</Group>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<Group justify="space-between" pt="xs">
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => setPhase("nationality")}
>
Back
</Button>
<Button
color="edr-green"
onClick={handleRolesContinue}
disabled={!rolesValid}
loading={startMutation.isPending}
rightSection={
startMutation.isPending ? undefined : <ArrowRight size={16} />
}
>
Continue
</Button>
</Group>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
</Stack>
<Stack gap="xl">
{phase === "nationality" ? (
<Stack gap="lg">
<NationalitySelect
value={nationality}
onChange={setNationality}
embedded
/>
<Group justify="flex-end" pt="xs">
<Button
color="edr-green"
onClick={handleNationalityContinue}
disabled={!nationality}
rightSection={<ArrowRight size={16} />}
>
Continue
</Button>
</Group>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect
value={roles}
onChange={setRoles}
embedded
/>
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<Group justify="space-between" pt="xs">
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => setPhase("nationality")}
>
Back
</Button>
<Button
color="edr-green"
onClick={handleRolesContinue}
disabled={!rolesValid}
loading={startMutation.isPending}
rightSection={
startMutation.isPending ? undefined : (
<ArrowRight size={16} />
)
}
>
Continue
</Button>
</Group>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
</Stack>
)}
</Modal>
);
@@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
className="flex h-16 w-16 items-center justify-center rounded-full"
style={{ background: "var(--mantine-color-edr-green-1)" }}
>
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
<PartyPopper
size={32}
className="text-[var(--mantine-color-edr-green-7)]"
/>
</Box>
<Box>
@@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
style={{ background: "var(--mantine-color-edr-green-0)" }}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
<Clock
size={18}
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left">
Each operational profile (importer, exporter, freight forwarder) is
reviewed and approved individually.
</Text>
</Group>
<Group gap="sm" wrap="nowrap" align="flex-start">
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
<ShieldCheck
size={18}
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left">
You can start creating bookings under a profile as soon as it's
approved we'll let you know the moment that happens.

View File

@@ -1,14 +1,8 @@
import {
Anchor,
Badge,
Card,
FileInput,
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { FileText, Paperclip, Upload } from "lucide-react";
import { Anchor, Group, Stack, Text } from "@mantine/core";
import { Paperclip } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import type { LicenseFile } from "@/services/companies.service";
@@ -20,6 +14,48 @@ const ROLE_LABELS: Record<string, string> = {
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: 10,
order: 1,
},
],
};
}
export interface RoleLicenseProfile {
id: string;
type: string;
@@ -38,8 +74,9 @@ interface RoleLicenseStepProps {
/**
* Final onboarding step: collect a business license (one or more files) for
* each operational role the company holds. Each role gets its own multi-file
* input; already-uploaded files are listed for context.
* 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,
@@ -60,37 +97,11 @@ export default function RoleLicenseStep({
{profiles.map((profile) => {
const label = ROLE_LABELS[profile.type] ?? profile.type;
const selected = value[profile.id] ?? [];
const hasAny = selected.length > 0 || profile.existingFiles.length > 0;
const hasExisting = profile.existingFiles.length > 0;
return (
<Card key={profile.id} padding="lg" withBorder>
<Group justify="space-between" mb="sm" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color="edr-green"
>
<FileText size={20} />
</ThemeIcon>
<div>
<Text fw={700} c="edr-text" fz={15}>
{label} Business License
</Text>
<Text size="xs" c="edr-muted" ff="monospace">
{profile.reference}
</Text>
</div>
</Group>
{hasAny && (
<Badge color="edr-green" variant="light">
Provided
</Badge>
)}
</Group>
{profile.existingFiles.length > 0 && (
<>
{hasExisting && (
<Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
@@ -108,20 +119,17 @@ export default function RoleLicenseStep({
</Stack>
)}
<FileInput
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder={
profile.existingFiles.length > 0
? "Upload more / replace files"
: "Select license file(s)"
}
value={selected}
onChange={(files) => setFiles(profile.id, files ?? [])}
<SmartFileInput
file={buildLicenseSetting(profile.id, label)}
value={{ [LICENSE_FILE_KEY]: selected }}
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
onChange={(v) => {
const next = v[LICENSE_FILE_KEY];
const files = Array.isArray(next) ? next : next ? [next] : [];
setFiles(profile.id, files);
}}
/>
</Card>
</>
);
})}
</Stack>