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

View File

@@ -25,6 +25,7 @@ import {
Menu as MenuIcon,
Moon,
Plus,
RefreshCw,
Search,
Settings,
Sun,
@@ -59,7 +60,13 @@ export interface AppLayoutProps {
userName?: string;
userEmail?: string;
/** 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. */
companyType?: string | null;
/** Create a new service profile of the given type (with business license). */
@@ -67,6 +74,11 @@ export interface AppLayoutProps {
type: ServiceType,
licenseFiles: File[],
) => Promise<SwitchResult> | void;
/** Resubmit a rejected service for approval, optionally replacing its license. */
onReapplyProfile?: (
profileId: string,
licenseFiles: File[],
) => Promise<SwitchResult> | void;
children: ReactNode;
}
@@ -145,6 +157,7 @@ export function AppLayout({
companyProfiles = [],
companyType,
onCreateProfile,
onReapplyProfile,
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -178,36 +191,59 @@ export function AppLayout({
const profileExists = (type: ServiceType) =>
companyProfiles.some((p) => p.type === type);
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 [createOpen, setCreateOpen] = useState(false);
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 [createError, setCreateError] = useState<string | null>(null);
const handleAddService = (type: ServiceType) => {
// Collect a business license, then create the profile.
const openServiceModal = (
type: ServiceType,
profileId: string | null,
) => {
setCreateTarget(type);
setReapplyId(profileId);
setLicenseFiles([]);
setCreateError(null);
setCreateOpen(true);
};
const handleAddService = (type: ServiceType) => openServiceModal(type, null);
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.");
return;
}
setSwitching(true);
setCreateError(null);
try {
const res = await onCreateProfile?.(createTarget, licenseFiles);
const res = isReapply
? await onReapplyProfile?.(reapplyId, licenseFiles)
: await onCreateProfile?.(createTarget, licenseFiles);
if (res && !res.success) {
setCreateError(res.error?.message ?? "Failed to create profile");
setCreateError(res.error?.message ?? "Failed to submit service");
return;
}
setCreateOpen(false);
setReapplyId(null);
} finally {
setSwitching(false);
}
@@ -287,10 +323,10 @@ export function AppLayout({
{/* Right: switch + search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center">
{/* Add a service (customer companies that don't yet have all three) */}
{canAddService && (
{/* Add a service, or resubmit a rejected one (customer companies) */}
{canManageServices && (
<Menu
width={220}
width={240}
position="bottom-end"
withinPortal
shadow="md"
@@ -313,16 +349,38 @@ export function AppLayout({
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Add a service</Menu.Label>
{addableServices.map((type) => (
<Menu.Item
key={type}
onClick={() => handleAddService(type)}
leftSection={<Plus size={15} strokeWidth={1.8} />}
>
{serviceLabel(type)}
</Menu.Item>
))}
{addableServices.length > 0 && (
<>
<Menu.Label>Add a service</Menu.Label>
{addableServices.map((type) => (
<Menu.Item
key={type}
onClick={() => handleAddService(type)}
leftSection={<Plus size={15} strokeWidth={1.8} />}
>
{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>
)}
@@ -790,18 +848,28 @@ export function AppLayout({
<Modal
opened={createOpen}
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
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
You don't have a {serviceLabel(createTarget).toLowerCase()} profile
yet. Add your business license to create one and switch to{" "}
{serviceLabel(createTarget).toLowerCase()}.
{reapplyId
? `Your ${serviceLabel(
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>
<FileInput
label="Business license"
label={reapplyId ? "Business license (optional)" : "Business license"}
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
@@ -824,7 +892,7 @@ export function AppLayout({
onClick={handleCreateConfirm}
loading={switching}
>
Create &amp; switch
{reapplyId ? "Resubmit" : "Create & switch"}
</Button>
</Group>
</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 (
profileId: string,
licenseFiles: File[] = [],
): Promise<Result<void>> => {
try {
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(profileId, licenseFiles);
}
await api.companies.reapplyProfile.call({ profileId });
await invalidateScopedData();
return { success: true, data: undefined };

View File

@@ -4,10 +4,12 @@ import {
Alert,
Badge,
Box,
Button,
Card,
Center,
Container,
Fieldset,
FileButton,
Group,
Loader,
Stack,
@@ -16,7 +18,7 @@ import {
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
@@ -26,12 +28,16 @@ import {
Clock,
FileCheck,
Globe,
Layers,
RefreshCw,
ShieldCheck,
UploadCloud,
User,
UserCheck,
UserCog,
} 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 useAuth from "@/hooks/useAuth";
import { rolesForCompanyType } from "./settings/companyRoles";
@@ -42,13 +48,16 @@ import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager";
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. */
function tabIncomplete(
tabId: SettingsTab,
profile: ProfileResponse,
): boolean {
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
switch (tabId) {
case "company":
return (
@@ -66,8 +75,8 @@ function tabIncomplete(
!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.
// 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;
@@ -291,6 +300,8 @@ export default function SettingsPage() {
</Alert>
)}
<OperationalServicesCard profile={profile} />
<Tabs
value={tab}
onChange={(value) => value && setTab(value as SettingsTab)}
@@ -369,3 +380,147 @@ export default function SettingsPage() {
</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>
);
}