feat(backoffice): search customers by trade name and licence, filter by role

Staff search with whatever is in front of them. Company name, TIN, email
and profile reference already matched; a TIN's licence number and the
trade name of the business a role operates as did not, which is most of
what appears on a customer's own paperwork.

Adds a Role filter alongside it. Distinct from the existing Type pill:
that is the company's own kind, this asks "who does X?" — one `customer`
company routinely holds importer and exporter at once.

Both are EXISTS subqueries rather than constraints on the joined
`companyProfiles` alias. Filtering the join would drop the company's
other profiles from the loaded entity, so an importer-and-exporter would
render as importer-only.
This commit is contained in:
Nathnael
2026-08-27 09:30:12 +00:00
parent a48d77aff8
commit f8e8897f5c
4 changed files with 65 additions and 2 deletions

View File

@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
createdTo,
onboardingCompleted,
hasPendingChangeRequest,
profileType,
sortBy = 'review',
sortOrder = 'DESC',
} = query;
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
if (search) {
const term = `%${search.trim()}%`;
// Staff search by whatever is in front of them: the company name, the
// TIN/email, a profile reference off a document — and, since a TIN holds
// many licences, the trade name or licence number of the specific
// business a role operates as. All the per-profile terms share one EXISTS
// so a match on any of them qualifies the company once.
qb.andWhere(
`(company.name ILIKE :term
OR company.tin ILIKE :term
OR company.email ILIKE :term
OR company.licence_number ILIKE :term
OR EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = company.id
AND cp.reference ILIKE :term
AND cp.deleted_at IS NULL
AND (
cp.reference ILIKE :term
OR cp.etrade_business->>'tradeName' ILIKE :term
OR cp.etrade_business->>'licenceNumber' ILIKE :term
)
))`,
{ term },
);
}
// Companies holding a given operational role. EXISTS rather than a filter
// on the joined `companyProfiles` alias: constraining the join would drop
// the company's OTHER profiles from the loaded entity, so the list would
// render an exporter-and-importer as importer-only.
if (profileType) {
qb.andWhere(
`EXISTS (
SELECT 1 FROM freight.company_profiles cp_type
WHERE cp_type.company_id = company.id
AND cp_type.deleted_at IS NULL
AND cp_type.type = :profileType
)`,
{ profileType },
);
}
// 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

View File

@@ -15,6 +15,7 @@ import {
CompanyStatus,
CompanyType,
} from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyNationality))
nationality?: CompanyNationality;
@ApiPropertyOptional({
enum: ProfileType,
description:
"Only companies holding this operational role. A company may hold " +
"several; its other roles are still returned on the row.",
})
@IsOptional()
@IsIn(Object.values(ProfileType))
profileType?: ProfileType;
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
@IsOptional()
@IsDateString()

View File

@@ -108,6 +108,24 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
// The operational role, not `type` above: one `customer` company routinely
// holds importer AND exporter, so this asks "who does X?" rather than
// "what kind of company is this?".
key: "profileType",
label: "Role",
type: "enum",
multiple: false,
options: (
[
"importer",
"exporter",
"freight_forwarder",
"dj_freight_forwarder",
"transporter",
] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
key: "kind",
label: "Sector",
@@ -348,7 +366,7 @@ export default function CustomersPage() {
<FilterBar
defs={CUSTOMER_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by company, TIN, email or profile reference…"
searchPlaceholder="Search by company, trade name, TIN, email, licence no. or profile ref…"
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
viewId="customers"
>

View File

@@ -325,6 +325,13 @@ export interface CompanyListFilter {
kind?: CompanyKind;
status?: CompanyStatus;
nationality?: CompanyNationality;
/**
* Only companies holding this operational role. Distinct from `type`, which
* is the company's own kind — a `customer` company can hold importer,
* exporter and forwarder roles at once, and its other roles still come back
* on the row.
*/
profileType?: ProfileType;
/** ISO instants — inclusive bounds on the registration date. */
createdFrom?: string;
createdTo?: string;