feat: centralized the user onboaridn requriements

This commit is contained in:
Nathnael
2026-06-24 11:50:28 +00:00
parent 43be6892aa
commit 9b48955eaf
17 changed files with 670 additions and 150 deletions

View File

@@ -1,11 +1,14 @@
import {
ActionIcon,
Badge,
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
@@ -32,7 +35,7 @@ import {
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company } from "@/types/customer";
import type { Company, CompanyStatus } from "@/types/customer";
import {
DataTable,
DataTableFooter,
@@ -45,14 +48,17 @@ export default function CustomersPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
@@ -107,7 +113,25 @@ export default function CustomersPage() {
{
id: "status",
header: "Status",
cell: ({ row }) => <CompanyStatusBadge status={row.original.status} />,
cell: ({ row }) => {
const pending = (row.original.companyProfiles ?? []).filter(
(p) => p.status === "pending",
).length;
return (
<Group gap={6} wrap="nowrap">
<CompanyStatusBadge status={row.original.status} />
{pending > 0 ? (
<Tooltip
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
>
<Badge color="yellow" variant="light" size="sm" radius="sm">
{pending} pending
</Badge>
</Tooltip>
) : null}
</Group>
);
},
},
{
id: "contact",
@@ -216,6 +240,20 @@ export default function CustomersPage() {
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Active", value: "active" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>

View File

@@ -1,4 +1,5 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { useDisclosure } from "@mantine/hooks";
import {
CalendarCheck,
Home,
@@ -8,7 +9,6 @@ import {
Receipt,
Settings,
} from "lucide-react";
import { useDisclosure } from "@mantine/hooks";
import { useEffect, useRef } from "react";
import {
Navigate,
@@ -19,9 +19,11 @@ import {
useNavigate,
} from "react-router-dom";
import useAuth from "./hooks/useAuth";
import OnboardingResumeBanner from "./components/onboarding/OnboardingResumeBanner";
import OnboardingResumeBanner, {
AccountReviewBanner,
} from "./components/onboarding/OnboardingResumeBanner";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import useAuth from "./hooks/useAuth";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
@@ -36,11 +38,11 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ContractsList from "./pages/contracts/ContractsList";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractsList from "./pages/contracts/ContractsList";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() {
@@ -143,9 +145,10 @@ function OnboardingGate() {
return (
<>
{needsOnboarding && !wizardOpen && (
{needsOnboarding && (
<OnboardingResumeBanner onResume={openWizard} />
)}
{!needsOnboarding && <AccountReviewBanner />}
<Outlet />
<OnboardingWizardDialog
opened={needsOnboarding && wizardOpen}

View File

@@ -0,0 +1,60 @@
import { Box, Button, Tooltip } from "@mantine/core";
import { Link } from "react-router-dom";
import { Lock, Plus } from "lucide-react";
import useAuth from "@/hooks/useAuth";
interface NewBookingButtonProps {
label?: string;
size?: string;
mt?: string;
}
/**
* New-booking entry point that respects approval status: a customer can only
* create bookings under a profile once the backoffice has approved it. While the
* active profile is pending the button is disabled with an explanation, so the
* gate is communicated rather than silently failing at submit time.
*/
export function NewBookingButton({
label = "New booking",
size,
mt,
}: NewBookingButtonProps) {
const { canBook, activeProfileStatus } = useAuth();
if (!canBook) {
const message =
activeProfileStatus === "pending"
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
: "Bookings aren't available for this profile yet.";
return (
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
<Box mt={mt}>
<Button
color="edr-green"
radius="md"
size={size}
disabled
leftSection={<Lock size={16} />}
>
{label}
</Button>
</Box>
</Tooltip>
);
}
return (
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
size={size}
mt={mt}
leftSection={<Plus size={16} />}
>
{label}
</Button>
);
}

View File

@@ -1,10 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Clock } from "lucide-react";
import { api } from "@/services/api";
import {
getProfileCompletion,
type ProfileCompletion,
} from "@/utils/profileCompletion";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
interface OnboardingResumeBannerProps {
/** Re-opens the onboarding wizard. */
@@ -17,15 +15,16 @@ interface BannerCopy {
cta: string;
}
/** Picks wording based on how far through setup the user actually is. */
/**
* Wording is driven entirely by the backend's outstanding-items list — the
* client never decides what's required, it just narrates what's left.
*/
function getCopy(
completion: ProfileCompletion,
requirements: OnboardingRequirements | undefined,
pct: number,
isPending: boolean,
): BannerCopy {
// Until the profile loads, or before anything is filled in, treat it as a
// fresh start rather than guessing progress.
if (isPending || completion.completed === 0) {
// No data yet (or nothing started) — treat it as a fresh start.
if (!requirements || requirements.progress.completed === 0) {
return {
title: "Set up your company profile",
subtitle: "Unlock bookings, tracking and billing — it only takes a minute.",
@@ -33,20 +32,29 @@ function getCopy(
};
}
const remaining = completion.total - completion.completed;
// Everything's filled in but not yet submitted for review.
if (requirements.isComplete) {
return {
title: "Everything's ready to go",
subtitle: "Submit your profile to send it for approval.",
cta: "Submit for review",
};
}
const remaining = requirements.outstanding.length;
if (remaining <= 2) {
return {
title: `Almost done — you're ${pct}% set up`,
subtitle: `Just ${remaining} more ${
remaining === 1 ? "detail" : "details"
} to unlock bookings, tracking and billing.`,
remaining === 1 ? "item" : "items"
} to finish: ${requirements.outstanding.join(", ")}.`,
cta: "Finish onboarding",
};
}
return {
title: `You're ${pct}% set up`,
subtitle: `${completion.completed} of ${completion.total} details added — finish to unlock bookings, tracking and billing.`,
subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`,
cta: "Continue onboarding",
};
}
@@ -90,28 +98,23 @@ function ProgressRing({ pct }: { pct: number }) {
/**
* Prominent banner shown on onboarding-allowed pages after the wizard is
* dismissed. It reads the company profile directly so it stays aware of real
* progress: a percentage ring and the copy adapt as fields get filled, and the
* whole banner disappears once every required detail is complete.
* dismissed. Progress and copy are read straight from the backend's onboarding
* requirements, so the banner always agrees with the wizard about what's left.
*/
export default function OnboardingResumeBanner({
onResume,
}: OnboardingResumeBannerProps) {
const profileQuery = useQuery(
api.companies.getProfile.queryOptions({ retry: false }),
const requirementsQuery = useQuery(
api.companies.onboardingRequirements.queryOptions({ retry: false }),
);
const completion = getProfileCompletion(profileQuery.data);
// Reliably step aside once the user has genuinely finished onboarding.
if (!profileQuery.isPending && completion.isComplete) return null;
const pct = Math.round((completion.completed / completion.total) * 100);
const { title, subtitle, cta } = getCopy(
completion,
pct,
profileQuery.isPending,
);
const requirements = requirementsQuery.data;
const { completed, total } = requirements?.progress ?? {
completed: 0,
total: 0,
};
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
const { title, subtitle, cta } = getCopy(requirements, pct);
return (
<div className="bg-gradient-to-r from-[#065f46] via-[#0A6F4D] to-[#0A8A5F] px-6 py-4 shadow-md">
@@ -143,3 +146,46 @@ export default function OnboardingResumeBanner({
</div>
);
}
/**
* Shown once onboarding is submitted but the company's operational profiles are
* still being reviewed. Communicates that approval is per-profile and that
* bookings unlock as each profile is cleared. Self-hides when nothing is pending.
*/
export function AccountReviewBanner() {
const { company } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
if (profiles.length === 0 || pending.length === 0) return null;
const pendingLabel = pending
.map((p) => p.type.replace(/_/g, " "))
.join(", ");
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Clock size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-amber-900">
Your account is under review
</span>
<span className="text-xs text-amber-800">
We're reviewing your {pendingLabel}{" "}
{pending.length === 1 ? "profile" : "profiles"}. You can create
bookings under a profile as soon as it's approved.
</span>
</span>
</div>
<span className="text-xs font-medium text-amber-800">
{approved.length} of {profiles.length} approved
</span>
</div>
</div>
);
}

View File

@@ -14,8 +14,11 @@ import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
FileText,
Globe2,
PartyPopper,
ShieldCheck,
UploadCloud,
User,
UserCheck,
@@ -142,7 +145,7 @@ export default function OnboardingWizardDialog({
onClose,
}: OnboardingWizardDialogProps) {
const queryClient = useQueryClient();
const { user, company, onboardingStep } = useAuth();
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
const existingProfiles = company?.company?.companyProfiles ?? [];
const companyAlreadyStarted = Boolean(company?.company?.id);
@@ -174,6 +177,10 @@ export default function OnboardingWizardDialog({
// Mirror of CompanyProfileForm's active step so the global header + progress
// pill can reflect it (the form no longer renders its own stepper).
const [formStep, setFormStep] = useState<FormStep>(resumeFormStep);
// Once submission succeeds we swap the whole wizard body for a congratulations
// panel, and keep the modal open (the gate would otherwise tear it down the
// moment onboardingCompleted flips true).
const [completed, setCompleted] = useState(false);
// Saved profile data, for rehydrating the form fields after a refresh.
const profileQuery = useQuery(
@@ -184,6 +191,19 @@ export default function OnboardingWizardDialog({
}),
);
// Server-driven onboarding requirements: the backend decides which document
// set applies (by nationality) and what's still outstanding, so the client
// never makes that choice itself. This is the heavier "second request" — it's
// only issued while onboarding is still incomplete; once the getInfo flag says
// we're done, it never fires.
const requirementsQuery = useQuery(
api.companies.onboardingRequirements.queryOptions({
enabled: companyAlreadyStarted && !onboardingCompleted,
retry: false,
refetchOnWindowFocus: false,
}),
);
const refreshInfo = useCallback(
() =>
queryClient.invalidateQueries({
@@ -225,7 +245,10 @@ export default function OnboardingWizardDialog({
}
return api.companies.completeOnboarding.call();
},
onSuccess: refreshInfo,
onSuccess: async () => {
await refreshInfo();
setCompleted(true);
},
onError: (err) => setStartError(extractApiError(err).message),
});
@@ -329,8 +352,22 @@ export default function OnboardingWizardDialog({
const stepMeta = STEP_META[activeStep];
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a
// future reopen (shouldn't happen once onboarded) starts clean.
const handleClose = useCallback(() => {
if (completed) setCompleted(false);
onClose();
}, [completed, onClose]);
// Prefer the backend-resolved document code; fall back to the local mapping
// only until the requirements query lands (the documents step is reached well
// after the draft — and thus the requirements — exist).
const resolvedDocumentSettingCode =
requirementsQuery.data?.documentSettingCode ??
documentSettingCode(effectiveNationality);
const formProps = {
documentSettingCode: documentSettingCode(effectiveNationality),
documentSettingCode: resolvedDocumentSettingCode,
documentFiles,
onDocumentFilesChange: setDocumentFiles,
user,
@@ -350,11 +387,11 @@ export default function OnboardingWizardDialog({
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
opened={opened || completed}
onClose={handleClose}
withCloseButton={!completed}
closeOnClickOutside={false}
closeOnEscape
closeOnEscape={!completed}
size={720}
radius="lg"
padding="xl"
@@ -371,20 +408,25 @@ export default function OnboardingWizardDialog({
}
}}
title={
<Stack gap="md">
<Box>
<Group gap="sm" mb={4}>
{stepMeta.icon}
<Title order={3}>{stepMeta.title}</Title>
</Group>
<Text c="edr-muted" size="sm">
{stepMeta.description}
</Text>
</Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
</Stack>
completed ? null : (
<Stack gap="md">
<Box>
<Group gap="sm" mb={4}>
{stepMeta.icon}
<Title order={3}>{stepMeta.title}</Title>
</Group>
<Text c="edr-muted" size="sm">
{stepMeta.description}
</Text>
</Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
</Stack>
)
}
>
{completed ? (
<OnboardingCompletePanel onClose={handleClose} />
) : (
<Stack gap="xl">
{phase === "nationality" ? (
@@ -438,10 +480,65 @@ export default function OnboardingWizardDialog({
<CompanyProfileForm {...formProps} />
)}
</Stack>
)}
</Modal>
);
}
/**
* Replaces the wizard body once onboarding is submitted: congratulates the user
* and sets the expectation that their company is now under review, and that
* bookings unlock per profile as the team approves each one.
*/
function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
return (
<Stack gap="lg" align="center" py="md" ta="center">
<Box
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)]" />
</Box>
<Box>
<Title order={3}>You're all set!</Title>
<Text c="edr-muted" size="sm" mt={4} maw={460}>
Thanks for completing your company profile. Your application has been
submitted and is now with our team for review.
</Text>
</Box>
<Stack
gap="sm"
w="100%"
maw={460}
p="md"
className="rounded-lg"
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)]" />
<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)]" />
<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.
</Text>
</Group>
</Stack>
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
Go to my dashboard
</Button>
</Stack>
);
}
/**
* Continuous progress pill: a single rounded track that fills left-to-right as
* the user advances, with faint ticks marking each step boundary.

View File

@@ -89,6 +89,7 @@ export const URL_CONSTANTS = {
ONBOARDING_START: "/api/companies/onboarding/start",
ONBOARDING_STEP: "/api/companies/onboarding-step",
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements",
DASHBOARD: "/api/companies/dashboard",
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,

View File

@@ -161,6 +161,15 @@ const useAuth = () => {
companyInfo?.profile?.onboardingCompleted ?? false;
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
// Booking is gated on backoffice approval of the active operational profile:
// a customer can only book under a profile once its status is "active".
const activeProfile =
companyInfo?.company?.companyProfiles?.find(
(p) => p.id === activeCompanyProfileId,
) ?? null;
const activeProfileStatus = activeProfile?.status ?? null;
const canBook = activeProfileStatus === "active";
/** Refetch everything scoped to the active operational profile. */
const invalidateScopedData = async () => {
await Promise.all([
@@ -229,6 +238,8 @@ const useAuth = () => {
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
activeProfileType,
activeCompanyProfileId,
activeProfileStatus,
canBook,
companyType,
onboardingCompleted,
onboardingStep,

View File

@@ -912,7 +912,7 @@ export default function CompanyProfileForm({
{step === "documents"
? "Continue"
: step === "additional"
? "Finish onboarding"
? "Submit for review"
: "Save & Continue"}
</Button>
</Group>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
@@ -25,7 +25,6 @@ import {
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Train,
Wallet,
@@ -35,6 +34,7 @@ import {
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { ModeIndicator } from "@/components/ModeIndicator";
import { NewBookingButton } from "@/components/NewBookingButton";
import {
BookingTypeBadge,
CargoModeCell,
@@ -623,15 +623,7 @@ export default function MyBookings() {
Track every cargo booking from draft to delivery.
</Text>
</Box>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
<NewBookingButton label="New booking" />
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
@@ -779,17 +771,7 @@ export default function MyBookings() {
: "Create your first booking to get started."}
</Text>
{!query && (
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
<NewBookingButton label="Create first booking" size="sm" mt="md" />
)}
</Stack>
) : (

View File

@@ -29,7 +29,7 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { Navigate, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import {
BookingFormInputValues,
@@ -63,6 +63,12 @@ export default function NewBookingPage() {
api.bookings.referenceData.queryOptions(),
);
// Booking is gated on profile approval: a customer whose active profile isn't
// approved yet is bounced back to the list, where the gate is explained.
if (!auth.isPending && auth.company && !auth.canBook) {
return <Navigate to="/bookings" replace />;
}
if (!auth.isPending && !auth.company) {
return (
<Box

View File

@@ -44,6 +44,7 @@ import type {
CompanyProfileResponse,
CreateCompanyPayload,
DashboardSummary,
OnboardingRequirements,
ProfileTypeValue,
} from "./companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
@@ -171,6 +172,12 @@ export const api = {
"completeOnboarding",
companiesService.completeOnboarding,
),
onboardingRequirements: endpoint<void, OnboardingRequirements>(
"companies",
"onboardingRequirements",
companiesService.getOnboardingRequirements,
),
},
bookings: {

View File

@@ -82,6 +82,47 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
}
/** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField {
fileKey: string;
fileLabel: string;
helpText: string | null;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
uploaded: boolean;
}
export interface OnboardingLicenseProfile {
profileId: string;
type: string;
reference: string;
uploaded: boolean;
}
/**
* Server-driven onboarding requirements. The portal renders this verbatim: the
* backend decides which documents apply (by nationality) and what is still
* outstanding, so the client never hardcodes required fields or document sets.
*/
export interface OnboardingRequirements {
documentSettingCode: string;
nationality: string;
companyInfo: {
complete: boolean;
missingFields: { key: string; label: string }[];
};
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
progress: { completed: number; total: number };
isComplete: boolean;
onboardingCompleted: boolean;
outstanding: string[];
}
export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
businessLicense?: string;
@@ -226,6 +267,14 @@ export const companiesService = {
return unwrap(response.data);
},
/** Server-driven list of outstanding onboarding requirements + completeness. */
getOnboardingRequirements: async (): Promise<OnboardingRequirements> => {
const response = await client.get<ApiResponse<OnboardingRequirements>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,

View File

@@ -1,56 +0,0 @@
import type { ProfileResponse } from "@/types/profile";
/**
* Company-profile fields that must be filled before onboarding is considered
* finished. Shared between the portal SetupPrompt and the onboarding banner so
* both agree on what "done" means.
*/
export const REQUIRED_PROFILE_FIELDS: (keyof ProfileResponse)[] = [
"companyEmail",
"companyPhone",
"companyAddress",
"fanNumber",
"contactPersonName",
"contactPersonPhone",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
];
export interface ProfileCompletion {
/** Number of required fields that are filled in. */
completed: number;
/** Total number of required fields. */
total: number;
/** Required fields still missing a value. */
missing: (keyof ProfileResponse)[];
/** True when every required field is filled. */
isComplete: boolean;
}
/** Breaks a profile down into how much of the required setup is complete. */
export function getProfileCompletion(
profile?: ProfileResponse | null,
): ProfileCompletion {
const total = REQUIRED_PROFILE_FIELDS.length;
if (!profile) {
return {
completed: 0,
total,
missing: [...REQUIRED_PROFILE_FIELDS],
isComplete: false,
};
}
const missing = REQUIRED_PROFILE_FIELDS.filter((field) => !profile[field]);
return {
completed: total - missing.length,
total,
missing,
isComplete: missing.length === 0,
};
}
/** Convenience predicate kept for existing call sites. */
export function isProfileIncomplete(profile?: ProfileResponse | null): boolean {
return !getProfileCompletion(profile).isComplete;
}