mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
Merge pull request #899 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -61,8 +61,11 @@ export default function ResetPasswordAction({
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
||||
// missing one, so staff can't send a link that will never arrive.
|
||||
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
||||
const channelMissing =
|
||||
!!target && (channel === "email" ? !target.email : !target.phone);
|
||||
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -106,9 +109,13 @@ export default function ResetPasswordAction({
|
||||
<Radio
|
||||
value="phone"
|
||||
label="SMS"
|
||||
disabled={!target.phone}
|
||||
disabled={!phoneUsable}
|
||||
description={
|
||||
target.phone ?? "No phone number on this account"
|
||||
!target.phone
|
||||
? "No phone number on this account"
|
||||
: target.phoneIsDomestic === false
|
||||
? `${target.phone} — foreign number, SMS unavailable; use email`
|
||||
: target.phone
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
|
||||
@@ -298,34 +298,82 @@ export function ProfileApprovalActions({
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [decision, setDecision] = useState<
|
||||
"reject" | "suspend" | "reactivate" | null
|
||||
>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||
|
||||
const confirmReject = () => {
|
||||
// Decisions the customer must be given a reason for. Reject/suspend/reactivate
|
||||
// all capture a required message through the same modal; the API refuses
|
||||
// suspend/reactivate without one.
|
||||
const DECISIONS = {
|
||||
reject: {
|
||||
title: "Reject profile",
|
||||
intro:
|
||||
"Tell the customer what needs fixing. They'll see this note and can " +
|
||||
"amend and resubmit the role for approval.",
|
||||
label: "Reason for rejection",
|
||||
placeholder: "e.g. The uploaded business license is expired.",
|
||||
confirmLabel: "Reject profile",
|
||||
color: "red",
|
||||
status: "rejected" as ProfileStatus,
|
||||
},
|
||||
suspend: {
|
||||
title: "Suspend role",
|
||||
intro:
|
||||
"Explain why this role is being suspended. The customer will see this " +
|
||||
"message and cannot operate under the role until it is reactivated.",
|
||||
label: "Reason for suspension",
|
||||
placeholder: "e.g. Outstanding invoices unpaid for over 90 days.",
|
||||
confirmLabel: "Suspend role",
|
||||
color: "orange",
|
||||
status: "suspended" as ProfileStatus,
|
||||
},
|
||||
reactivate: {
|
||||
title: "Reactivate role",
|
||||
intro:
|
||||
"Explain why this role is being reactivated. The customer will see " +
|
||||
"this message and can operate under the role again.",
|
||||
label: "Reactivation message",
|
||||
placeholder: "e.g. Outstanding payments have been settled.",
|
||||
confirmLabel: "Reactivate role",
|
||||
color: "edr-green",
|
||||
status: "active" as ProfileStatus,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const openDecision = (kind: keyof typeof DECISIONS) => {
|
||||
setNote("");
|
||||
setDecision(kind);
|
||||
};
|
||||
|
||||
const active = decision ? DECISIONS[decision] : null;
|
||||
|
||||
const confirmDecision = () => {
|
||||
if (!active) return;
|
||||
mutate(
|
||||
{ profileId, status: "rejected", note: note.trim() },
|
||||
{ onSuccess: () => setRejectOpen(false) },
|
||||
{ profileId, status: active.status, note: note.trim() },
|
||||
{ onSuccess: () => setDecision(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const rejectModal = (
|
||||
const decisionModal = active && (
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
title="Reject profile"
|
||||
opened
|
||||
onClose={() => setDecision(null)}
|
||||
title={active.title}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tell the customer what needs fixing. They'll see this note and can
|
||||
amend and resubmit the role for approval.
|
||||
{active.intro}
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="e.g. The uploaded business license is expired."
|
||||
label={active.label}
|
||||
placeholder={active.placeholder}
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
@@ -335,18 +383,18 @@ export function ProfileApprovalActions({
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectOpen(false)}
|
||||
onClick={() => setDecision(null)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
color={active.color}
|
||||
loading={isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
onClick={confirmDecision}
|
||||
>
|
||||
Reject profile
|
||||
{active.confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -368,7 +416,7 @@ export function ProfileApprovalActions({
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<>
|
||||
{rejectModal}
|
||||
{decisionModal}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -385,7 +433,7 @@ export function ProfileApprovalActions({
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => setRejectOpen(true)}
|
||||
onClick={() => openDecision("reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
@@ -411,29 +459,33 @@ export function ProfileApprovalActions({
|
||||
|
||||
if (status === "active") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("suspended")}
|
||||
>
|
||||
Suspend
|
||||
</Button>
|
||||
<>
|
||||
{decisionModal}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => openDecision("suspend")}
|
||||
>
|
||||
Suspend
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "suspended") {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{decisionModal}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
onClick={() => openDecision("reactivate")}
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
|
||||
@@ -81,6 +81,10 @@ const VIEW_FILTERS: Record<
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
// Queue ordering: awaiting first approval → pending profile changes → the
|
||||
// rest, newest first within each group. The default, so whatever marketing
|
||||
// must act on is always on top of the list.
|
||||
{ value: "review:DESC", label: "Needs review first" },
|
||||
{ value: "createdAt:DESC", label: "Newest first" },
|
||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||
{ value: "name:ASC", label: "Name (A–Z)" },
|
||||
@@ -93,11 +97,11 @@ export default function CustomersPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||
const [sort, setSort] = useState<string>("review:DESC");
|
||||
|
||||
const filter = useMemo(() => {
|
||||
const [sortBy, sortOrder] = sort.split(":") as [
|
||||
"name" | "createdAt" | "updatedAt",
|
||||
"review" | "name" | "createdAt" | "updatedAt",
|
||||
"ASC" | "DESC",
|
||||
];
|
||||
return {
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface CustomerResetTarget {
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
/** SMS gateway is domestic-only; `false` means SMS can't reach this phone. `null` = no phone. */
|
||||
phoneIsDomestic: boolean | null;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||
@@ -207,7 +209,8 @@ export interface CompanyListFilter {
|
||||
* already `active`, so `status` alone can never surface them.
|
||||
*/
|
||||
hasPendingChangeRequest?: boolean;
|
||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
||||
/** `review` = queue ordering: awaiting first approval → pending changes → rest, newest first within each. */
|
||||
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
Alert,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
@@ -19,12 +21,14 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Ban,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
LogOut,
|
||||
Menu as MenuIcon,
|
||||
Moon,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
@@ -59,7 +63,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 +77,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 +160,7 @@ export function AppLayout({
|
||||
companyProfiles = [],
|
||||
companyType,
|
||||
onCreateProfile,
|
||||
onReapplyProfile,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||
@@ -178,42 +194,86 @@ 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),
|
||||
)
|
||||
: [];
|
||||
// Suspended services are also hidden by default (the profile exists) — surface
|
||||
// them so the customer can appeal by resubmitting a fresh business license.
|
||||
const suspendedServices = isCustomer
|
||||
? companyProfiles.filter(
|
||||
(p) =>
|
||||
p.status === "suspended" &&
|
||||
p.id &&
|
||||
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||||
)
|
||||
: [];
|
||||
const canManageServices =
|
||||
isCustomer &&
|
||||
(addableServices.length > 0 ||
|
||||
rejectedServices.length > 0 ||
|
||||
suspendedServices.length > 0);
|
||||
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
|
||||
// Non-null while resubmitting a rejected/suspended service; null while creating a new one.
|
||||
const [reapplyId, setReapplyId] = useState<string | null>(null);
|
||||
// Status of the profile being resubmitted ("rejected" | "suspended") — drives
|
||||
// the modal copy; null for a brand-new profile.
|
||||
const [reapplyStatus, setReapplyStatus] = useState<string | null>(null);
|
||||
// Reason the profile was suspended/rejected, surfaced in the modal.
|
||||
const [reapplyNote, setReapplyNote] = 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,
|
||||
profile?: { id?: string; status?: string; reviewNote?: string | null },
|
||||
) => {
|
||||
setCreateTarget(type);
|
||||
setReapplyId(profile?.id ?? null);
|
||||
setReapplyStatus(profile?.status ?? null);
|
||||
setReapplyNote(profile?.reviewNote ?? null);
|
||||
setLicenseFiles([]);
|
||||
setCreateError(null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleAddService = (type: ServiceType) => openServiceModal(type);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||||
const isSuspendedAppeal = reapplyStatus === "suspended";
|
||||
|
||||
const isItemActive = (item: SidebarItem) =>
|
||||
activePath === item.href.toLowerCase() ||
|
||||
@@ -287,10 +347,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 +373,62 @@ 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)
|
||||
}
|
||||
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
|
||||
>
|
||||
{serviceLabel(p.type as ServiceType)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{suspendedServices.length > 0 && (
|
||||
<>
|
||||
{(addableServices.length > 0 ||
|
||||
rejectedServices.length > 0) && <Menu.Divider />}
|
||||
<Menu.Label>Suspended — appeal</Menu.Label>
|
||||
{suspendedServices.map((p) => (
|
||||
<Menu.Item
|
||||
key={p.id}
|
||||
color="orange"
|
||||
onClick={() =>
|
||||
openServiceModal(p.type as ServiceType, p)
|
||||
}
|
||||
leftSection={<Ban size={15} strokeWidth={1.8} />}
|
||||
rightSection={
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
Suspended
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{serviceLabel(p.type as ServiceType)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
@@ -790,18 +896,42 @@ export function AppLayout({
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
||||
title={`Set up your ${serviceLabel(createTarget)} profile`}
|
||||
title={
|
||||
isSuspendedAppeal
|
||||
? `Appeal suspension — ${serviceLabel(createTarget)}`
|
||||
: 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()}.
|
||||
{isSuspendedAppeal
|
||||
? `Your ${serviceLabel(
|
||||
createTarget,
|
||||
).toLowerCase()} service is currently suspended. Replace the business license if needed and resubmit — this sends your appeal back to EDR for review.`
|
||||
: 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 — it goes to EDR for approval before you can operate under it.`}
|
||||
</Text>
|
||||
{isSuspendedAppeal && reapplyNote && (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
icon={<Ban size={16} />}
|
||||
title="Reason for suspension"
|
||||
>
|
||||
{reapplyNote}
|
||||
</Alert>
|
||||
)}
|
||||
<FileInput
|
||||
label="Business license"
|
||||
label={reapplyId ? "Business license (optional)" : "Business license"}
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
@@ -824,7 +954,11 @@ export function AppLayout({
|
||||
onClick={handleCreateConfirm}
|
||||
loading={switching}
|
||||
>
|
||||
Create & switch
|
||||
{isSuspendedAppeal
|
||||
? "Submit appeal"
|
||||
: reapplyId
|
||||
? "Resubmit"
|
||||
: "Create"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -11,22 +11,21 @@ interface NewBookingButtonProps {
|
||||
|
||||
/**
|
||||
* New-booking entry point that respects approval status: a customer can only
|
||||
* create bookings under a profile once the backoffice has approved it. While the
|
||||
* active profile is pending the button is disabled with an explanation, so the
|
||||
* gate is communicated rather than silently failing at submit time.
|
||||
* create bookings once the backoffice has approved at least one operational
|
||||
* role. While every role is still pending the button is disabled with an
|
||||
* explanation, so the gate is communicated rather than failing at submit time.
|
||||
*/
|
||||
export function NewBookingButton({
|
||||
label = "New booking",
|
||||
size,
|
||||
mt,
|
||||
}: NewBookingButtonProps) {
|
||||
const { canBook, activeProfileStatus } = useAuth();
|
||||
const { canBook, hasPendingProfile } = useAuth();
|
||||
|
||||
if (!canBook) {
|
||||
const message =
|
||||
activeProfileStatus === "pending"
|
||||
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
||||
: "Bookings aren't available for this profile yet.";
|
||||
const message = hasPendingProfile
|
||||
? "Your role is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
||||
: "Bookings aren't available until one of your roles is approved.";
|
||||
return (
|
||||
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
|
||||
<Box mt={mt}>
|
||||
|
||||
@@ -99,7 +99,6 @@ export const URL_CONSTANTS = {
|
||||
PROFILE: "/api/companies/profile",
|
||||
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||
COMPANY_PROFILE: "/api/companies/company-profile",
|
||||
ACTIVE_MODE: "/api/companies/active-mode",
|
||||
ONBOARDING_START: "/api/companies/onboarding/start",
|
||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||
|
||||
@@ -158,11 +158,7 @@ const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Active-mode (importer/exporter) state, sourced from the persisted profile.
|
||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||
const activeProfileType = companyInfo?.profile?.activeProfileType ?? null;
|
||||
const activeCompanyProfileId =
|
||||
companyInfo?.profile?.activeCompanyProfileId ?? null;
|
||||
const companyType = companyInfo?.company?.type ?? null;
|
||||
const companyStatus = companyInfo?.company?.status ?? null;
|
||||
// A company can create bookings only once an admin has approved it (active).
|
||||
@@ -171,14 +167,13 @@ const useAuth = () => {
|
||||
companyInfo?.profile?.onboardingCompleted ?? false;
|
||||
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
||||
|
||||
// Booking is gated on backoffice approval of the active operational profile:
|
||||
// a customer can only book under a profile once its status is "active".
|
||||
const activeProfile =
|
||||
companyInfo?.company?.companyProfiles?.find(
|
||||
(p) => p.id === activeCompanyProfileId,
|
||||
) ?? null;
|
||||
const activeProfileStatus = activeProfile?.status ?? null;
|
||||
const canBook = activeProfileStatus === "active";
|
||||
// A booking/contract stamps its operational profile from the trade direction
|
||||
// at creation time, so there's no "active mode": the customer can create work
|
||||
// as long as they have at least one backoffice-approved operational role.
|
||||
const companyProfiles = companyInfo?.company?.companyProfiles ?? [];
|
||||
const hasActiveProfile = companyProfiles.some((p) => p.status === "active");
|
||||
const hasPendingProfile = companyProfiles.some((p) => p.status === "pending");
|
||||
const canBook = hasActiveProfile;
|
||||
|
||||
// Profile-edit review: while a change request is pending the customer is
|
||||
// locked out of editing and of creating new contracts/bookings; a rejected
|
||||
@@ -188,7 +183,7 @@ const useAuth = () => {
|
||||
const reviewNote = review?.note ?? null;
|
||||
const isUnderReview = reviewStatus === "pending";
|
||||
|
||||
/** Refetch everything scoped to the active operational profile. */
|
||||
/** Refetch company info, dashboard, and bookings after a profile change. */
|
||||
const invalidateScopedData = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
@@ -201,16 +196,6 @@ const useAuth = () => {
|
||||
]);
|
||||
};
|
||||
|
||||
const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
|
||||
try {
|
||||
await api.companies.setActiveMode.call({ type });
|
||||
await invalidateScopedData();
|
||||
return { success: true, data: undefined };
|
||||
} catch (err) {
|
||||
return { success: false, error: extractApiError(err) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an operational role. The new role starts pending review, so the active
|
||||
* mode is left untouched — the user keeps working under their approved role.
|
||||
@@ -231,11 +216,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 };
|
||||
@@ -269,10 +261,9 @@ const useAuth = () => {
|
||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
activeProfileType,
|
||||
activeCompanyProfileId,
|
||||
activeProfileStatus,
|
||||
canBook,
|
||||
hasActiveProfile,
|
||||
hasPendingProfile,
|
||||
companyType,
|
||||
companyStatus,
|
||||
isCompanyApproved,
|
||||
@@ -281,7 +272,6 @@ const useAuth = () => {
|
||||
isUnderReview,
|
||||
onboardingCompleted,
|
||||
onboardingStep,
|
||||
switchMode,
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
login,
|
||||
|
||||
@@ -106,7 +106,6 @@ export function captureApiError(error: unknown): void {
|
||||
/** Company context, as returned by `useAuth().company`. */
|
||||
interface IdentifyCompany {
|
||||
company?: { id?: string; type?: string | null; status?: string | null } | null;
|
||||
profile?: { activeProfileType?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +135,6 @@ export function useIdentify(
|
||||
company_id: company?.company?.id,
|
||||
company_type: company?.company?.type,
|
||||
company_status: company?.company?.status,
|
||||
active_profile_type: company?.profile?.activeProfileType,
|
||||
});
|
||||
}, [
|
||||
user?.id,
|
||||
@@ -146,6 +144,5 @@ export function useIdentify(
|
||||
company?.company?.id,
|
||||
company?.company?.type,
|
||||
company?.company?.status,
|
||||
company?.profile?.activeProfileType,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Fieldset,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
@@ -16,7 +19,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,
|
||||
@@ -25,13 +28,20 @@ import {
|
||||
Building2,
|
||||
Clock,
|
||||
FileCheck,
|
||||
FileText,
|
||||
Globe,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
UploadCloud,
|
||||
User,
|
||||
UserCheck,
|
||||
UserCog,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||
@@ -42,13 +52,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 +79,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;
|
||||
@@ -343,6 +356,7 @@ export default function SettingsPage() {
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabCompanyProfile mode="edit" profile={profile} />
|
||||
</Fieldset>
|
||||
<OperationalServicesCard profile={profile} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="contact">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
@@ -369,3 +383,203 @@ 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 { view, viewer } = useFileViewer();
|
||||
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" mt="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.status === "suspended") &&
|
||||
r.reviewNote && (
|
||||
<Text
|
||||
size="sm"
|
||||
c={r.status === "suspended" ? "orange.7" : "red.7"}
|
||||
>
|
||||
<strong>
|
||||
{r.status === "suspended"
|
||||
? "Suspension reason:"
|
||||
: "Reviewer note:"}
|
||||
</strong>{" "}
|
||||
{r.reviewNote}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{r.licenseFiles.length === 0 ? (
|
||||
<Text size="xs" c="edr-muted">
|
||||
No license document
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{r.licenseFiles.map((f) => (
|
||||
<Group key={f.id} gap="xs" wrap="nowrap">
|
||||
<FileText
|
||||
size={14}
|
||||
className="text-edr-muted"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
{f.status !== "live" && (
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
f.status === "pending_remove" ? "red" : "yellow"
|
||||
}
|
||||
>
|
||||
{f.status === "pending_remove"
|
||||
? "Removal pending"
|
||||
: "Pending"}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{r.status === "rejected" && (
|
||||
<ResubmitService
|
||||
pending={resubmit.isPending}
|
||||
onResubmit={(files) =>
|
||||
resubmit.mutate({ profileId: r.id, files })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,10 +211,6 @@ export function BookingPaymentPanel({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
});
|
||||
// The invoice worth a prominent "Download" — the first issued one, else any.
|
||||
const primary =
|
||||
invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0];
|
||||
const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false;
|
||||
|
||||
const downloadInvoice = async (inv: PortalInvoice) => {
|
||||
try {
|
||||
@@ -380,50 +376,67 @@ export function BookingPaymentPanel({
|
||||
</Box>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<InvoiceStatusBadge status={inv.status} />
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => downloadInvoice(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{invoices.length > 1 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => downloadInvoice(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download receipt"
|
||||
onClick={() => downloadReceipt(inv)}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{primary && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
onClick={() => downloadInvoice(primary)}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
)}
|
||||
{primary && primaryPaid && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={8}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={10}
|
||||
leftSection={<Receipt size={17} />}
|
||||
onClick={() => downloadReceipt(primary)}
|
||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||
>
|
||||
Download receipt
|
||||
</Button>
|
||||
{/* Single invoice: a prominent download instead of a lone row icon. */}
|
||||
{invoices.length === 1 && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
onClick={() => downloadInvoice(invoices[0])}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
{Number(invoices[0].paidAmount) > 0 && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={8}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={10}
|
||||
leftSection={<Receipt size={17} />}
|
||||
onClick={() => downloadReceipt(invoices[0])}
|
||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||
>
|
||||
Download receipt
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, Download, Receipt } from "lucide-react";
|
||||
import { CreditCard, Download, FileText, Receipt } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -184,23 +184,27 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => download(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download receipt"
|
||||
onClick={() => downloadReceipt(inv)}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</ActionIcon>
|
||||
{invoices.length > 1 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download invoice"
|
||||
onClick={() => download(inv)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download receipt"
|
||||
onClick={() => downloadReceipt(inv)}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -208,6 +212,40 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Single invoice: a prominent download instead of a lone row icon. */}
|
||||
{invoices.length === 1 && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="md"
|
||||
variant="default"
|
||||
radius={10}
|
||||
leftSection={<FileText size={17} color="#475569" />}
|
||||
onClick={() => download(invoices[0])}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||
}}
|
||||
>
|
||||
Download invoice
|
||||
</Button>
|
||||
{Number(invoices[0].paidAmount) > 0 && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={8}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius={10}
|
||||
leftSection={<Receipt size={17} />}
|
||||
onClick={() => downloadReceipt(invoices[0])}
|
||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||
>
|
||||
Download receipt
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payInvoice !== null}
|
||||
onClose={closePayModal}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
bookingFormSchema,
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
isForwarderOperation,
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
stepFields,
|
||||
@@ -384,25 +385,23 @@ export default function NewBookingPage() {
|
||||
[profileTypes],
|
||||
);
|
||||
|
||||
// Stamp the booking to the right operational profile. Import/Export (and their
|
||||
// "as FF" variants) switch the active mode so the matching onboarding documents
|
||||
// are attached; Intercity uses whatever profile is already active.
|
||||
const handleOperationSelect = (op: OperationType) => {
|
||||
if (op === "intercity") return;
|
||||
const target = operationToProfileType(op, profileTypes);
|
||||
if (auth.activeProfileType !== target) {
|
||||
void auth.switchMode(target as never);
|
||||
}
|
||||
};
|
||||
|
||||
// Onboarding documents for the active profile — shown read-only in the
|
||||
// Documents step and attached to the booking on submit by the backend.
|
||||
const onboardingDocs = useMemo(() => {
|
||||
// The company_profile this booking belongs to, derived from the selected
|
||||
// operation: import→importer, export→exporter, and the "as FF" variants →
|
||||
// freight_forwarder. A forwarder booking is pinned explicitly on submit
|
||||
// (companyProfileId) because trade direction alone can't distinguish it.
|
||||
const selectedProfile = useMemo(() => {
|
||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||
const active =
|
||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
||||
return active?.licenseFiles ?? [];
|
||||
}, [auth.company, auth.activeCompanyProfileId]);
|
||||
if (!operationType) return profiles[0] ?? null;
|
||||
const targetType = operationToProfileType(operationType, profileTypes);
|
||||
return profiles.find((p) => p.type === targetType) ?? profiles[0] ?? null;
|
||||
}, [auth.company, operationType, profileTypes]);
|
||||
|
||||
// Onboarding documents for the resolved profile — shown read-only in the
|
||||
// Documents step and attached to the booking on submit by the backend.
|
||||
const onboardingDocs = useMemo(
|
||||
() => selectedProfile?.licenseFiles ?? [],
|
||||
[selectedProfile],
|
||||
);
|
||||
|
||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||
null,
|
||||
@@ -483,7 +482,16 @@ export default function NewBookingPage() {
|
||||
(s) => s.id === data.serviceTypeId,
|
||||
)!;
|
||||
|
||||
// Pin the profile only for a forwarder booking — trade direction resolves
|
||||
// importer/exporter on its own, but can't tell a forwarder apart.
|
||||
const forwarderProfileId =
|
||||
data.operationType &&
|
||||
isForwarderOperation(data.operationType, profileTypes)
|
||||
? selectedProfile?.id
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
|
||||
bookingType: isContract
|
||||
? Freight.BookingType.GeneralContract
|
||||
: Freight.BookingType.OneTime,
|
||||
@@ -711,7 +719,6 @@ export default function NewBookingPage() {
|
||||
<Step0OperationType
|
||||
form={form}
|
||||
allowedOperations={allowedOperations}
|
||||
onSelect={handleOperationSelect}
|
||||
/>
|
||||
)}
|
||||
{step === 1 && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
operationToProfileType,
|
||||
type BookingDocuments,
|
||||
type BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
@@ -58,11 +59,20 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Documents already on file from onboarding (read-only reference).
|
||||
// Documents already on file from onboarding (read-only reference), for the
|
||||
// profile this booking's operation resolves to (importer/exporter/forwarder).
|
||||
const onboardingDocs = (() => {
|
||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||
const operationType = form.watch("operationType");
|
||||
const targetType = operationType
|
||||
? operationToProfileType(
|
||||
operationType,
|
||||
profiles.map((p) => p.type),
|
||||
)
|
||||
: null;
|
||||
const active =
|
||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
||||
(targetType ? profiles.find((p) => p.type === targetType) : undefined) ??
|
||||
profiles[0];
|
||||
return active?.licenseFiles ?? [];
|
||||
})();
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "./new-contract-form/schema";
|
||||
import {
|
||||
getRouteDirection,
|
||||
isForwarderOperation,
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
} from "./new-contract-form/helpers";
|
||||
@@ -124,6 +125,30 @@ export default function NewContractPage({
|
||||
);
|
||||
}
|
||||
|
||||
// A suspended/blacklisted account must be told its real status — falling
|
||||
// through to the wizard here only to fail at submit read as "pending" before.
|
||||
if (
|
||||
!auth.isPending &&
|
||||
(auth.companyStatus === "suspended" || auth.companyStatus === "blacklisted")
|
||||
) {
|
||||
return (
|
||||
<GateNotice
|
||||
title={
|
||||
auth.companyStatus === "suspended"
|
||||
? "Account Suspended"
|
||||
: "Account Blacklisted"
|
||||
}
|
||||
body={
|
||||
auth.companyStatus === "suspended"
|
||||
? "Your company account has been suspended by EDR staff, so new contracts are disabled. Please contact EDR support for details."
|
||||
: "Your company account has been blacklisted, so new contracts are disabled. Please contact EDR support."
|
||||
}
|
||||
actionLabel="Back to Contracts"
|
||||
onAction={() => navigate("/contracts")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Profile changes pending review lock out new contract creation too.
|
||||
if (!auth.isPending && auth.isUnderReview) {
|
||||
return (
|
||||
@@ -349,13 +374,19 @@ export default function NewContractPage({
|
||||
// badges. Intercity rides any customer profile, so always "approved".
|
||||
const operationStatus = useMemo(
|
||||
() =>
|
||||
(op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
|
||||
(
|
||||
op: OperationType,
|
||||
): "approved" | "pending" | "rejected" | "suspended" | "missing" => {
|
||||
if (op === "intercity") return "approved";
|
||||
const target = operationToProfileType(op, profileTypes);
|
||||
const status = profileStatusByType.get(target);
|
||||
if (!status) return "missing";
|
||||
if (status === "active") return "approved";
|
||||
if (status === "rejected") return "rejected";
|
||||
// Suspension is per-role: the customer keeps working under their other
|
||||
// roles, so this operation must say "suspended", not "pending".
|
||||
if (status === "suspended" || status === "blacklisted")
|
||||
return "suspended";
|
||||
return "pending";
|
||||
},
|
||||
[profileStatusByType, profileTypes],
|
||||
@@ -411,7 +442,9 @@ export default function NewContractPage({
|
||||
mutationFn: async (profileId: string) => {
|
||||
const res = await auth.reapplyProfile(profileId);
|
||||
if (!res.success) {
|
||||
throw new Error(res.error?.message ?? "Failed to resubmit for approval");
|
||||
throw new Error(
|
||||
res.error?.message ?? "Failed to resubmit for approval",
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => setPendingApprovalProfile(null),
|
||||
@@ -439,10 +472,8 @@ export default function NewContractPage({
|
||||
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
// Case 1 — approved: proceed, switching the active profile if needed.
|
||||
if (auth.activeProfileType !== target) {
|
||||
void auth.switchMode(target as never);
|
||||
}
|
||||
// Case 1 — approved: proceed. The profile is resolved from the operation at
|
||||
// submit time (a forwarder operation pins it explicitly), so nothing to set.
|
||||
};
|
||||
|
||||
const handleCreateProfileConfirm = () => {
|
||||
@@ -512,16 +543,16 @@ export default function NewContractPage({
|
||||
// GENERAL contract until its validity expires.
|
||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||
? data.enabledContainerSizes.map((size) => ({
|
||||
containerSize: size,
|
||||
// Required cargo description — what the containers carry.
|
||||
cargoFreeText: data.cargoFreeText.trim() || undefined,
|
||||
}))
|
||||
containerSize: size,
|
||||
// Required cargo description — what the containers carry.
|
||||
cargoFreeText: data.cargoFreeText.trim() || undefined,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||
cargoFreeText: data.cargoFreeText || undefined,
|
||||
},
|
||||
];
|
||||
{
|
||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||
cargoFreeText: data.cargoFreeText || undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// Route — a single origin→destination lane, general contracts included.
|
||||
const routes: Freight.CreateContractRouteInputDto[] = [
|
||||
@@ -532,7 +563,20 @@ export default function NewContractPage({
|
||||
},
|
||||
];
|
||||
|
||||
// Pin the profile only for a forwarder contract (trade direction can't tell
|
||||
// a forwarder apart from a direct import/export).
|
||||
const forwarderProfileId =
|
||||
data.operationType &&
|
||||
isForwarderOperation(data.operationType, profileTypes)
|
||||
? (auth.company?.company?.companyProfiles ?? []).find(
|
||||
(p) =>
|
||||
p.type ===
|
||||
operationToProfileType(data.operationType!, profileTypes),
|
||||
)?.id
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
|
||||
contractKind: isGeneral
|
||||
? Freight.ContractKind.General
|
||||
: Freight.ContractKind.OneTime,
|
||||
@@ -547,11 +591,11 @@ export default function NewContractPage({
|
||||
// booking time, but only on contracts created WITH_RETURN.
|
||||
...(isContainer
|
||||
? {
|
||||
equipmentReturn:
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
}
|
||||
equipmentReturn:
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
}
|
||||
: {}),
|
||||
isHazardous: data.isHazardous,
|
||||
// Reefer is a contract-level flag for both container and bulk.
|
||||
@@ -580,7 +624,8 @@ export default function NewContractPage({
|
||||
? { customsClearingEnabled: true }
|
||||
: {
|
||||
customsClearingEnabled: false,
|
||||
customsClearingAgent: data.customsClearingAgent?.trim() || undefined,
|
||||
customsClearingAgent:
|
||||
data.customsClearingAgent?.trim() || undefined,
|
||||
}),
|
||||
cargoScope,
|
||||
routes,
|
||||
@@ -695,10 +740,7 @@ export default function NewContractPage({
|
||||
<Text size="sm" fw={600}>
|
||||
What the reviewer asked for:
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{editContract.latestChangeRequestNote}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
@@ -1166,6 +1208,8 @@ export default function NewContractPage({
|
||||
? profileByType.get(pendingApprovalProfile)
|
||||
: undefined;
|
||||
const isRejected = target?.status === "rejected";
|
||||
const isSuspended =
|
||||
target?.status === "suspended" || target?.status === "blacklisted";
|
||||
const label = pendingApprovalProfile
|
||||
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
||||
pendingApprovalProfile)
|
||||
@@ -1174,12 +1218,42 @@ export default function NewContractPage({
|
||||
<Modal
|
||||
opened={pendingApprovalProfile !== null}
|
||||
onClose={() => setPendingApprovalProfile(null)}
|
||||
title={isRejected ? "Profile not approved" : "Awaiting approval"}
|
||||
title={
|
||||
isRejected
|
||||
? "Profile not approved"
|
||||
: isSuspended
|
||||
? "Role suspended"
|
||||
: "Awaiting approval"
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{isRejected ? (
|
||||
{isSuspended ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Your {label} role has been suspended by EDR staff, so you
|
||||
can't start a contract under it. Your other roles are
|
||||
unaffected. Contact EDR support to resolve this.
|
||||
</Text>
|
||||
{target?.reviewNote && (
|
||||
<Alert color="orange" variant="light" radius="md">
|
||||
<Text size="sm">
|
||||
<strong>Message from EDR staff:</strong>{" "}
|
||||
{target.reviewNote}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={() => setPendingApprovalProfile(null)}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : isRejected ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Your {label} profile was not approved. Fix the issue below
|
||||
@@ -1215,7 +1289,8 @@ export default function NewContractPage({
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Your {label} profile was submitted and is under staff
|
||||
review. You can start a contract under it once it's approved.
|
||||
review. You can start a contract under it once it's
|
||||
approved.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { operationToProfileType } from "./helpers";
|
||||
import { StepCard, StepHeader } from "./shared";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
@@ -73,8 +74,16 @@ export function StepDocuments({
|
||||
|
||||
const onboardingDocs = (() => {
|
||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||
const operationType = form.watch("operationType");
|
||||
const targetType = operationType
|
||||
? operationToProfileType(
|
||||
operationType,
|
||||
profiles.map((p) => p.type),
|
||||
)
|
||||
: null;
|
||||
const active =
|
||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
||||
(targetType ? profiles.find((p) => p.type === targetType) : undefined) ??
|
||||
profiles[0];
|
||||
return active?.licenseFiles ?? [];
|
||||
})();
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export function Step1ContractType({
|
||||
/** Approval state of the profile each operation maps to (for the badges). */
|
||||
operationStatus?: (
|
||||
op: OperationType,
|
||||
) => "approved" | "pending" | "rejected" | "missing";
|
||||
) => "approved" | "pending" | "rejected" | "suspended" | "missing";
|
||||
}) {
|
||||
const contractType = form.watch("contractType");
|
||||
|
||||
@@ -229,6 +229,11 @@ export function Step1ContractType({
|
||||
Rejected
|
||||
</Badge>
|
||||
)}
|
||||
{status === "suspended" && (
|
||||
<Badge size="xs" color="orange" variant="light" radius="sm">
|
||||
Suspended
|
||||
</Badge>
|
||||
)}
|
||||
{status === "missing" && (
|
||||
<Badge size="xs" color="gray" variant="light" radius="sm">
|
||||
Add license
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import { Building2, CheckCircle2, RefreshCw, Save, XCircle } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -87,6 +87,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Resubmit a rejected role for review. Flips it back to Pending server-side
|
||||
// and pings the backoffice, so the fix-and-resubmit loop can happen entirely
|
||||
// from settings instead of only from the contract page's rejection banner.
|
||||
const reapplyMutation = useMutation({
|
||||
mutationFn: (profileId: string) =>
|
||||
api.companies.reapplyProfile.call({ profileId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (selected.size === 0) return;
|
||||
mutation.mutate(Array.from(selected));
|
||||
@@ -113,6 +129,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
{options.map((opt) => {
|
||||
const existing = profileByType.get(opt.type);
|
||||
const view = existing ? roleStatusView(existing) : undefined;
|
||||
const rejected = existing?.status === "rejected";
|
||||
return (
|
||||
<RoleCard
|
||||
key={opt.type}
|
||||
@@ -124,6 +141,28 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
approved={view?.approved}
|
||||
lockedNote={view?.note}
|
||||
lockedNoteColor={view?.color}
|
||||
detail={
|
||||
(rejected || existing?.status === "suspended") &&
|
||||
existing?.reviewNote
|
||||
? `Reviewer note: ${existing.reviewNote}`
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
rejected ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={
|
||||
reapplyMutation.isPending &&
|
||||
reapplyMutation.variables === existing.id
|
||||
}
|
||||
onClick={() => reapplyMutation.mutate(existing.id)}
|
||||
>
|
||||
Resubmit for approval
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => toggle(opt.type)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,14 @@ export interface RoleCardProps {
|
||||
lockedNote?: string;
|
||||
/** Mantine color for {@link lockedNote}; matches the role's status. */
|
||||
lockedNoteColor?: string;
|
||||
/** Extra muted line under {@link lockedNote}, e.g. the reviewer's note. */
|
||||
detail?: string;
|
||||
/**
|
||||
* Interactive content (e.g. a resubmit button) rendered inside the card.
|
||||
* Only honoured on a locked card — the interactive variant is itself a
|
||||
* button, and buttons cannot nest.
|
||||
*/
|
||||
action?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -35,54 +43,70 @@ export default function RoleCard({
|
||||
approved = false,
|
||||
lockedNote,
|
||||
lockedNoteColor = "edr-green",
|
||||
detail,
|
||||
action,
|
||||
onClick,
|
||||
}: RoleCardProps) {
|
||||
const highlighted = selected || approved;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
onClick={locked ? undefined : onClick}
|
||||
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
||||
highlighted
|
||||
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
||||
: "border-edr-border! bg-edr-card!"
|
||||
} ${
|
||||
locked
|
||||
? "cursor-default"
|
||||
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
||||
}`}
|
||||
>
|
||||
<Group gap="md" wrap="nowrap" align="start">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant={highlighted ? "filled" : "light"}
|
||||
color="edr-green"
|
||||
className="shrink-0"
|
||||
>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fw={700} c="edr-text" fz={15}>
|
||||
{label}
|
||||
const className = `group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
||||
highlighted
|
||||
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
||||
: "border-edr-border! bg-edr-card!"
|
||||
} ${
|
||||
locked
|
||||
? "cursor-default"
|
||||
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
||||
}`;
|
||||
|
||||
const content = (
|
||||
<Group gap="md" wrap="nowrap" align="start">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant={highlighted ? "filled" : "light"}
|
||||
color="edr-green"
|
||||
className="shrink-0"
|
||||
>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text fw={700} c="edr-text" fz={15}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||
{description}
|
||||
</Text>
|
||||
{lockedNote && (
|
||||
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
||||
{lockedNote}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||
{description}
|
||||
</Text>
|
||||
{lockedNote && (
|
||||
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
||||
{lockedNote}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{highlighted && (
|
||||
<Check
|
||||
size={18}
|
||||
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{locked && detail && (
|
||||
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||
{detail}
|
||||
</Text>
|
||||
)}
|
||||
{locked && action && <Box mt="sm">{action}</Box>}
|
||||
</Box>
|
||||
{highlighted && (
|
||||
<Check
|
||||
size={18}
|
||||
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
// A locked card is display-only, so it renders as a plain box — which also
|
||||
// lets `action` hold real buttons without nesting them inside a button.
|
||||
if (locked) {
|
||||
return <Box className={className}>{content}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton type="button" onClick={onClick} className={className}>
|
||||
{content}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,12 +240,6 @@ export const api = {
|
||||
CompanyInfoResponse
|
||||
>("companies", "startOnboarding", companiesService.startOnboarding),
|
||||
|
||||
setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>(
|
||||
"companies",
|
||||
"setActiveMode",
|
||||
companiesService.setActiveMode,
|
||||
),
|
||||
|
||||
setOnboardingStep: endpoint<{ step: string }, void>(
|
||||
"companies",
|
||||
"setOnboardingStep",
|
||||
|
||||
@@ -45,10 +45,6 @@ export interface ExternalProfileResponse {
|
||||
nationalId: string | null;
|
||||
jobTitle: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
/** The active operational mode (importer/exporter/forwarder). */
|
||||
activeProfileType: ProfileTypeValue | null;
|
||||
/** Id of the company_profile matching activeProfileType (server-resolved). */
|
||||
activeCompanyProfileId: string | null;
|
||||
onboardingStep: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: string;
|
||||
@@ -323,17 +319,6 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Switch the active operational mode (target profile must already exist). */
|
||||
setActiveMode: async (payload: {
|
||||
type: ProfileTypeValue;
|
||||
}): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.patch<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
||||
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user