add profile creation flow with license upload in NewContractPage

This commit is contained in:
Marshal
2026-06-29 10:25:06 +00:00
parent 0f7cac2b68
commit 6be461bb2f
2 changed files with 131 additions and 6 deletions

View File

@@ -11,6 +11,7 @@ import {
Alert,
Box,
Button,
FileInput,
Group,
Modal,
Stack,
@@ -24,6 +25,7 @@ import {
ChevronLeft,
ChevronRight,
Send,
Upload,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
@@ -36,15 +38,17 @@ import {
contractFormSchema,
contractStepFields,
initialContractFormValues,
OPERATION_TYPES,
type ContractFormValues,
type OperationType,
} from "./new-contract-form/schema";
import {
allowedOperationsForProfiles,
getRouteDirection,
operationToProfileType,
operationToTradeDirection,
} from "./new-contract-form/helpers";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import type { ProfileTypeValue } from "@/services/companies.service";
import { StepIndicator } from "./new-contract-form/StepIndicator";
import {
clearContractDraft,
@@ -240,19 +244,92 @@ export default function NewContractPage() {
[auth.company],
);
// All operation types are always selectable. Picking one the company has no
// profile for prompts a license upload that creates the profile on the fly
// (mirrors the header "Add service" flow).
const allowedOperations = useMemo<OperationType[]>(
() => allowedOperationsForProfiles(profileTypes),
[profileTypes],
() => [...OPERATION_TYPES],
[],
);
// Create-profile modal state (license upload → createProfileAndSwitch).
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
null,
);
const [pendingOperation, setPendingOperation] =
useState<OperationType | null>(null);
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
const createProfileMutation = useMutation({
mutationFn: async ({
type,
files,
}: {
type: ProfileTypeValue;
files: File[];
}) => {
const res = await auth.createProfileAndSwitch(type, files);
if (!res.success) {
throw new Error(res.error?.message ?? "Failed to create profile");
}
},
onSuccess: () => {
// The operation was already set on the form when the modal opened.
setCreateTarget(null);
setPendingOperation(null);
setLicenseFiles([]);
setCreateError(null);
},
onError: (err) => {
setCreateError(
err instanceof Error ? err.message : "Failed to create profile",
);
},
});
const handleOperationSelect = (op: OperationType) => {
// Intercity (domestic) runs on any existing customer profile — no switch.
if (op === "intercity") return;
const target = operationToProfileType(op, profileTypes);
const target = operationToProfileType(op, profileTypes) as ProfileTypeValue;
const hasProfile = profileTypes.includes(target);
if (!hasProfile) {
// No matching profile — collect a license and create one.
setPendingOperation(op);
setCreateTarget(target);
setLicenseFiles([]);
setCreateError(null);
return;
}
if (auth.activeProfileType !== target) {
void auth.switchMode(target as never);
}
};
const handleCreateProfileConfirm = () => {
if (!createTarget) return;
if (licenseFiles.length === 0) {
setCreateError("Please upload at least one business license file.");
return;
}
createProfileMutation.mutate({ type: createTarget, files: licenseFiles });
};
const handleCreateProfileCancel = () => {
// Roll back the operation selection that triggered the modal.
if (pendingOperation) {
form.setValue("operationType", undefined as never, { shouldDirty: true });
}
setCreateTarget(null);
setPendingOperation(null);
setLicenseFiles([]);
setCreateError(null);
};
const createTargetLabel = createTarget
? (PROFILE_TYPE_LABELS[createTarget] ?? createTarget)
: "";
const onboardingDocs = useMemo(() => {
const profiles = auth.company?.company?.companyProfiles ?? [];
const active =
@@ -811,6 +888,54 @@ export default function NewContractPage() {
</Stack>
)}
</Modal>
{/* Create-profile modal — opens when the chosen operation type has no
matching company profile yet. Collects a license, creates the profile,
and switches to it (mirrors the header "Add service" flow). */}
<Modal
opened={createTarget !== null}
onClose={() => {
if (!createProfileMutation.isPending) handleCreateProfileCancel();
}}
title={`Set up your ${createTargetLabel} profile`}
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
your business license to create one and continue this contract as{" "}
{createTargetLabel.toLowerCase()}.
</Text>
<FileInput
label="Business license"
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder="Select license file(s)"
value={licenseFiles}
onChange={(files) => setLicenseFiles(files ?? [])}
error={createError ?? undefined}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={handleCreateProfileCancel}
disabled={createProfileMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
onClick={handleCreateProfileConfirm}
loading={createProfileMutation.isPending}
>
Create &amp; continue
</Button>
</Group>
</Stack>
</Modal>
</Box>
);
}

View File

@@ -239,7 +239,7 @@ export function Step3CargoScope({
label={`${size} cap (containers)`}
placeholder="0 = unlimited"
min={0}
value={field.value ?? 0}
value={Number(field.value ?? 0)}
onChange={(v) => field.onChange(Number(v) || 0)}
radius={10}
styles={fieldStyles}
@@ -258,7 +258,7 @@ export function Step3CargoScope({
label="Total cap (tons / items)"
placeholder="0 = unlimited"
min={0}
value={field.value ?? 0}
value={Number(field.value ?? 0)}
onChange={(v) => field.onChange(Number(v) || 0)}
radius={10}
styles={fieldStyles}