mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
style: show the profile and the review ont the settings
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 & switch
|
||||
{reapplyId ? "Resubmit" : "Create & switch"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user