style: show the profile and the review ont the settings

This commit is contained in:
Nathnael
2026-07-21 12:13:18 +00:00
parent 649316070d
commit d95af0ea72
4 changed files with 275 additions and 38 deletions

View File

@@ -220,8 +220,14 @@ const sidebarItems: SidebarItem[] = [
const App = () => { const App = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, company, companyType, createProfile, isAuthenticated } = const {
useAuth(); user,
company,
companyType,
createProfile,
reapplyProfile,
isAuthenticated,
} = useAuth();
// Attribute replays and exceptions to the signed-in user (id/org only). // Attribute replays and exceptions to the signed-in user (id/org only).
useIdentify(user, company); useIdentify(user, company);
@@ -290,6 +296,7 @@ const App = () => {
companyProfiles={companyProfiles} companyProfiles={companyProfiles}
companyType={companyType} companyType={companyType}
onCreateProfile={createProfile} onCreateProfile={createProfile}
onReapplyProfile={reapplyProfile}
> >
<OnboardingGate /> <OnboardingGate />
</AppLayout> </AppLayout>

View File

@@ -25,6 +25,7 @@ import {
Menu as MenuIcon, Menu as MenuIcon,
Moon, Moon,
Plus, Plus,
RefreshCw,
Search, Search,
Settings, Settings,
Sun, Sun,
@@ -59,7 +60,13 @@ export interface AppLayoutProps {
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */ /** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[]; companyProfiles?: {
id?: string;
type: string;
reference: string;
status?: string;
reviewNote?: string | null;
}[];
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */ /** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
companyType?: string | null; companyType?: string | null;
/** Create a new service profile of the given type (with business license). */ /** Create a new service profile of the given type (with business license). */
@@ -67,6 +74,11 @@ export interface AppLayoutProps {
type: ServiceType, type: ServiceType,
licenseFiles: File[], licenseFiles: File[],
) => Promise<SwitchResult> | void; ) => Promise<SwitchResult> | void;
/** Resubmit a rejected service for approval, optionally replacing its license. */
onReapplyProfile?: (
profileId: string,
licenseFiles: File[],
) => Promise<SwitchResult> | void;
children: ReactNode; children: ReactNode;
} }
@@ -145,6 +157,7 @@ export function AppLayout({
companyProfiles = [], companyProfiles = [],
companyType, companyType,
onCreateProfile, onCreateProfile,
onReapplyProfile,
children, children,
}: AppLayoutProps) { }: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -178,36 +191,59 @@ export function AppLayout({
const profileExists = (type: ServiceType) => const profileExists = (type: ServiceType) =>
companyProfiles.some((p) => p.type === type); companyProfiles.some((p) => p.type === type);
const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t)); const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t));
const canAddService = isCustomer && addableServices.length > 0; // Rejected services can't be re-added (they exist), so they'd otherwise be
// invisible here — surface them for resubmission alongside addable ones.
const rejectedServices = isCustomer
? companyProfiles.filter(
(p) =>
p.status === "rejected" &&
p.id &&
CUSTOMER_SERVICES.includes(p.type as ServiceType),
)
: [];
const canManageServices =
isCustomer && (addableServices.length > 0 || rejectedServices.length > 0);
const [switching, setSwitching] = useState(false); const [switching, setSwitching] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createTarget, setCreateTarget] = useState<ServiceType>("importer"); const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
// Non-null while resubmitting a rejected service; null while creating a new one.
const [reapplyId, setReapplyId] = useState<string | null>(null);
const [licenseFiles, setLicenseFiles] = useState<File[]>([]); const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const handleAddService = (type: ServiceType) => { const openServiceModal = (
// Collect a business license, then create the profile. type: ServiceType,
profileId: string | null,
) => {
setCreateTarget(type); setCreateTarget(type);
setReapplyId(profileId);
setLicenseFiles([]); setLicenseFiles([]);
setCreateError(null); setCreateError(null);
setCreateOpen(true); setCreateOpen(true);
}; };
const handleAddService = (type: ServiceType) => openServiceModal(type, null);
const handleCreateConfirm = async () => { const handleCreateConfirm = async () => {
if (licenseFiles.length === 0) { const isReapply = reapplyId !== null;
// A new profile needs its license up front; a resubmit may reuse the old one.
if (!isReapply && licenseFiles.length === 0) {
setCreateError("Please upload at least one business license file."); setCreateError("Please upload at least one business license file.");
return; return;
} }
setSwitching(true); setSwitching(true);
setCreateError(null); setCreateError(null);
try { try {
const res = await onCreateProfile?.(createTarget, licenseFiles); const res = isReapply
? await onReapplyProfile?.(reapplyId, licenseFiles)
: await onCreateProfile?.(createTarget, licenseFiles);
if (res && !res.success) { if (res && !res.success) {
setCreateError(res.error?.message ?? "Failed to create profile"); setCreateError(res.error?.message ?? "Failed to submit service");
return; return;
} }
setCreateOpen(false); setCreateOpen(false);
setReapplyId(null);
} finally { } finally {
setSwitching(false); setSwitching(false);
} }
@@ -287,10 +323,10 @@ export function AppLayout({
{/* Right: switch + search + bell + avatar */} {/* Right: switch + search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center"> <Group gap={10} wrap="nowrap" align="center">
{/* Add a service (customer companies that don't yet have all three) */} {/* Add a service, or resubmit a rejected one (customer companies) */}
{canAddService && ( {canManageServices && (
<Menu <Menu
width={220} width={240}
position="bottom-end" position="bottom-end"
withinPortal withinPortal
shadow="md" shadow="md"
@@ -313,16 +349,38 @@ export function AppLayout({
</Button> </Button>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label>Add a service</Menu.Label> {addableServices.length > 0 && (
{addableServices.map((type) => ( <>
<Menu.Item <Menu.Label>Add a service</Menu.Label>
key={type} {addableServices.map((type) => (
onClick={() => handleAddService(type)} <Menu.Item
leftSection={<Plus size={15} strokeWidth={1.8} />} key={type}
> onClick={() => handleAddService(type)}
{serviceLabel(type)} leftSection={<Plus size={15} strokeWidth={1.8} />}
</Menu.Item> >
))} {serviceLabel(type)}
</Menu.Item>
))}
</>
)}
{rejectedServices.length > 0 && (
<>
{addableServices.length > 0 && <Menu.Divider />}
<Menu.Label>Rejected resubmit</Menu.Label>
{rejectedServices.map((p) => (
<Menu.Item
key={p.id}
color="red"
onClick={() =>
openServiceModal(p.type as ServiceType, p.id!)
}
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
>
{serviceLabel(p.type as ServiceType)}
</Menu.Item>
))}
</>
)}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
)} )}
@@ -790,18 +848,28 @@ export function AppLayout({
<Modal <Modal
opened={createOpen} opened={createOpen}
onClose={() => (switching ? undefined : setCreateOpen(false))} onClose={() => (switching ? undefined : setCreateOpen(false))}
title={`Set up your ${serviceLabel(createTarget)} profile`} title={
reapplyId
? `Resubmit your ${serviceLabel(createTarget)} service`
: `Set up your ${serviceLabel(createTarget)} profile`
}
centered centered
radius="lg" radius="lg"
> >
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
You don't have a {serviceLabel(createTarget).toLowerCase()} profile {reapplyId
yet. Add your business license to create one and switch to{" "} ? `Your ${serviceLabel(
{serviceLabel(createTarget).toLowerCase()}. createTarget,
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
: `You don't have a ${serviceLabel(
createTarget,
).toLowerCase()} profile yet. Add your business license to create one and switch to ${serviceLabel(
createTarget,
).toLowerCase()}.`}
</Text> </Text>
<FileInput <FileInput
label="Business license" label={reapplyId ? "Business license (optional)" : "Business license"}
multiple multiple
clearable clearable
accept="application/pdf,image/png,image/jpeg" accept="application/pdf,image/png,image/jpeg"
@@ -824,7 +892,7 @@ export function AppLayout({
onClick={handleCreateConfirm} onClick={handleCreateConfirm}
loading={switching} loading={switching}
> >
Create &amp; switch {reapplyId ? "Resubmit" : "Create & switch"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -231,11 +231,18 @@ const useAuth = () => {
} }
}; };
/** Resubmit a rejected operational role for approval, then refresh. */ /**
* Resubmit a rejected operational role for approval — optionally replacing its
* business license first (the common reason a role is rejected) — then refresh.
*/
const reapplyProfile = async ( const reapplyProfile = async (
profileId: string, profileId: string,
licenseFiles: File[] = [],
): Promise<Result<void>> => { ): Promise<Result<void>> => {
try { try {
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(profileId, licenseFiles);
}
await api.companies.reapplyProfile.call({ profileId }); await api.companies.reapplyProfile.call({ profileId });
await invalidateScopedData(); await invalidateScopedData();
return { success: true, data: undefined }; return { success: true, data: undefined };

View File

@@ -4,10 +4,12 @@ import {
Alert, Alert,
Badge, Badge,
Box, Box,
Button,
Card, Card,
Center, Center,
Container, Container,
Fieldset, Fieldset,
FileButton,
Group, Group,
Loader, Loader,
Stack, Stack,
@@ -16,7 +18,7 @@ import {
ThemeIcon, ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -26,12 +28,16 @@ import {
Clock, Clock,
FileCheck, FileCheck,
Globe, Globe,
Layers,
RefreshCw,
ShieldCheck, ShieldCheck,
UploadCloud,
User, User,
UserCheck, UserCheck,
UserCog, UserCog,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect } from "react"; import { companiesService } from "@/services/companies.service";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import { rolesForCompanyType } from "./settings/companyRoles"; import { rolesForCompanyType } from "./settings/companyRoles";
@@ -42,13 +48,16 @@ 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";
type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents"; type SettingsTab =
| "account"
| "company"
| "contact"
| "gm"
| "poa"
| "documents";
/** A section is "incomplete" when its required fields aren't filled in yet. */ /** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete( function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
tabId: SettingsTab,
profile: ProfileResponse,
): boolean {
switch (tabId) { switch (tabId) {
case "company": case "company":
return ( return (
@@ -66,8 +75,8 @@ function tabIncomplete(
!profile.generalManagerPhone !profile.generalManagerPhone
); );
case "account": case "account":
// Account fields live on the IAM user, not the company profile, and are // Account fields live on the IAM user, not the company profile, and are
// always populated (signup requires them) — nothing to nag about here. // always populated (signup requires them) — nothing to nag about here.
case "poa": case "poa":
case "documents": case "documents":
return false; return false;
@@ -291,6 +300,8 @@ export default function SettingsPage() {
</Alert> </Alert>
)} )}
<OperationalServicesCard profile={profile} />
<Tabs <Tabs
value={tab} value={tab}
onChange={(value) => value && setTab(value as SettingsTab)} onChange={(value) => value && setTab(value as SettingsTab)}
@@ -369,3 +380,147 @@ export default function SettingsPage() {
</Container> </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>
);
}