chore: rm the active company profile

This commit is contained in:
Nathnael
2026-07-22 06:43:05 +00:00
parent d95af0ea72
commit 82cb5508cc
23 changed files with 440 additions and 351 deletions

View File

@@ -1,6 +1,8 @@
import {
Alert,
AppShell,
Avatar,
Badge,
Box,
Button,
Divider,
@@ -19,6 +21,7 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
Ban,
ChevronDown,
FileSignature,
LogOut,
@@ -201,29 +204,49 @@ export function AppLayout({
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);
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 service; null while creating a new one.
// 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 openServiceModal = (
type: ServiceType,
profileId: string | null,
profile?: { id?: string; status?: string; reviewNote?: string | null },
) => {
setCreateTarget(type);
setReapplyId(profileId);
setReapplyId(profile?.id ?? null);
setReapplyStatus(profile?.status ?? null);
setReapplyNote(profile?.reviewNote ?? null);
setLicenseFiles([]);
setCreateError(null);
setCreateOpen(true);
};
const handleAddService = (type: ServiceType) => openServiceModal(type, null);
const handleAddService = (type: ServiceType) => openServiceModal(type);
const handleCreateConfirm = async () => {
const isReapply = reapplyId !== null;
@@ -250,6 +273,7 @@ export function AppLayout({
};
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
const isSuspendedAppeal = reapplyStatus === "suspended";
const isItemActive = (item: SidebarItem) =>
activePath === item.href.toLowerCase() ||
@@ -372,7 +396,7 @@ export function AppLayout({
key={p.id}
color="red"
onClick={() =>
openServiceModal(p.type as ServiceType, p.id!)
openServiceModal(p.type as ServiceType, p)
}
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
>
@@ -381,6 +405,30 @@ export function AppLayout({
))}
</>
)}
{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>
)}
@@ -849,25 +897,39 @@ export function AppLayout({
opened={createOpen}
onClose={() => (switching ? undefined : setCreateOpen(false))}
title={
reapplyId
? `Resubmit your ${serviceLabel(createTarget)} service`
: `Set up your ${serviceLabel(createTarget)} profile`
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">
{reapplyId
{isSuspendedAppeal
? `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()}.`}
).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={reapplyId ? "Business license (optional)" : "Business license"}
multiple
@@ -892,7 +954,11 @@ export function AppLayout({
onClick={handleCreateConfirm}
loading={switching}
>
{reapplyId ? "Resubmit" : "Create & switch"}
{isSuspendedAppeal
? "Submit appeal"
: reapplyId
? "Resubmit"
: "Create"}
</Button>
</Group>
</Stack>

View File

@@ -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}>

View File

@@ -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",

View File

@@ -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.
@@ -276,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,
@@ -288,7 +272,6 @@ const useAuth = () => {
isUnderReview,
onboardingCompleted,
onboardingStep,
switchMode,
createProfile,
reapplyProfile,
login,

View File

@@ -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,
]);
}

View File

@@ -2,6 +2,7 @@ import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import {
Alert,
Anchor,
Badge,
Box,
Button,
@@ -27,6 +28,7 @@ import {
Building2,
Clock,
FileCheck,
FileText,
Globe,
Layers,
RefreshCw,
@@ -37,6 +39,8 @@ import {
UserCog,
} from "lucide-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";
@@ -300,8 +304,6 @@ export default function SettingsPage() {
</Alert>
)}
<OperationalServicesCard profile={profile} />
<Tabs
value={tab}
onChange={(value) => value && setTab(value as SettingsTab)}
@@ -354,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}>
@@ -404,6 +407,7 @@ const ROLE_STATUS: Record<string, { color: string; label: string }> = {
*/
function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
const roles = profile.companyProfiles;
const refresh = () =>
@@ -431,7 +435,8 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
if (roles.length === 0) return null;
return (
<Card padding="lg" radius="lg">
<>
<Card padding="lg" radius="lg" mt="lg">
<Group gap="sm" mb="md">
<Layers size={20} />
<Title order={3}>Operational Services</Title>
@@ -465,10 +470,62 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
</Text>
)}
</Group>
{r.status === "rejected" && r.reviewNote && (
<Text size="sm" c="red.7">
<strong>Reviewer note:</strong> {r.reviewNote}
{(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>
@@ -484,7 +541,9 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
);
})}
</Stack>
</Card>
</Card>
{viewer}
</>
);
}

View File

@@ -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 && (

View File

@@ -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)) ??
profiles[0];
return active?.licenseFiles ?? [];
})();

View File

@@ -49,6 +49,7 @@ import {
} from "./new-contract-form/schema";
import {
getRouteDirection,
isForwarderOperation,
operationToProfileType,
operationToTradeDirection,
} from "./new-contract-form/helpers";
@@ -469,10 +470,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,10 +511,14 @@ export default function NewContractPage({
const onboardingDocs = useMemo(() => {
const profiles = auth.company?.company?.companyProfiles ?? [];
const targetType = operationType
? operationToProfileType(operationType, profileTypes)
: null;
const active =
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
(targetType && profiles.find((p) => p.type === targetType)) ??
profiles[0];
return active?.licenseFiles ?? [];
}, [auth.company, auth.activeCompanyProfileId]);
}, [auth.company, operationType, profileTypes]);
async function handleContinue() {
const fields = contractStepFields[step];
@@ -569,7 +572,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,

View File

@@ -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)) ??
profiles[0];
return active?.licenseFiles ?? [];
})();

View File

@@ -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",

View File

@@ -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);
},