From f8e8897f5c4adeb485b7e11f498b1f0f15205e69 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 27 Aug 2026 09:30:12 +0000 Subject: [PATCH] feat(backoffice): search customers by trade name and licence, filter by role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../modules/companies/companies.repository.ts | 29 ++++++++++++++++++- .../companies/dto/list-companies-query.dto.ts | 11 +++++++ .../src/pages/customers/CustomersPage.tsx | 20 ++++++++++++- .../backoffice/src/types/customer.ts | 7 +++++ 4 files changed, 65 insertions(+), 2 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 17e3631e0..673e0fe64 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository { createdTo, onboardingCompleted, hasPendingChangeRequest, + profileType, sortBy = 'review', sortOrder = 'DESC', } = query; @@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository { 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 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 18b816a2a..33a5f4131 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 @@ -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() 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 72eb38fe6..ef57313f6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -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() { ({ ...o }))} viewId="customers" > diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index e45112ecd..db5da275e 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -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;