From d76d198a180e37c8b6a0a680331be6f752e1376d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 09:08:53 +0000 Subject: [PATCH 01/15] fix(companies): default customer list to review-queue ordering Marketing asked for approval requests to surface in order instead of the alphabetical default. New sortBy=review tiers the list by what needs action - submitted applications awaiting first approval, then approved customers with a pending change request, then everyone else (drafts included) - newest first within each tier. Exposed as the backoffice "Needs review first" sort option and made the default on both ends. EDRFREIGHT-232 --- .../modules/companies/companies.repository.ts | 28 +++++++++++++++++-- .../companies/dto/list-companies-query.dto.ts | 16 +++++++---- .../src/pages/customers/CustomersPage.tsx | 8 ++++-- .../backoffice/src/types/customer.ts | 3 +- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 3ac12b11a..db8db0d2e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository { AND ccr.deleted_at IS NULL )`; + /** + * The `sortBy = 'review'` queue ordering: whatever marketing must act on + * floats to the top. Tier 0 — submitted applications awaiting first approval + * (drafts excluded: nothing to review yet). Tier 1 — approved customers with + * a pending change request. Tier 2 — everyone else, drafts included. + */ + private static readonly REVIEW_TIER_SQL = `(CASE + WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0 + WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1 + ELSE 2 + END)`; + constructor( @InjectRepository(Company) repo: Repository, @@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository { status, onboardingCompleted, hasPendingChangeRequest, - sortBy = 'name', - sortOrder = 'ASC', + sortBy = 'review', + sortOrder = 'DESC', } = query; const qb = this.repository @@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository { } // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. + if (sortBy === 'review') { + // Queue ordering: actionable tiers first, newest first within each. The + // tier is selected under an alias because skip/take pagination with + // joins re-derives the ORDER BY in a subquery — a raw expression there + // breaks, a selected alias survives. + qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier') + .orderBy('review_tier', 'ASC') + .addOrderBy('company.createdAt', 'DESC'); + } else { + qb.orderBy(`company.${sortBy}`, sortOrder); + } const [items, total] = await qb - .orderBy(`company.${sortBy}`, sortOrder) // Names are not unique and createdAt can tie on bulk imports; the id // tiebreaker keeps paging stable instead of dropping/repeating rows. .addOrderBy('company.id', 'ASC') diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 8d4910ded..ffb600e36 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -60,15 +60,19 @@ export class ListCompaniesQueryDto { hasPendingChangeRequest?: boolean; @ApiPropertyOptional({ - enum: ["name", "createdAt", "updatedAt"], - default: "name", - description: "Column to order by. Defaults to name for backwards compatibility.", + enum: ["review", "name", "createdAt", "updatedAt"], + default: "review", + description: + "Column to order by. The default `review` is a review-queue ordering: " + + "companies awaiting first approval, then those with a pending change " + + "request, then everyone else — newest first within each group. The " + + "other values are plain column sorts.", }) @IsOptional() - @IsIn(["name", "createdAt", "updatedAt"]) - sortBy?: "name" | "createdAt" | "updatedAt"; + @IsIn(["review", "name", "createdAt", "updatedAt"]) + sortBy?: "review" | "name" | "createdAt" | "updatedAt"; - @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @IsIn(["ASC", "DESC"]) diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index d70e6d040..4c63dc09e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -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("all"); - const [sort, setSort] = useState("createdAt:DESC"); + const [sort, setSort] = useState("review:DESC"); const filter = useMemo(() => { const [sortBy, sortOrder] = sort.split(":") as [ - "name" | "createdAt" | "updatedAt", + "review" | "name" | "createdAt" | "updatedAt", "ASC" | "DESC", ]; return { diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 419e93d25..53d3daed8 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -207,7 +207,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"; } From 4f6a559ae331bd566881a3aabc5da5020f9f9eff Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 09:09:04 +0000 Subject: [PATCH 02/15] feat(portal): reviewer note and resubmit for rejected roles in settings A customer whose role was rejected saw only a bare "Rejected" chip in settings - the reviewer's note and the resubmit action existed solely inside the contract wizard's block modal, so fixing and reapplying from the profile page was impossible. The settings role card now shows the reviewer note (for suspended roles too) and offers "Resubmit for approval", which flips the role back to Pending and notifies the backoffice through the existing roleReapplied inbox event. RoleCard's locked variant now renders as a plain box instead of a button so it can host the action button (buttons cannot nest) and the new detail line. EDRFREIGHT-233 --- .../src/pages/settings/CompanyRolesCard.tsx | 41 ++++++- .../portal/src/pages/settings/RoleCard.tsx | 108 +++++++++++------- 2 files changed, 106 insertions(+), 43 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx index fc0407d5e..974af456e 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -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 ( } + loading={ + reapplyMutation.isPending && + reapplyMutation.variables === existing.id + } + onClick={() => reapplyMutation.mutate(existing.id)} + > + Resubmit for approval + + ) : undefined + } onClick={() => toggle(opt.type)} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx index 0a6b98dc4..0061d3318 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx @@ -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 ( - - - - {icon} - - - - {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 = ( + + + {icon} + + + + {label} + + + {description} + + {lockedNote && ( + + {lockedNote} - - {description} - - {lockedNote && ( - - {lockedNote} - - )} - - {highlighted && ( - )} - + {locked && detail && ( + + {detail} + + )} + {locked && action && {action}} + + {highlighted && ( + + )} + + ); + + // 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 {content}; + } + + return ( + + {content} ); } From c8f932f5d5603728a0a1008bfdd44d6a37c0daaf Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 09:09:15 +0000 Subject: [PATCH 03/15] feat(companies): require and deliver a staff message on suspend/reactivate Staff could suspend or reactivate a customer role with one silent click: no reason captured, nothing stored, and the customer was never told. The API now rejects a suspend or reactivate without a non-empty note, keeps the note in reviewNote while suspended, and sends the customer an SMS/email/in-app notification quoting the staff message. In the backoffice the reject-note modal is generalised into a decision modal shared by reject, suspend and reactivate, so all three force a message. EDRFREIGHT-188 --- .../modules/companies/companies.service.ts | 44 ++++++- .../companies/company-notifier.service.ts | 37 ++++++ .../src/components/customers/badges.tsx | 110 +++++++++++++----- 3 files changed, 159 insertions(+), 32 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index df0e14998..73e2fdc00 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1090,6 +1090,23 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // Suspension and reactivation must carry a staff explanation — the customer + // sees it, so "why" can never be left blank. Reactivation is the + // active-write that leaves Suspended; a first approval stays note-free. + const reactivating = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if ( + (status === ProfileStatus.Suspended || reactivating) && + !note?.trim() + ) { + throw new BadRequestException( + status === ProfileStatus.Suspended + ? "A message explaining the suspension is required — the customer will see it." + : "A message explaining the reactivation is required — the customer will see it.", + ); + } + // A self-registered company is only reviewable once its owner submits the // onboarding wizard (markOnboardingComplete) — until then its profiles are // half-filled drafts and approving one would mint a reference against an @@ -1176,9 +1193,13 @@ export class CompaniesService { ); } - // Track the review outcome. Rejection keeps the note so the customer knows - // why; approval clears it. Any decision stamps the reviewer + time. - if (status === ProfileStatus.Rejected) { + // Track the review outcome. Rejection and suspension keep the note so the + // customer knows why; approval/reactivation clears it. Any decision stamps + // the reviewer + time. + if ( + status === ProfileStatus.Rejected || + status === ProfileStatus.Suspended + ) { patch.reviewNote = note ?? null; } else if (status === ProfileStatus.Active) { patch.reviewNote = null; @@ -1192,6 +1213,23 @@ export class CompaniesService { if (!updated) throw new NotFoundException(`Company profile ${existing.id} not found`); + // Suspension and reactivation lock/unlock a role the customer relies on — + // tell them, and carry the staff message so they know why. + const reactivated = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if (status === ProfileStatus.Suspended || reactivated) { + const company = await this.companiesRepo.findById(updated.companyId); + if (company) { + this.companyNotifier.profileStatusChanged( + company, + updated.type, + status === ProfileStatus.Suspended ? "suspended" : "reactivated", + note ?? "", + ); + } + } + // Approving any profile promotes a pending company to active, so the // customer can start working as soon as their first profile is cleared. if (status === ProfileStatus.Active) { diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index f71a67976..d9aac8e45 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -89,6 +89,43 @@ export class CompanyNotifierService { }); } + /** + * Tell the customer one of their operational roles was suspended or + * reactivated, quoting the staff message — the service layer requires one for + * both transitions, so the customer always learns why, not just what. + */ + profileStatusChanged( + company: Company, + profileType: string, + change: "suspended" | "reactivated", + staffMessage: string, + ): void { + const title = `${profileType} role ${change}`; + const consequence = + change === "suspended" + ? `You will not be able to operate under this role until it is reactivated; ` + + `your other roles are unaffected.` + : `You can operate under this role again.`; + const body = + `Your company's ${profileType} role has been ${change}. ` + + `${consequence} Message from EDR staff: ${staffMessage}`; + + this.logger.log( + `PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`, + ); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link: "/settings", + data: { companyId: company.id, profileType, change, staffMessage }, + priority: NotificationPriority.HIGH, + }); + } + // ── Backoffice-facing: work has arrived back in the review queue ──────────── /** diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 6cb6759e7..04267cc7b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -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 && ( setRejectOpen(false)} - title="Reject profile" + opened + onClose={() => setDecision(null)} + title={active.title} centered radius="lg" > - Tell the customer what needs fixing. They'll see this note and can - amend and resubmit the role for approval. + {active.intro}