fix: improve the setting onboarding flow

This commit is contained in:
ghost2023
2026-06-17 13:17:09 +03:00
parent e007b28285
commit 2a0f16241b
7 changed files with 373 additions and 355 deletions

View File

@@ -1,31 +1,61 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react"; import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Truck, AlertTriangle } from "lucide-react";
import { memo } from "react"; import { memo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import { cv } from "../constants"; import { cv } from "../constants";
const REQUIRED_FIELDS: (keyof ProfileResponse)[] = [
"companyEmail",
"companyPhone",
"companyAddress",
"fanNumber",
"contactPersonName",
"contactPersonPhone",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
];
function isProfileIncomplete(profile?: ProfileResponse | null): boolean {
if (!profile) return true;
return REQUIRED_FIELDS.some((field) => !profile[field]);
}
interface SetupPromptProps { interface SetupPromptProps {
show: boolean; show: boolean;
} }
export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) { export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) {
if (!show) return null; const profileQuery = useQuery(
api.companies.getProfile.queryOptions({ retry: false }),
);
const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data);
if (!show && !incomplete) return null;
return ( return (
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6"> <Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Box className="flex-1"> <Box className="flex-1">
<Text fz={15} fw={700} c="edr-text" mb={6}> <Group gap={6} align="center" mb={6}>
Setup your Company Profile {incomplete && <AlertTriangle size={16} color={cv("edr-orange")} />}
</Text> <Text fz={15} fw={700} c="edr-text">
{incomplete ? "Complete Your Profile" : "Setup your Company Profile"}
</Text>
</Group>
<Text fz={13} c="edr-muted" mb={12}> <Text fz={13} c="edr-muted" mb={12}>
Complete your company information to unlock all features and start {incomplete
booking shipments. ? "Your company profile is incomplete. Fill in the missing details to unlock all features."
: "Complete your company information to unlock all features and start booking shipments."}
</Text> </Text>
<Link to="/settings" className="no-underline"> <Link to="/settings" className="no-underline">
<Group gap={8} align="center" className="w-fit"> <Group gap={8} align="center" className="w-fit">
<Text fz={13} fw={600} c="edr-green.7"> <Text fz={13} fw={600} c="edr-green.7">
Complete Setup {incomplete ? "Complete Profile" : "Complete Setup"}
</Text> </Text>
<ArrowRight size={16} color={cv("edr-green.7")} /> <ArrowRight size={16} color={cv("edr-green.7")} />
</Group> </Group>

View File

@@ -1,46 +1,51 @@
import { useSearchParams } from "react-router-dom"; import { api } from "@/services/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ProfileResponse } from "@/types/profile";
import { import {
Alert,
Card,
Center,
Container, Container,
Group, Group,
Stack,
Title,
Text,
Tabs,
Card,
TextInput,
Button,
Badge,
Alert,
Center,
Loader, Loader,
Grid, Tabs,
Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
Building2,
Briefcase, Briefcase,
CheckCircle2, Building2,
FileCheck, FileCheck,
Save,
User, User,
UserCheck, UserCheck,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { useForm } from "react-hook-form"; import { useCallback, useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate, useSearchParams } from "react-router-dom";
import { z } from "zod";
import { api } from "@/services/api";
import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson"; import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager"; import TabGeneralManager from "./settings/TabGeneralManager";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
import TabDocuments from "./settings/TabDocuments";
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents"; type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean {
if (!profile) return false;
switch (tabId) {
case "company":
return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber;
case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone;
case "gm":
return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone;
case "poa":
return false;
case "documents":
return false;
}
}
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> }, { id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> }, { id: "contact", label: "Contact Person", icon: <User size={16} /> },
@@ -49,72 +54,68 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> }, { id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
]; ];
const TAB_ORDER: SettingsTab[] = [
"company",
"contact",
"gm",
"poa",
"documents",
];
export default function SettingsPage() { export default function SettingsPage() {
const queryClient = useQueryClient(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company"; const tab = (searchParams.get("tab") as SettingsTab) || "company";
const setTab = (t: SettingsTab) => { const setTab = useCallback(
setSearchParams( (t: SettingsTab) => {
(prev) => { setSearchParams(
const next = new URLSearchParams(prev); (prev) => {
next.set("tab", t); const next = new URLSearchParams(prev);
return next; next.set("tab", t);
}, return next;
{ replace: true }, },
); { replace: true },
}; );
},
[setSearchParams],
);
const profileQuery = useQuery(api.companies.getProfile.queryOptions()); const profileQuery = useQuery(
api.companies.getProfile.queryOptions({
retry: false,
refetchOnWindowFocus: false,
}),
);
const profile = profileQuery.data; const profile = profileQuery.data;
const createCompanyMutation = useMutation({ const [isOnboarding, setIsOnboarding] = useState<boolean | null>(null);
mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
},
});
const onboardingSchema = z.object({ useEffect(() => {
companyName: z.string().min(1, "Company name is required"), if (profileQuery.isFetched && isOnboarding === null) {
companyEmail: z.string().email("Invalid email address"), setIsOnboarding(!profileQuery.data);
companyPhone: z.string().min(1, "Company phone is required"), }
companyPhoneCountryCode: z.string().min(1, "Country code is required"), }, [profileQuery.isFetched, profileQuery.data, isOnboarding]);
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
});
type OnboardingFormData = z.infer<typeof onboardingSchema>; const handleOnboardingSuccess = useCallback(() => {
setTab("contact");
}, [setTab]);
const { const handleContactContinue = useCallback(() => {
register, setTab("gm");
handleSubmit, }, [setTab]);
formState: { errors },
} = useForm<OnboardingFormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyPhoneCountryCode: "+251",
},
});
const onSubmitOnboarding = (data: OnboardingFormData) => { const handleGMContinue = useCallback(() => {
const payload: CreateCompanyPayload = { setTab("poa");
companyType: "customer", }, [setTab]);
companyName: data.companyName,
companyEmail: data.companyEmail, const handlePOAContinue = useCallback(() => {
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, setTab("documents");
companyLocation: data.companyLocation, }, [setTab]);
companyAddress: data.companyAddress,
tin: data.tinNumber, const handleDocumentsContinue = useCallback(() => {
fanNumber: data.fanNumber, navigate("/portal");
}; }, [navigate]);
createCompanyMutation.mutate(payload);
};
if (profileQuery.isPending) { if (profileQuery.isPending) {
return ( return (
@@ -124,25 +125,54 @@ export default function SettingsPage() {
); );
} }
const onboarding = isOnboarding === true;
const renderProfileContent = (children: React.ReactNode) => {
if (onboarding && tab !== "company" && !profile) {
return (
<Center h={200}>
<Loader color="edr-green" />
</Center>
);
}
if (!profile) {
return (
<Card padding="xl">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
);
}
return children;
};
return ( return (
<Container size="xl" py="xl"> <Container size="xl" px="lg">
<Group justify="space-between" mb="xl"> <Group justify="space-between" mb="xl">
<div> <div>
<Title order={1} size="h2"> <Title order={1} size="h2">
Account Settings {onboarding ? "Complete Your Profile" : "Account Settings"}
</Title> </Title>
<Text c="edr-muted" size="sm" mt={4}> <Text c="edr-muted" size="sm" mt={4}>
Manage your company profile, personnel, and documents {onboarding
? "Set up your company profile, personnel, and documents to get started"
: "Manage your company profile, personnel, and documents"}
</Text> </Text>
</div> </div>
{profile && <Badge color="edr-green">Verified</Badge>}
</Group> </Group>
<Tabs <Tabs
value={tab} value={tab}
onChange={(value) => { onChange={(value) => {
if (!value) return; if (!value) return;
if (!profile && value !== "company") return; // if (onboarding) return;
setTab(value as SettingsTab); setTab(value as SettingsTab);
}} }}
> >
@@ -152,7 +182,12 @@ export default function SettingsPage() {
key={t.id} key={t.id}
value={t.id} value={t.id}
leftSection={t.icon} leftSection={t.icon}
disabled={!profile && t.id !== "company"} disabled={!onboarding && !profile && t.id !== "company"}
rightSection={
!onboarding && profile && tabIncomplete(t.id, profile) ? (
<AlertCircle size={14} color="red" />
) : undefined
}
> >
{t.label} {t.label}
</Tabs.Tab> </Tabs.Tab>
@@ -161,201 +196,52 @@ export default function SettingsPage() {
<Tabs.Panel value="company"> <Tabs.Panel value="company">
{!profile ? ( {!profile ? (
<Card padding="lg"> <TabCompanyProfile
<Stack gap="md"> mode="create"
<Group gap="sm"> onCreateSuccess={handleOnboardingSuccess}
<Building2 size={20} /> />
<Title order={3}>Company Profile</Title>
</Group>
<Text c="edr-muted" size="sm">
Enter your company registration details to get started
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmitOnboarding)}>
<Stack gap="md" mt="lg">
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={6}>
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</Grid.Col>
</Grid>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Group gap="xs">
{createCompanyMutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Profile created successfully
</Text>
</Group>
)}
{createCompanyMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Failed to create profile
</Text>
</Group>
)}
</Group>
<Button
type="submit"
leftSection={<Save size={16} />}
loading={createCompanyMutation.isPending}
>
Create Profile
</Button>
</Group>
</form>
</Card>
) : ( ) : (
<TabCompanyProfile profile={profile} /> <TabCompanyProfile mode="edit" profile={profile} />
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="contact"> <Tabs.Panel value="contact">
{profile ? ( {renderProfileContent(
<TabContactPerson profile={profile} /> <TabContactPerson
) : ( profile={profile!}
<Card padding="xl"> mode={onboarding ? "onboarding" : "edit"}
<Center> onContinue={handleContactContinue}
<Alert />,
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="gm"> <Tabs.Panel value="gm">
{profile ? ( {renderProfileContent(
<TabGeneralManager profile={profile} /> <TabGeneralManager
) : ( profile={profile!}
<Card padding="xl"> mode={onboarding ? "onboarding" : "edit"}
<Center> onContinue={handleGMContinue}
<Alert />,
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="poa"> <Tabs.Panel value="poa">
{profile ? ( {renderProfileContent(
<TabPowerOfAttorney profile={profile} /> <TabPowerOfAttorney
) : ( profile={profile!}
<Card padding="xl"> mode={onboarding ? "onboarding" : "edit"}
<Center> onContinue={handlePOAContinue}
<Alert />,
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="documents"> <Tabs.Panel value="documents">
{profile ? ( {renderProfileContent(
<TabDocuments profile={profile} /> <TabDocuments
) : ( profile={profile!}
<Card padding="xl"> mode={onboarding ? "onboarding" : "edit"}
<Center> onContinue={handleDocumentsContinue}
<Alert />,
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
</Center>
</Card>
)} )}
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>

View File

@@ -17,8 +17,9 @@ import {
import { api } from "@/services/api"; import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput"; import PhoneInput from "@/components/auth/PhoneInput";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import type { CreateCompanyPayload } from "@/services/companies.service";
const schema = z.object({ export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"), companyPhone: z.string().min(1, "Company phone is required"),
@@ -29,29 +30,52 @@ const schema = z.object({
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
}); });
type FormData = z.infer<typeof schema>; export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
function splitPhone(fullPhone?: string | null) { export function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" }; if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] }; if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone }; return { code: "+251", number: fullPhone };
} }
export default function TabCompanyProfile({ profile }: { profile: ProfileResponse }) { interface TabCompanyProfileProps {
const queryClient = useQueryClient(); profile?: ProfileResponse;
mode?: "edit" | "create";
onCreateSuccess?: () => void;
}
const defaultValues = useMemo((): FormData => { export default function TabCompanyProfile({
const phone = splitPhone(profile.companyPhone); profile,
mode = "edit",
onCreateSuccess,
}: TabCompanyProfileProps) {
const queryClient = useQueryClient();
const isCreate = mode === "create";
const defaultValues = useMemo((): CompanyProfileFormData => {
if (profile) {
const phone = splitPhone(profile.companyPhone);
return {
companyName: profile.companyName,
companyEmail: profile.companyEmail ?? "",
companyPhone: phone.number,
companyPhoneCountryCode: phone.code,
companyLocation: profile.companyLocation,
companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber,
fanNumber: profile.fanNumber ?? "",
};
}
return { return {
companyName: profile.companyName, companyName: "",
companyEmail: profile.companyEmail ?? "", companyEmail: "",
companyPhone: phone.number, companyPhone: "",
companyPhoneCountryCode: phone.code, companyPhoneCountryCode: "+251",
companyLocation: profile.companyLocation, companyLocation: "",
companyAddress: profile.companyAddress ?? "", companyAddress: "",
tinNumber: profile.tinNumber, tinNumber: "",
fanNumber: profile.fanNumber ?? "", fanNumber: "",
}; };
}, [profile]); }, [profile]);
@@ -60,14 +84,15 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
handleSubmit, handleSubmit,
reset, reset,
formState: { errors, isDirty }, formState: { errors, isDirty },
} = useForm<FormData>({ } = useForm<CompanyProfileFormData>({
resolver: zodResolver(schema), resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
values: defaultValues, values: defaultValues,
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: FormData) => mutationFn: async (data: CompanyProfileFormData) => {
api.companies.updateProfile.call({ const payload: CreateCompanyPayload = {
companyType: "customer",
companyName: data.companyName, companyName: data.companyName,
companyEmail: data.companyEmail, companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
@@ -75,13 +100,25 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
companyAddress: data.companyAddress, companyAddress: data.companyAddress,
tin: data.tinNumber, tin: data.tinNumber,
fanNumber: data.fanNumber, fanNumber: data.fanNumber,
}), };
if (isCreate) {
return api.companies.create.call(payload);
} else {
return api.companies.updateProfile.call(payload);
}
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
if (isCreate) {
onCreateSuccess?.();
}
}, },
}); });
const onSubmit = (data: FormData) => mutation.mutate(data); const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
return ( return (
<Card padding="lg"> <Card padding="lg">
@@ -90,7 +127,9 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
<Title order={3}>Company Profile</Title> <Title order={3}>Company Profile</Title>
</Group> </Group>
<Text c="edr-muted" size="sm" mb="lg"> <Text c="edr-muted" size="sm" mb="lg">
Edit your company registration details {isCreate
? "Enter your company registration details to get started"
: "Edit your company registration details"}
</Text> </Text>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
@@ -115,7 +154,10 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
<Grid.Col span={6}> <Grid.Col span={6}>
<PhoneInput <PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }} countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{ ...register("companyPhone"), placeholder: "912345678" }} phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode} countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone} phoneError={errors.companyPhone}
label="Company Phone" label="Company Phone"
@@ -171,34 +213,40 @@ export default function TabCompanyProfile({ profile }: { profile: ProfileRespons
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }} style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
> >
<Group gap="xs"> <Group gap="xs">
{mutation.isSuccess && ( {mutation.isSuccess && !isCreate && (
<Group gap={6} c="green"> <Group gap={6} c="green">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
<Text size="sm" fw={500}>Saved successfully</Text> <Text size="sm" fw={500}>
Saved successfully
</Text>
</Group> </Group>
)} )}
{mutation.isError && ( {mutation.isError && (
<Group gap={6} c="red"> <Group gap={6} c="red">
<XCircle size={16} /> <XCircle size={16} />
<Text size="sm" fw={500}>Save failed</Text> <Text size="sm" fw={500}>
{isCreate ? "Failed to create profile" : "Save failed"}
</Text>
</Group> </Group>
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Button {!isCreate && (
type="button" <Button
variant="outline" type="button"
disabled={mutation.isPending || !isDirty} variant="outline"
onClick={() => reset()} disabled={mutation.isPending || !isDirty}
> onClick={() => reset()}
Reset >
</Button> Reset
</Button>
)}
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
> >
Save Changes {isCreate ? "Continue" : "Save Changes"}
</Button> </Button>
</Group> </Group>
</Group> </Group>

View File

@@ -32,7 +32,13 @@ function splitPhone(fullPhone?: string | null) {
return { code: "+251", number: fullPhone }; return { code: "+251", number: fullPhone };
} }
export default function TabContactPerson({ profile }: { profile: ProfileResponse }) { interface TabContactPersonProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
export default function TabContactPerson({ profile, mode = "edit", onContinue }: TabContactPersonProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
@@ -62,6 +68,7 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
if (mode === "onboarding") onContinue?.();
}, },
}); });
@@ -116,20 +123,22 @@ export default function TabContactPerson({ profile }: { profile: ProfileResponse
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Button {mode === "edit" && (
type="button" <Button
variant="outline" type="button"
disabled={mutation.isPending || !isDirty} variant="outline"
onClick={() => reset()} disabled={mutation.isPending || !isDirty}
> onClick={() => reset()}
Reset >
</Button> Reset
</Button>
)}
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
> >
Save Changes {mode === "onboarding" ? "Continue" : "Save Changes"}
</Button> </Button>
</Group> </Group>
</Group> </Group>

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowRight,
CheckCircle2, CheckCircle2,
FileCheck, FileCheck,
Loader2, Loader2,
@@ -20,7 +21,13 @@ import { companiesService } from "@/services/companies.service";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
export default function TabDocuments({ profile }: { profile: ProfileResponse }) { interface TabDocumentsProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({}); const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
@@ -75,7 +82,9 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
{docUploadMutation.isSuccess && ( {docUploadMutation.isSuccess && (
<Group gap={6} c="green"> <Group gap={6} c="green">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
<Text size="sm" fw={500}>Documents uploaded successfully</Text> <Text size="sm" fw={500}>
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
</Text>
</Group> </Group>
)} )}
{docUploadMutation.isError && ( {docUploadMutation.isError && (
@@ -85,14 +94,37 @@ export default function TabDocuments({ profile }: { profile: ProfileResponse })
</Group> </Group>
)} )}
</Group> </Group>
<Button {mode === "onboarding" ? (
type="button" <Button
leftSection={<UploadCloud size={16} />} type="button"
loading={docUploadMutation.isPending} leftSection={<ArrowRight size={16} />}
onClick={() => docUploadMutation.mutate(documentFiles)} loading={docUploadMutation.isPending}
> onClick={() => {
Upload Documents const hasFiles = Object.values(documentFiles).some((f) => f !== null);
</Button> if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
onContinue?.();
},
});
} else {
onContinue?.();
}
}}
>
Continue
</Button>
) : (
<Button
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
onClick={() => docUploadMutation.mutate(documentFiles)}
>
Upload Documents
</Button>
)}
</Group> </Group>
)} )}
</Card> </Card>

View File

@@ -34,7 +34,13 @@ function splitPhone(fullPhone?: string | null) {
return { code: "+251", number: fullPhone }; return { code: "+251", number: fullPhone };
} }
export default function TabGeneralManager({ profile }: { profile: ProfileResponse }) { interface TabGeneralManagerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
@@ -66,6 +72,7 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
if (mode === "onboarding") onContinue?.();
}, },
}); });
@@ -133,20 +140,22 @@ export default function TabGeneralManager({ profile }: { profile: ProfileRespons
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Button {mode === "edit" && (
type="button" <Button
variant="outline" type="button"
disabled={mutation.isPending || !isDirty} variant="outline"
onClick={() => reset()} disabled={mutation.isPending || !isDirty}
> onClick={() => reset()}
Reset >
</Button> Reset
</Button>
)}
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
> >
Save Changes {mode === "onboarding" ? "Continue" : "Save Changes"}
</Button> </Button>
</Group> </Group>
</Group> </Group>

View File

@@ -36,11 +36,17 @@ function splitPhone(fullPhone?: string | null) {
return { code: "+251", number: fullPhone }; return { code: "+251", number: fullPhone };
} }
interface TabPowerOfAttorneyProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
export default function TabPowerOfAttorney({ export default function TabPowerOfAttorney({
profile, profile,
}: { mode = "edit",
profile: ProfileResponse; onContinue,
}) { }: TabPowerOfAttorneyProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => { const defaultValues = useMemo((): FormData => {
@@ -49,7 +55,7 @@ export default function TabPowerOfAttorney({
poaName: profile.poaName ?? "", poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "", poaEmail: profile.poaEmail ?? "",
poaPhone: phone.number, poaPhone: phone.number,
poaPhoneCountryCode: profile.poaPhone ? phone.code : "", poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251",
poaLocation: profile.poaLocation ?? "", poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "", poaAddress: profile.poaAddress ?? "",
}; };
@@ -81,6 +87,7 @@ export default function TabPowerOfAttorney({
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(), queryKey: api.companies.getProfile.queryKey(),
}); });
if (mode === "onboarding") onContinue?.();
}, },
}); });
@@ -99,11 +106,6 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md"> <Stack gap="md">
<Text c="edr-muted" size="sm">
Power of Attorney details are optional. Fill them in if you have an
authorized representative, or leave blank.
</Text>
<TextInput <TextInput
label="PoA Full Name" label="PoA Full Name"
placeholder="Authorized Representative Name" placeholder="Authorized Representative Name"
@@ -177,20 +179,22 @@ export default function TabPowerOfAttorney({
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
<Button {mode === "edit" && (
type="button" <Button
variant="outline" type="button"
disabled={mutation.isPending || !isDirty} variant="outline"
onClick={() => reset()} disabled={mutation.isPending || !isDirty}
> onClick={() => reset()}
Reset >
</Button> Reset
</Button>
)}
<Button <Button
type="submit" type="submit"
leftSection={<Save size={16} />} leftSection={<Save size={16} />}
loading={mutation.isPending} loading={mutation.isPending}
> >
Save Changes {mode === "onboarding" ? "Continue" : "Save Changes"}
</Button> </Button>
</Group> </Group>
</Group> </Group>