fix: onboarding back and contract blocking

This commit is contained in:
Nathnael
2026-07-09 12:36:51 +00:00
parent 3f9dca5244
commit 80ef15b9dc
3 changed files with 245 additions and 181 deletions

View File

@@ -286,9 +286,13 @@ export default function OnboardingWizardDialog({
});
}, [roles, nationality, startMutation]);
// 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(() => { }, []);
// Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
// draft, refreshes the nationality and creates only roles that don't exist yet.
const handleBackToRoles = useCallback(() => {
setStartError(null);
setPhase("nationality-role");
}, []);
// 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).
@@ -359,7 +363,6 @@ export default function OnboardingWizardDialog({
// The active step across the whole journey, driving the header + progress pill.
const activeStep: WizardStep = phase === "form" ? formStep : phase;
const stepMeta = STEP_META[activeStep];
console.log({ stepMeta, activeStep, STEP_META });
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a
@@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({
onSubmit: handleSubmit,
isPending: finishMutation.isPending,
onBack: handleBackToRoles,
hideFirstStepBack: true,
initialStep: effectiveResumeStep,
resyncOpen: opened,
onStepChange: handleStepChange,

View File

@@ -51,7 +51,6 @@ export default function CompanyProfileForm({
onBack,
initialStep,
resyncOpen,
hideFirstStepBack,
onStepChange,
onSaveStep,
rehydrate,
@@ -73,8 +72,6 @@ export default function CompanyProfileForm({
initialStep?: CompanyStep;
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
resyncOpen?: boolean;
/** Hide the Back button on the first step (onboarding can't go back to role pick). */
hideFirstStepBack?: boolean;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: CompanyStep) => void;
/** Persist the current step's data before advancing; returns an error to show. */
@@ -514,10 +511,6 @@ export default function CompanyProfileForm({
else setStep(stepOrder[currentIdx - 1]);
};
// Back is hidden on the first step during onboarding (can't return to role
// selection); otherwise always available.
const showBack = !(hideFirstStepBack && step === "company");
return (
<>
<form onSubmit={(e) => e.preventDefault()}>
@@ -851,17 +844,13 @@ export default function CompanyProfileForm({
)}
<Group justify="space-between" pt="xs">
{showBack ? (
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
Back
</Button>
) : (
<span />
)}
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
Back
</Button>
<Button
color="edr-green"
onClick={nextStep}

View File

@@ -9,6 +9,7 @@ import {
Group,
Loader,
Paper,
Popover,
Select,
Stack,
Table,
@@ -17,6 +18,7 @@ import {
Title,
} from "@mantine/core";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
@@ -31,6 +33,8 @@ import {
X,
} from "lucide-react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
import type { ContractListFilter } from "@/services/contracts.service";
@@ -58,14 +62,26 @@ function primaryRoute(contract: Freight.IContract) {
export default function ContractsList() {
const navigate = useNavigate();
const { company } = useAuth();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [disclaimerOpen, setDisclaimerOpen] = useState(false);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
// A contract can only be created under an approved profile — NewContractPage
// blocks every operation whose profile isn't "active". With none approved the
// page is reachable but unusable, so warn before sending the user there.
const profiles = company?.company?.companyProfiles ?? [];
const noActiveProfile =
profiles.length > 0 && !profiles.some((p) => p.status === "active");
const openNewContract = () =>
navigate("/contracts/new", { state: { fresh: true } });
const toggleExpanded = (id: string) =>
setExpanded((prev) => {
const nextSet = new Set(prev);
@@ -137,9 +153,11 @@ export default function ContractsList() {
const stats = useMemo(() => {
const items = data?.items ?? [];
const active = items.filter((c) =>
["CONTRACT_ACTIVE", "FULLY_EXECUTED", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
c.status,
),
[
"CONTRACT_ACTIVE",
"FULLY_EXECUTED",
"ACTIVE_SHIPMENT_IN_PROGRESS",
].includes(c.status),
).length;
const pending = items.filter((c) =>
[
@@ -158,7 +176,7 @@ export default function ContractsList() {
return { active, pending, total };
}, [data]);
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
const total = data?.meta?.total ?? data?.items?.length ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pageIndex = pagination.pageIndex;
const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1;
@@ -175,19 +193,63 @@ export default function ContractsList() {
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Contracts
</Title>
<Button
color="edr-green"
<Popover
opened={disclaimerOpen}
onChange={setDisclaimerOpen}
position="bottom-end"
width={340}
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
shadow="md"
withArrow
trapFocus
>
New Contract
</Button>
<Popover.Target>
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() =>
noActiveProfile
? setDisclaimerOpen((o) => !o)
: openNewContract()
}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 },
}}
>
New Contract
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Group gap={8} wrap="nowrap" align="flex-start">
<AlertTriangle
size={18}
color="var(--mantine-color-edr-accent-6)"
style={{ flexShrink: 0, marginTop: 1 }}
/>
<Text fz={14} fw={700} style={{ color: INK }}>
None of your profiles are active yet
</Text>
</Group>
<Text fz={13} c="dimmed">
Contracts can only be created under a profile EDR has
approved. You can continue, but every operation stays locked
until at least one profile is approved.
</Text>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
{/* Summary strip */}
@@ -378,7 +440,11 @@ export default function ContractsList() {
<Table.Tr>
<Table.Td colSpan={11}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Inbox
size={26}
color={MUTED}
style={{ opacity: 0.5 }}
/>
<Text fz={13} c="dimmed">
No contracts yet. Create one from New Contract.
</Text>
@@ -400,154 +466,159 @@ export default function ContractsList() {
const isOpen = expanded.has(c.id);
return (
<Fragment key={c.id}>
<Table.Tr
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Box
component="button"
aria-label={isOpen ? "Hide progress" : "Show progress"}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(c.id);
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: isOpen ? GREEN : "#FFFFFF",
color: isOpen ? "#FFFFFF" : MUTED,
cursor: "pointer",
transition: "all 140ms ease",
}}
>
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 160ms ease",
<Table.Tr
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Box
component="button"
aria-label={
isOpen ? "Hide progress" : "Show progress"
}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(c.id);
}}
/>
</Box>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
<Text fz={12} c="dimmed">
{isContainer ? "Containerised" : "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={isGeneral ? "edr-green" : "gray"}
radius="sm"
>
{isGeneral ? "General" : "One-Time"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: isOpen ? GREEN : "#FFFFFF",
color: isOpen ? "#FFFFFF" : MUTED,
cursor: "pointer",
transition: "all 140ms ease",
}}
>
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 160ms ease",
}}
/>
</Box>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{origin}{" "}
<Text span c="dimmed">
</Text>{" "}
{destination}
{count > 1 && (
<Text span c="dimmed" fz={12}>
{" "}
+{count - 1}
<Text fz={12} c="dimmed">
{isContainer ? "Containerised" : "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={isGeneral ? "edr-green" : "gray"}
radius="sm"
>
{isGeneral ? "General" : "One-Time"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
</Text>
)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={dir ? undefined : "dimmed"}
style={{ color: dir ? INK : undefined }}
>
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.createdAt ? undefined : "dimmed"}
style={{ color: c.createdAt ? INK : undefined }}
>
{c.createdAt
? new Date(c.createdAt).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.contractValidUntil ? undefined : "dimmed"}
style={{
color: c.contractValidUntil ? INK : undefined,
}}
>
{c.contractValidUntil
? new Date(
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{origin}{" "}
<Text span c="dimmed">
</Text>{" "}
{destination}
{count > 1 && (
<Text span c="dimmed" fz={12}>
{" "}
+{count - 1}
</Text>
)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={dir ? undefined : "dimmed"}
style={{ color: dir ? INK : undefined }}
>
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.createdAt ? undefined : "dimmed"}
style={{ color: c.createdAt ? INK : undefined }}
>
{c.createdAt
? new Date(c.createdAt).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.contractValidUntil ? undefined : "dimmed"}
style={{
color: c.contractValidUntil ? INK : undefined,
}}
>
{c.contractValidUntil
? new Date(
c.contractValidUntil,
).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<ContractStatusBadge status={c.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<ContractDocButton
contract={c}
onClick={(e) => e.stopPropagation()}
/>
<ContractCustomerAction
contract={c}
bookings={bookings}
size="sm"
listStyle
/>
</Group>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td colSpan={11} style={{ padding: "6px 20px 18px" }}>
<ContractStepBanner contract={c} />
: "—"}
</Text>
</Table.Td>
<Table.Td>
<ContractStatusBadge status={c.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<ContractDocButton
contract={c}
onClick={(e) => e.stopPropagation()}
/>
<ContractCustomerAction
contract={c}
bookings={bookings}
size="sm"
listStyle
/>
</Group>
</Table.Td>
</Table.Tr>
)}
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td
colSpan={11}
style={{ padding: "6px 20px 18px" }}
>
<ContractStepBanner contract={c} />
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
@@ -564,7 +635,10 @@ export default function ContractsList() {
gap="md"
px={20}
py={14}
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
style={{
borderTop: `1px solid ${BORDER}`,
background: "#FCFDFE",
}}
>
<Group gap={10} align="center">
<Text fz={13} c="dimmed">
@@ -574,8 +648,7 @@ export default function ContractsList() {
data={["10", "25", "50"]}
value={String(pagination.pageSize)}
onChange={(v) =>
v &&
setPagination({ pageIndex: 0, pageSize: Number(v) })
v && setPagination({ pageIndex: 0, pageSize: Number(v) })
}
radius="md"
size="xs"