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 && (
)}
@@ -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) => (
+ }
+ >
+ {files.length > 0 ? `${files.length} file(s)` : "Replace license"}
+
+ )}
+
+ }
+ loading={pending}
+ onClick={() => onResubmit(files)}
+ >
+ Resubmit
+
+
+ );
+}