mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
527 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|