mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
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:
@@ -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')
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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 (A–Z)" },
|
{ value: "name:ASC", label: "Name (A–Z)" },
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user