Files
edr-platform/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx

527 lines
16 KiB
TypeScript

import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import {
Alert,
Badge,
Box,
Button,
Card,
Center,
Container,
Fieldset,
FileButton,
Group,
Loader,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
BadgeCheck,
Briefcase,
Building2,
Clock,
FileCheck,
Globe,
Layers,
RefreshCw,
ShieldCheck,
UploadCloud,
User,
UserCheck,
UserCog,
} from "lucide-react";
import { companiesService } from "@/services/companies.service";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import { rolesForCompanyType } from "./settings/companyRoles";
import TabAccount from "./settings/TabAccount";
import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab =
| "account"
| "company"
| "contact"
| "gm"
| "poa"
| "documents";
/** 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
);
case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone;
case "gm":
return (
!profile.generalManagerName ||
!profile.generalManagerEmail ||
!profile.generalManagerPhone
);
case "account":
// Account fields live on the IAM user, not the company profile, and are
// always populated (signup requires them) — nothing to nag about here.
case "poa":
case "documents":
return false;
}
}
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "account", label: "Account", icon: <UserCog 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);
// Only an approved role is a role the company actually operates as. A pending
// one carries no reference yet, and must not read as granted.
const activeRoles = roleOptions.filter(
(o) => refByType.get(o.type)?.status === "active",
);
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 queryClient = useQueryClient();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company";
const setTab = useCallback(
(t: SettingsTab) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", t);
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const profileQuery = useQuery(
api.companies.getProfile.queryOptions({
retry: false,
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({
queryKey: api.companies.getInfo.queryKey(),
});
}
}, [profileQuery.dataUpdatedAt, queryClient]);
if (profileQuery.isPending) {
return (
<Center h="100%">
<Loader color="edr-green" size="lg" />
</Center>
);
}
if (!profile) {
return (
<Container size="xl" px="lg" py="xl">
<Card padding="xl" radius="lg">
<Center>
<Group gap="sm" c="edr-muted">
<AlertCircle size={20} />
<Text>No company profile found.</Text>
</Group>
</Center>
</Card>
</Container>
);
}
const reviewStatus = profile.reviewStatus ?? null;
const locked = reviewStatus === "pending";
return (
<Container size="xl" px="lg" py="xl">
<Stack gap="xl">
<ProfileHeader profile={profile} />
<div>
<Title order={2} size="h3">
Account Settings
</Title>
<Text c="edr-muted" size="sm" mt={4}>
Manage your company profile, personnel, and documents.
</Text>
</div>
{reviewStatus === "pending" && (
<Alert
color="yellow"
variant="light"
icon={<Clock size={18} />}
title="Changes submitted for review"
>
Your recent changes are awaiting administrator approval. Editing is
disabled until the review is complete you'll be notified once it's
approved or if any changes are requested.
</Alert>
)}
{reviewStatus === "rejected" && (
<Alert
color="red"
variant="light"
icon={<AlertTriangle size={18} />}
title="Changes were not approved"
>
<Stack gap={4}>
{profile.reviewNote && (
<Text size="sm">
<strong>Reviewer note:</strong> {profile.reviewNote}
</Text>
)}
<Text size="sm">
Please update the details below and save again to resubmit for
review.
</Text>
</Stack>
</Alert>
)}
<OperationalServicesCard profile={profile} />
<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>
{/* Deliberately NOT wrapped in the `locked` fieldset below: that lock
is for company-profile edits awaiting review. Account identity is
the user's own login/notification details — they must stay editable
even mid-review, or a customer whose phone changed while pending
would be locked out of their own OTPs. */}
<Tabs.Panel value="account">
{user ? (
<TabAccount user={user} />
) : (
<Center py="xl">
<Loader color="edr-green" />
</Center>
)}
</Tabs.Panel>
{/* While a change request is pending, every panel's inputs + submit
buttons are disabled via the native fieldset; tab switching stays
enabled so the customer can still review what they submitted. */}
<Tabs.Panel value="company">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} />
</Fieldset>
</Tabs.Panel>
<Tabs.Panel value="contact">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabContactPerson profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel>
<Tabs.Panel value="gm">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabGeneralManager profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel>
<Tabs.Panel value="poa">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel>
<Tabs.Panel value="documents">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabDocuments profile={profile} mode="edit" />
</Fieldset>
</Tabs.Panel>
</Tabs>
</Stack>
</Container>
);
}
const ROLE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
const ROLE_STATUS: Record<string, { color: string; label: string }> = {
active: { color: "edr-green", label: "Approved" },
pending: { color: "yellow", label: "Awaiting approval" },
rejected: { color: "red", label: "Rejected" },
suspended: { color: "orange", label: "Suspended" },
blacklisted: { color: "red", label: "Blocked" },
};
/**
* Lists the company's operational services with approval status, and lets the
* customer resubmit a rejected one — replacing its license first if the reviewer
* flagged the document.
*/
function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
const queryClient = useQueryClient();
const roles = profile.companyProfiles;
const refresh = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
}),
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
}),
]);
// Resubmit: upload any freshly-picked license files first, then flip the role
// back to pending so it re-enters the approval queue.
const resubmit = useMutation({
mutationFn: async (args: { profileId: string; files: File[] }) => {
if (args.files.length > 0) {
await companiesService.uploadProfileLicense(args.profileId, args.files);
}
await api.companies.reapplyProfile.call({ profileId: args.profileId });
},
onSuccess: refresh,
});
if (roles.length === 0) return null;
return (
<Card padding="lg" radius="lg">
<Group gap="sm" mb="md">
<Layers size={20} />
<Title order={3}>Operational Services</Title>
</Group>
<Stack gap="sm">
{roles.map((r) => {
const status = ROLE_STATUS[r.status] ?? {
color: "gray",
label: r.status,
};
return (
<Group
key={r.id}
justify="space-between"
align="flex-start"
wrap="nowrap"
py="xs"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={4}>
<Group gap="xs">
<Text fw={600}>{ROLE_LABELS[r.type] ?? r.type}</Text>
<Badge color={status.color} variant="light" radius="sm">
{status.label}
</Badge>
{r.reference && (
<Text size="xs" c="dimmed" ff="monospace">
{r.reference}
</Text>
)}
</Group>
{r.status === "rejected" && r.reviewNote && (
<Text size="sm" c="red.7">
<strong>Reviewer note:</strong> {r.reviewNote}
</Text>
)}
</Stack>
{r.status === "rejected" && (
<ResubmitService
pending={resubmit.isPending}
onResubmit={(files) =>
resubmit.mutate({ profileId: r.id, files })
}
/>
)}
</Group>
);
})}
</Stack>
</Card>
);
}
/** Rejected-role actions: optionally replace the license, then resubmit. */
function ResubmitService({
pending,
onResubmit,
}: {
pending: boolean;
onResubmit: (files: File[]) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
return (
<Group gap="xs" wrap="nowrap">
<FileButton onChange={setFiles} accept="application/pdf,image/*" multiple>
{(props) => (
<Button
{...props}
size="xs"
variant="light"
leftSection={<UploadCloud size={14} />}
>
{files.length > 0 ? `${files.length} file(s)` : "Replace license"}
</Button>
)}
</FileButton>
<Button
size="xs"
color="edr-green"
leftSection={<RefreshCw size={14} />}
loading={pending}
onClick={() => onResubmit(files)}
>
Resubmit
</Button>
</Group>
);
}