diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 8bd7cc74f..72674363c 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -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} > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 35641f9fc..7870a56b6 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -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 | void; + /** Resubmit a rejected service for approval, optionally replacing its license. */ + onReapplyProfile?: ( + profileId: string, + licenseFiles: File[], + ) => Promise | 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("importer"); + // Non-null while resubmitting a rejected service; null while creating a new one. + const [reapplyId, setReapplyId] = useState(null); const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(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 */} - {/* Add a service (customer companies that don't yet have all three) */} - {canAddService && ( + {/* Add a service, or resubmit a rejected one (customer companies) */} + {canManageServices && ( - Add a service - {addableServices.map((type) => ( - handleAddService(type)} - leftSection={} - > - {serviceLabel(type)} - - ))} + {addableServices.length > 0 && ( + <> + Add a service + {addableServices.map((type) => ( + handleAddService(type)} + leftSection={} + > + {serviceLabel(type)} + + ))} + + )} + {rejectedServices.length > 0 && ( + <> + {addableServices.length > 0 && } + Rejected — resubmit + {rejectedServices.map((p) => ( + + openServiceModal(p.type as ServiceType, p.id!) + } + leftSection={} + > + {serviceLabel(p.type as ServiceType)} + + ))} + + )} )} @@ -790,18 +848,28 @@ export function AppLayout({ (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" > - 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()}.`} - Create & switch + {reapplyId ? "Resubmit" : "Create & switch"} diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index be5cd4d7c..daeb55600 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -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> => { try { + if (licenseFiles.length > 0) { + await companiesService.uploadProfileLicense(profileId, licenseFiles); + } await api.companies.reapplyProfile.call({ profileId }); await invalidateScopedData(); return { success: true, data: undefined }; diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 00b9c3043..4018d131b 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -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() { )} + + value && setTab(value as SettingsTab)} @@ -369,3 +380,147 @@ export default function SettingsPage() { ); } + +const ROLE_LABELS: Record = { + importer: "Importer", + exporter: "Exporter", + freight_forwarder: "Freight Forwarder", + dj_freight_forwarder: "DJ Freight Forwarder", + transporter: "Transporter", +}; + +const ROLE_STATUS: Record = { + 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 ( + + + + Operational Services + + + {roles.map((r) => { + const status = ROLE_STATUS[r.status] ?? { + color: "gray", + label: r.status, + }; + return ( + + + + {ROLE_LABELS[r.type] ?? r.type} + + {status.label} + + {r.reference && ( + + {r.reference} + + )} + + {r.status === "rejected" && r.reviewNote && ( + + Reviewer note: {r.reviewNote} + + )} + + + {r.status === "rejected" && ( + + resubmit.mutate({ profileId: r.id, files }) + } + /> + )} + + ); + })} + + + ); +} + +/** Rejected-role actions: optionally replace the license, then resubmit. */ +function ResubmitService({ + pending, + onResubmit, +}: { + pending: boolean; + onResubmit: (files: File[]) => void; +}) { + const [files, setFiles] = useState([]); + + return ( + + + {(props) => ( + + )} + + + + ); +}