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
This commit is contained in:
Nathnael
2026-07-21 09:08:53 +00:00
parent af171974f8
commit d76d198a18
4 changed files with 43 additions and 12 deletions

View File

@@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
AND ccr.deleted_at IS NULL 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( constructor(
@InjectRepository(Company) @InjectRepository(Company)
repo: Repository<Company>, repo: Repository<Company>,
@@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
status, status,
onboardingCompleted, onboardingCompleted,
hasPendingChangeRequest, hasPendingChangeRequest,
sortBy = 'name', sortBy = 'review',
sortOrder = 'ASC', sortOrder = 'DESC',
} = query; } = query;
const qb = this.repository const qb = this.repository
@@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
} }
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. // 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 const [items, total] = await qb
.orderBy(`company.${sortBy}`, sortOrder)
// Names are not unique and createdAt can tie on bulk imports; the id // Names are not unique and createdAt can tie on bulk imports; the id
// tiebreaker keeps paging stable instead of dropping/repeating rows. // tiebreaker keeps paging stable instead of dropping/repeating rows.
.addOrderBy('company.id', 'ASC') .addOrderBy('company.id', 'ASC')

View File

@@ -60,15 +60,19 @@ export class ListCompaniesQueryDto {
hasPendingChangeRequest?: boolean; hasPendingChangeRequest?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: ["name", "createdAt", "updatedAt"], enum: ["review", "name", "createdAt", "updatedAt"],
default: "name", default: "review",
description: "Column to order by. Defaults to name for backwards compatibility.", 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() @IsOptional()
@IsIn(["name", "createdAt", "updatedAt"]) @IsIn(["review", "name", "createdAt", "updatedAt"])
sortBy?: "name" | "createdAt" | "updatedAt"; sortBy?: "review" | "name" | "createdAt" | "updatedAt";
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@IsOptional() @IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["ASC", "DESC"]) @IsIn(["ASC", "DESC"])

View File

@@ -81,6 +81,10 @@ const VIEW_FILTERS: Record<
}; };
const SORT_OPTIONS = [ 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:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" }, { value: "createdAt:ASC", label: "Oldest first" },
{ value: "name:ASC", label: "Name (AZ)" }, { value: "name:ASC", label: "Name (AZ)" },
@@ -93,11 +97,11 @@ export default function CustomersPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300); const [debouncedQuery] = useDebouncedValue(query, 300);
const [view, setView] = useState<CustomerView>("all"); const [view, setView] = useState<CustomerView>("all");
const [sort, setSort] = useState<string>("createdAt:DESC"); const [sort, setSort] = useState<string>("review:DESC");
const filter = useMemo(() => { const filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [ const [sortBy, sortOrder] = sort.split(":") as [
"name" | "createdAt" | "updatedAt", "review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC", "ASC" | "DESC",
]; ];
return { return {

View File

@@ -207,7 +207,8 @@ export interface CompanyListFilter {
* already `active`, so `status` alone can never surface them. * already `active`, so `status` alone can never surface them.
*/ */
hasPendingChangeRequest?: boolean; 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"; sortOrder?: "ASC" | "DESC";
} }