feat: remove ProfilePage and update routing to redirect to Settings; enhance SettingsPage with ProfileHeader and improved tab handling

This commit is contained in:
Marshal
2026-06-20 10:59:18 +00:00
parent 72b6c9875d
commit cc62482d4e
5 changed files with 219 additions and 581 deletions

View File

@@ -7,7 +7,6 @@ import {
Receipt,
Settings,
Sparkles,
User,
} from "lucide-react";
import { useDisclosure } from "@mantine/hooks";
import { useEffect, useRef } from "react";
@@ -24,7 +23,6 @@ import useAuth from "./hooks/useAuth";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import ProfilePage from "./pages/ProfilePage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
import LoginPage from "./pages/accounts/LoginPage";
@@ -211,12 +209,6 @@ const sidebarItems: SidebarItem[] = [
href: "/billing",
icon: <Receipt size={18} />,
},
{
section: "Account",
label: "Profile",
href: "/profile",
icon: <User size={18} />,
},
{
section: "Account",
label: "Settings",
@@ -297,7 +289,8 @@ const App = () => {
/>
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route path="/profile" element={<Navigate to="/settings" replace />} />
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -1,410 +0,0 @@
import { api } from "@/services/api";
import {
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Grid,
Group,
Loader,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Briefcase,
Building,
Building2,
FileCheck,
Globe,
Mail,
MapPin,
Phone,
Plus,
ShieldCheck,
User,
UserCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { rolesForCompanyType } from "./settings/companyRoles";
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<Group gap="sm" align="flex-start" wrap="nowrap">
{icon && (
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
{icon}
</ThemeIcon>
)}
<Stack gap={2}>
<Text size="xs" fw={700} tt="uppercase" c="edr-muted">
{label}
</Text>
<Text size="sm" fw={600} c="edr-text">
{value || "—"}
</Text>
</Stack>
</Group>
);
}
function CardHeading({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<Stack gap={2} mb="md">
<Group gap="sm">
{icon}
<Title order={4} size="h5">
{title}
</Title>
</Group>
<Text size="sm" c="edr-muted">
{description}
</Text>
</Stack>
);
}
function PersonnelGroup({
color,
title,
children,
}: {
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Group gap="xs">
<Box w={4} h={16} bg={color} style={{ borderRadius: 2 }} />
<Text size="sm" fw={700} tt="uppercase" c="edr-text">
{title}
</Text>
</Group>
<Stack gap="sm" pl="lg">
{children}
</Stack>
</Stack>
);
}
export default function ProfilePage() {
const { data: profile, isPending } = useQuery(
api.companies.getProfile.queryOptions(),
);
if (isPending) {
return (
<Center h="100%">
<Loader color="edr-green" size="lg" />
</Center>
);
}
if (!profile) {
return (
<Center h="100%">
<Text c="edr-muted">No company profile found.</Text>
</Center>
);
}
// Registered operational profiles keyed by type, plus the roles this company
// type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard.
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
const roleOptions = rolesForCompanyType(profile.companyType);
const activeOptions = roleOptions.filter((o) => refByType.has(o.type));
return (
<Container size="xl" px="lg" py="xl">
{/* Header */}
<Group gap="lg" align="center" mb="lg">
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
<User size={44} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center">
<Title order={1} size="h2">
{profile.companyName}
</Title>
<Badge color="edr-green" variant="light">
Verified
</Badge>
</Group>
{activeOptions.length > 0 ? (
<Group gap="xs">
{activeOptions.map((opt) => (
<Badge
key={opt.type}
variant="light"
color="edr-green"
size="lg"
radius="sm"
>
{opt.label} · {refByType.get(opt.type)!.reference}
</Badge>
))}
</Group>
) : (
<Group gap={6} c="edr-muted">
<Building size={16} />
<Text c="edr-muted" fw={500}>
{profile.companyType}
</Text>
</Group>
)}
</Stack>
</Group>
<Divider mb="lg" />
<Grid gap="lg">
{/* Left Column */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
{/* Company Details */}
<Card>
<CardHeading
icon={
<Building2
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Company Details"
description="Business registration information"
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoItem
icon={<Globe size={16} />}
label="Location"
value={profile.companyLocation}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Address"
value={profile.companyAddress}
/>
<InfoItem
icon={<FileCheck size={16} />}
label="TIN Number"
value={profile.tinNumber}
/>
<InfoItem
icon={<ShieldCheck size={16} />}
label="FAN Number"
value={profile.fanNumber}
/>
<InfoItem
icon={<Mail size={16} />}
label="Email"
value={profile.companyEmail}
/>
<InfoItem
icon={<Phone size={16} />}
label="Phone"
value={profile.companyPhone}
/>
</SimpleGrid>
</Card>
{/* Key Personnel */}
<Card>
<CardHeading
icon={
<Briefcase
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Key Personnel"
description="Management and contact persons"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<PersonnelGroup color="edr-green" title="Contact Person">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</PersonnelGroup>
<PersonnelGroup color="edr-accent" title="General Manager">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</PersonnelGroup>
</SimpleGrid>
</Card>
{/* Power of Attorney */}
{profile.poaName && (
<Card style={{ borderStyle: "dashed" }}>
<CardHeading
icon={
<UserCheck
size={20}
color="var(--mantine-color-edr-accent-6)"
/>
}
title="Power of Attorney"
description="Authorized representative details"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</SimpleGrid>
</Card>
)}
</Stack>
</Grid.Col>
{/* Right Column */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{/* Operating Roles */}
<Card>
<CardHeading
icon={
<BadgeCheck
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Operating Roles"
description="Your registered freight roles and reference numbers"
/>
{roleOptions.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<Stack gap="md">
{roleOptions.map((opt) => {
const active = refByType.get(opt.type);
return (
<Group
key={opt.type}
justify="space-between"
wrap="nowrap"
align="center"
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
>
{opt.icon}
</ThemeIcon>
<Stack gap={2}>
<Text size="sm" fw={600} c="edr-text">
{opt.label}
</Text>
<Text
size="xs"
c="edr-muted"
ff={active ? "monospace" : undefined}
>
{active ? active.reference : "Not registered"}
</Text>
</Stack>
</Group>
{active ? (
<Badge
variant="light"
color={
active.status === "active"
? "edr-green"
: "edr-accent"
}
tt="capitalize"
>
{active.status}
</Badge>
) : (
<Button
component={Link}
to="/settings?tab=company"
size="xs"
variant="light"
color="edr-green"
leftSection={<Plus size={14} />}
>
Add {opt.label}
</Button>
)}
</Group>
);
})}
</Stack>
)}
</Card>
{/* Secure Account */}
<Card
padding="xl"
style={{
background: "var(--mantine-color-edr-ink-6)",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
top: 16,
right: 16,
opacity: 0.1,
}}
>
<ShieldCheck size={128} color="white" />
</Box>
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
<Title order={3} size="h4" c="white">
Secure Account
</Title>
<Text size="sm" c="gray.4">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</Text>
<Button
component={Link}
to="/settings"
variant="white"
color="dark"
mt="xs"
w="fit-content"
>
Edit Settings
</Button>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}

View File

@@ -1,27 +1,34 @@
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import {
Alert,
Badge,
Box,
Card,
Center,
Container,
Group,
Loader,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BadgeCheck,
Briefcase,
Building2,
FileCheck,
Globe,
ShieldCheck,
User,
UserCheck,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCallback, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { rolesForCompanyType } from "./settings/companyRoles";
import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
@@ -30,35 +37,139 @@ import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean {
if (!profile) return false;
/** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete(
tabId: SettingsTab,
profile: ProfileResponse,
): boolean {
switch (tabId) {
case "company":
return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber;
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;
return (
!profile.generalManagerName ||
!profile.generalManagerEmail ||
!profile.generalManagerPhone
);
case "poa":
return false;
case "documents":
return false;
}
}
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
{ id: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
];
/**
* Polished identity banner shown above the editor tabs — company name, its
* registered operating roles, location and verification status at a glance.
*/
function ProfileHeader({ profile }: { profile: ProfileResponse }) {
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
const roleOptions = rolesForCompanyType(profile.companyType);
const activeRoles = roleOptions.filter((o) => refByType.has(o.type));
return (
<Card
padding="xl"
radius="lg"
style={{
background:
"linear-gradient(135deg, var(--mantine-color-edr-ink-6) 0%, var(--mantine-color-edr-ink-8, var(--mantine-color-edr-ink-6)) 100%)",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{ position: "absolute", top: -24, right: -16, opacity: 0.08 }}
>
<Building2 size={180} color="white" />
</Box>
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
style={{ position: "relative", zIndex: 1 }}
>
<Group gap="lg" align="center" wrap="nowrap">
<ThemeIcon variant="white" color="edr-green" size={72} radius="lg">
<Building2 size={36} />
</ThemeIcon>
<Stack gap={8}>
<Group gap="sm" align="center">
<Title order={1} size="h2" c="white">
{profile.companyName}
</Title>
<Badge
color="edr-green"
variant="filled"
leftSection={<BadgeCheck size={13} />}
>
Verified
</Badge>
</Group>
{activeRoles.length > 0 ? (
<Group gap="xs">
{activeRoles.map((opt) => (
<Badge
key={opt.type}
variant="white"
color="edr-ink"
radius="sm"
size="lg"
>
{opt.label} · {refByType.get(opt.type)!.reference}
</Badge>
))}
</Group>
) : (
<Text c="gray.4" fw={500} tt="capitalize">
{profile.companyType.replace(/_/g, " ")}
</Text>
)}
<Group gap="lg" mt={4}>
{profile.companyLocation && (
<Group gap={6} c="gray.4">
<Globe size={15} />
<Text size="sm">{profile.companyLocation}</Text>
</Group>
)}
{profile.tinNumber && (
<Group gap={6} c="gray.4">
<ShieldCheck size={15} />
<Text size="sm" ff="monospace">
TIN {profile.tinNumber}
</Text>
</Group>
)}
</Group>
</Stack>
</Group>
</Group>
</Card>
);
}
export default function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company";
const setTab = useCallback(
(t: SettingsTab) => {
setSearchParams(
@@ -79,9 +190,10 @@ export default function SettingsPage() {
refetchOnWindowFocus: false,
}),
);
const profile = profileQuery.data;
// Keep the cached company info in sync whenever the profile changes, so the
// header (and the rest of the app) reflect edits immediately.
useEffect(() => {
if (profileQuery.dataUpdatedAt > 0) {
queryClient.invalidateQueries({
@@ -90,34 +202,6 @@ export default function SettingsPage() {
}
}, [profileQuery.dataUpdatedAt, queryClient]);
const [isOnboarding, setIsOnboarding] = useState<boolean | null>(null);
useEffect(() => {
if (profileQuery.isFetched && isOnboarding === null) {
setIsOnboarding(!profileQuery.data);
}
}, [profileQuery.isFetched, profileQuery.data, isOnboarding]);
const handleOnboardingSuccess = useCallback(() => {
setTab("contact");
}, [setTab]);
const handleContactContinue = useCallback(() => {
setTab("gm");
}, [setTab]);
const handleGMContinue = useCallback(() => {
setTab("poa");
}, [setTab]);
const handlePOAContinue = useCallback(() => {
setTab("documents");
}, [setTab]);
const handleDocumentsContinue = useCallback(() => {
navigate("/portal");
}, [navigate]);
if (profileQuery.isPending) {
return (
<Center h="100%">
@@ -126,126 +210,82 @@ 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">
if (!profile) {
return (
<Container size="xl" px="lg" py="xl">
<Card padding="xl" radius="lg">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
<Group gap="sm" c="edr-muted">
<AlertCircle size={20} />
<Text>No company profile found.</Text>
</Group>
</Center>
</Card>
);
}
return children;
};
</Container>
);
}
return (
<Container size="xl" px="lg">
<Group justify="space-between" mb="xl">
<Container size="xl" px="lg" py="xl">
<Stack gap="xl">
<ProfileHeader profile={profile} />
<div>
<Title order={1} size="h2">
{onboarding ? "Complete Your Profile" : "Account Settings"}
<Title order={2} size="h3">
Account Settings
</Title>
<Text c="edr-muted" size="sm" mt={4}>
{onboarding
? "Set up your company profile, personnel, and documents to get started"
: "Manage your company profile, personnel, and documents"}
Manage your company profile, personnel, and documents.
</Text>
</div>
</Group>
<Tabs
value={tab}
onChange={(value) => {
if (!value) return;
// if (onboarding) return;
setTab(value as SettingsTab);
}}
>
<Tabs.List mb="md">
{TABS.map((t) => (
<Tabs.Tab
key={t.id}
value={t.id}
leftSection={t.icon}
disabled={!onboarding && !profile && t.id !== "company"}
rightSection={
!onboarding && profile && tabIncomplete(t.id, profile) ? (
<AlertCircle size={14} color="red" />
) : undefined
}
>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
<Tabs
value={tab}
onChange={(value) => value && setTab(value as SettingsTab)}
variant="pills"
radius="md"
>
<Tabs.List mb="lg">
{TABS.map((t) => (
<Tabs.Tab
key={t.id}
value={t.id}
leftSection={t.icon}
rightSection={
tabIncomplete(t.id, profile) ? (
<Box
w={7}
h={7}
style={{
borderRadius: "50%",
background: "var(--mantine-color-red-6)",
}}
/>
) : undefined
}
>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
<Tabs.Panel value="company">
{!profile ? (
<TabCompanyProfile
mode="create"
onCreateSuccess={handleOnboardingSuccess}
/>
) : (
<Tabs.Panel value="company">
<TabCompanyProfile mode="edit" profile={profile} />
)}
</Tabs.Panel>
<Tabs.Panel value="contact">
{renderProfileContent(
<TabContactPerson
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleContactContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="gm">
{renderProfileContent(
<TabGeneralManager
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleGMContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="poa">
{renderProfileContent(
<TabPowerOfAttorney
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handlePOAContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="documents">
{renderProfileContent(
<TabDocuments
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleDocumentsContinue}
/>,
)}
</Tabs.Panel>
</Tabs>
</Tabs.Panel>
<Tabs.Panel value="contact">
<TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="gm">
<TabGeneralManager profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="poa">
<TabPowerOfAttorney profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="documents">
<TabDocuments profile={profile} mode="edit" />
</Tabs.Panel>
</Tabs>
</Stack>
</Container>
);
}

View File

@@ -57,7 +57,9 @@ const onboardingSchema = z.object({
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
vatNumber: z
.string()
@@ -429,6 +431,27 @@ export default function CompanyProfileForm({
setValue("kebele", data.kebele);
setValue("houseNo", data.houseNo);
setValue("etradePhone", data.regularPhone || data.mobilePhone);
// Compose a readable company address from the granular eTrade parts.
const addressParts = [
data.houseNo,
data.kebele,
data.woreda,
data.zone,
data.region,
].filter((part) => part && part.trim());
if (addressParts.length) {
setValue("companyAddress", addressParts.join(", "));
}
// Pre-fill the company contact phone from eTrade's mobile number.
const mobile = data.mobilePhone || data.regularPhone;
if (mobile) {
const { number, countryCode } = splitPhone(mobile);
setValue("companyPhone", number);
setValue("companyPhoneCountryCode", countryCode);
}
setEtradeOwner({
name: data.managerName,
phone: data.managerPhone || data.regularPhone || data.mobilePhone,
@@ -651,20 +674,12 @@ export default function CompanyProfileForm({
label="Company Phone"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</SimpleGrid>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="VAT Number"

View File

@@ -68,7 +68,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Business Profile</Title>
<Title order={3}>Operating Roles</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{profile.companyType === "customer"