mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
feat: Stripe-style filter bar for freight backoffice (pilot: contracts)
Replace the ad-hoc filter controls with a URL-linkable pill filter bar: each filter is a pill that opens a type-aware popover (text/enum/date/ number/boolean, each with the right operator set), overflow filters live behind a searchable "More filters" menu, sorting is a separate control, and filter state round-trips through the URL query string (shareable, back/forward-safe, backward compatible with existing ?statuses=A,B links). Frontend (apps/edr-freight-web/backoffice/src/components/filters/): - FilterDef schema + a pure url.ts codec (parse/serialize/toApiParams), with a 24-case round-trip + malformed-input test suite - useFilters hook driving react-query params straight from useSearchParams, debounced search, saved views in localStorage (@mantine/hooks useLocalStorage), page-reset-on-filter-change baked into one setSearchParams call instead of a separate effect - FilterBar/FilterPill/OperatorSelect/MoreFiltersMenu/SortControl + per-type popover bodies (Mantine) - ContractRequestsPage migrated end to end as the pilot Backend (apps/edr-freight-api): - pagination.util: applySort() — whitelisted sortBy resolved against a per-module column map (never interpolated), with a mandatory `id ASC` tiebreaker so paginating a non-unique sort can't drop/duplicate rows - facets.util: computeFacets() — one GROUP BY per enum column, each omitting its own predicate, so picking a value doesn't hide its siblings - contracts/bookings: list-summary now returns real filter-scoped facet counts (contracts' getStatusCounts was unfiltered/global; superseded) - deleted drivers/vehicles findAllWithFilters — dead code that interpolated an unwhitelisted sortBy straight into orderBy() - migration: missing bookings(status)/wagons(status) indexes + (created_at DESC, id ASC) partials on the hot list tables UI polish pass: inactive pill uses the opaque "default" variant instead of a faint tinted outline, active pill uses "light" not "filled", larger X hit target, applied filters sort first, sort control separated behind a divider on the right and wraps independently from the filter row, popover option rows are fully clickable (count moved inside the native label) with bigger hit area and font, fixed a real date-filter bug where the calendar's own portal falsely registered as an "outside click" and closed the popover, and fixed a timezone bug where bare YYYY-MM-DD strings were parsed as UTC instead of local time (shifts a day for EAT). Not in this commit: rollout to the other ~59 list pages, the Ethiopian- calendar DateBody branch, and the Family-B (client-side) bridge mode — tracked in the filter-bar plan.
This commit is contained in:
@@ -41,4 +41,16 @@ export class PaginationQueryDto {
|
||||
@Transform(({ value }) => String(value).toUpperCase())
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* Column to sort by, as a public field name (not a raw SQL column). The
|
||||
* actual whitelist lives in `applySort`'s `sortable` map at each call site,
|
||||
* not here — a per-DTO `@IsIn` is opt-in and has been forgotten before.
|
||||
* An unrecognized value falls back silently rather than 400ing, so a stale
|
||||
* bookmark or shared link never breaks.
|
||||
*/
|
||||
@ApiPropertyOptional({ description: 'Public field name; unknown values fall back to the endpoint default.' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : undefined))
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
48
apps/edr-freight-api/src/common/utils/facets.util.ts
Normal file
48
apps/edr-freight-api/src/common/utils/facets.util.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export interface FacetBucket {
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One `GROUP BY` query per faceted column, each with every OTHER active
|
||||
* filter applied but its OWN predicate omitted. That omission is the point:
|
||||
* with `status=SUBMITTED` selected, the status facet still reports
|
||||
* `APPROVED: 8` so the user can switch, while the freightType facet reflects
|
||||
* only the SUBMITTED-scoped set. Omit `search` from nothing — it's a scope,
|
||||
* not a pill, and stays applied in every facet.
|
||||
*
|
||||
* Capped at 50 buckets per column — FK-id facets (warehouseId, yardId) can
|
||||
* have real cardinality; beyond 50 the frontend should fall back to a
|
||||
* typeahead instead of a checkbox list. Never facet a column whose popover
|
||||
* would need its own search box (references, plate numbers, free text).
|
||||
*
|
||||
* @param base builds a FRESH query builder (soft-delete guard only,
|
||||
* no filters) — called once per facet column.
|
||||
* @param applyFilters applies every filter to `qb`, using `omit` to skip
|
||||
* one column's own predicate.
|
||||
* @param columns facet key -> "alias.column" SQL reference.
|
||||
*/
|
||||
export async function computeFacets<T extends ObjectLiteral>(
|
||||
base: () => SelectQueryBuilder<T>,
|
||||
applyFilters: (qb: SelectQueryBuilder<T>, omit?: string) => void,
|
||||
columns: Record<string, string>,
|
||||
): Promise<Record<string, FacetBucket[]>> {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(columns).map(async ([key, column]) => {
|
||||
const qb = base();
|
||||
applyFilters(qb, key);
|
||||
const rows = await qb
|
||||
.select(column, 'value')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.andWhere(`${column} IS NOT NULL`)
|
||||
.groupBy(column)
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(50)
|
||||
.getRawMany<{ value: string; count: number }>();
|
||||
return [key, rows.map((r) => ({ value: String(r.value), count: Number(r.count) }))] as const;
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
import { applySort, buildPaginationMeta, normalizePagination } from './pagination.util';
|
||||
|
||||
/** Minimal fake — just enough of the SelectQueryBuilder chain applySort touches. */
|
||||
function fakeQb() {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = [];
|
||||
const qb = {
|
||||
alias: 'contract',
|
||||
orderBy(...args: unknown[]) {
|
||||
calls.push({ method: 'orderBy', args });
|
||||
return qb;
|
||||
},
|
||||
addOrderBy(...args: unknown[]) {
|
||||
calls.push({ method: 'addOrderBy', args });
|
||||
return qb;
|
||||
},
|
||||
};
|
||||
return { qb: qb as unknown as SelectQueryBuilder<any>, calls };
|
||||
}
|
||||
|
||||
const SORTABLE = {
|
||||
createdAt: 'contract.createdAt',
|
||||
contractValidUntil: 'contract.contractValidUntil',
|
||||
};
|
||||
|
||||
describe('applySort', () => {
|
||||
it('resolves a whitelisted sortBy to its column', () => {
|
||||
const { qb, calls } = fakeQb();
|
||||
applySort(qb, { sortBy: 'contractValidUntil', sortOrder: 'ASC' }, SORTABLE, 'createdAt');
|
||||
expect(calls[0]).toEqual({
|
||||
method: 'orderBy',
|
||||
args: ['contract.contractValidUntil', 'ASC'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default column for an unknown sortBy instead of throwing', () => {
|
||||
const { qb, calls } = fakeQb();
|
||||
// A stale bookmark or shared link naming a removed/renamed column must
|
||||
// never 400 — it should silently behave as if sortBy were absent.
|
||||
expect(() =>
|
||||
applySort(qb, { sortBy: "id; DROP TABLE contracts; --" }, SORTABLE, 'createdAt'),
|
||||
).not.toThrow();
|
||||
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
|
||||
});
|
||||
|
||||
it('defaults sortOrder to DESC when absent or not ASC', () => {
|
||||
const { qb, calls } = fakeQb();
|
||||
applySort(qb, {}, SORTABLE, 'createdAt');
|
||||
expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] });
|
||||
});
|
||||
|
||||
it('always appends an id ASC tiebreaker', () => {
|
||||
const { qb, calls } = fakeQb();
|
||||
applySort(qb, { sortBy: 'createdAt' }, SORTABLE, 'createdAt');
|
||||
expect(calls[1]).toEqual({ method: 'addOrderBy', args: ['contract.id', 'ASC'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePagination / buildPaginationMeta', () => {
|
||||
it('clamps page to >= 1 and pageSize to the configured max', () => {
|
||||
const p = normalizePagination({ page: 0, pageSize: 999 }, { maxPageSize: 100 });
|
||||
expect(p).toEqual({ page: 1, pageSize: 100, skip: 0, take: 100 });
|
||||
});
|
||||
|
||||
it('computes hasNextPage/hasPreviousPage from total', () => {
|
||||
const meta = buildPaginationMeta(45, 2, 20);
|
||||
expect(meta).toEqual({
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
total: 45,
|
||||
totalPages: 3,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,3 +83,32 @@ export function paginateArray<T>(
|
||||
meta: buildPaginationMeta(rows.length, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `ORDER BY` from a query DTO's `sortBy`/`sortOrder`, resolved against a
|
||||
* whitelist — never interpolate `sortBy` into a query builder directly, it is
|
||||
* unvalidated user input and an unwhitelisted `orderBy(\`alias.${sortBy}\`)`
|
||||
* is a SQL-injection primitive (see the deleted `findAllWithFilters` methods
|
||||
* on drivers/vehicles repositories, which had exactly that bug).
|
||||
*
|
||||
* An unknown `sortBy` falls back to `fallback` instead of throwing — a stale
|
||||
* bookmark or shared link should never 400.
|
||||
*
|
||||
* Always appends `id ASC` as a tiebreaker: sorting by a non-unique column
|
||||
* (status, createdAt on bulk-imported rows) without one can drop or
|
||||
* duplicate rows across pages once LIMIT/OFFSET is involved.
|
||||
*
|
||||
* @param sortable public sort key -> "alias.column" SQL reference. Also
|
||||
* doubles as the Swagger enum / frontend's sortable-column list.
|
||||
* @param fallback a key that must exist in `sortable`.
|
||||
*/
|
||||
export function applySort<T extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<T>,
|
||||
query: { sortBy?: string; sortOrder?: 'ASC' | 'DESC' },
|
||||
sortable: Record<string, string>,
|
||||
fallback: string,
|
||||
): SelectQueryBuilder<T> {
|
||||
const column = (query.sortBy && sortable[query.sortBy]) || sortable[fallback];
|
||||
qb.orderBy(column, query.sortOrder === 'ASC' ? 'ASC' : 'DESC');
|
||||
return qb.addOrderBy(`${qb.alias}.id`, 'ASC');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Supports the Stripe-style pill filter bar: every list it lands on filters
|
||||
* and sorts server-side now. `@Index` decorators alone do nothing —
|
||||
* `synchronize: false` means an index exists only if a migration created it
|
||||
* (see the `RepairSynchronizeDrift`-style gaps this closes).
|
||||
*
|
||||
* `idx_warehouse_inventory_status` already exists (FreightBaseline). Bookings
|
||||
* and wagons have no plain `status` index — `idx_bookings_route_day` and
|
||||
* `idx_wagons_readiness` only cover `status` as a trailing/partial column,
|
||||
* not a standalone `WHERE status = $1`, and `status` is the single
|
||||
* most-filtered column on both lists (bookings: 37 values).
|
||||
*
|
||||
* `(created_at DESC, id ASC)` partials match the default sort + id
|
||||
* tiebreaker `applySort` now appends everywhere, and none of these tables
|
||||
* had a created_at index at all.
|
||||
*/
|
||||
export class FilterableListIndexes3540000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_status
|
||||
ON freight.bookings USING btree (status)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_status
|
||||
ON freight.wagons USING btree (status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_contracts_created_at_id
|
||||
ON freight.contracts (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_created_at_id
|
||||
ON freight.bookings (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_created_at_id
|
||||
ON freight.warehouse_inventory (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_created_at_id
|
||||
ON freight.wagons (created_at DESC, id ASC) WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_created_at_id`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_created_at_id`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_created_at_id`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_contracts_created_at_id`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_status`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_status`);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
|
||||
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
@@ -857,6 +858,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
|
||||
* column, each with every OTHER active filter applied but its own
|
||||
* predicate omitted (see `applyListFilters`'s `omit` param). `bookingType`
|
||||
* is derived from `contract_kind` (see the comment in `applyListFilters`),
|
||||
* not a plain column, so it facets on the same CASE expression the filter
|
||||
* itself applies.
|
||||
*/
|
||||
async getFacets(options: BookingListFilterOptions): Promise<Record<string, FacetBucket[]>> {
|
||||
return computeFacets(
|
||||
() => this.repository.createQueryBuilder('booking').where('booking.deleted_at IS NULL'),
|
||||
(qb, omit) => this.applyListFilters(qb, options, omit as keyof BookingListFilterOptions),
|
||||
{
|
||||
status: 'booking.status',
|
||||
freightType: 'booking.freight_type',
|
||||
tradeDirection: 'booking.trade_direction',
|
||||
paymentStatus: 'booking.payment_status',
|
||||
bookingType:
|
||||
"CASE WHEN booking.contract_kind = 'GENERAL' THEN 'GENERAL_CONTRACT' ELSE 'ONE_TIME' END",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('booking')
|
||||
@@ -915,16 +939,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return { inQueue, onThisPage, needsAction, urgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param omit skip this one predicate — used by `getFacets` so a facet's
|
||||
* own filter doesn't hide its own sibling values. Every other caller
|
||||
* (list, summary metrics) passes nothing.
|
||||
*/
|
||||
private applyListFilters(
|
||||
qb: SelectQueryBuilder<Booking>,
|
||||
options: BookingListFilterOptions,
|
||||
omit?: keyof BookingListFilterOptions | 'status',
|
||||
): void {
|
||||
if (options.statuses?.length) {
|
||||
qb.andWhere('booking.status IN (:...statuses)', {
|
||||
statuses: options.statuses,
|
||||
});
|
||||
} else if (options.status) {
|
||||
qb.andWhere('booking.status = :status', { status: options.status });
|
||||
if (omit !== 'status') {
|
||||
if (options.statuses?.length) {
|
||||
qb.andWhere('booking.status IN (:...statuses)', {
|
||||
statuses: options.statuses,
|
||||
});
|
||||
} else if (options.status) {
|
||||
qb.andWhere('booking.status = :status', { status: options.status });
|
||||
}
|
||||
}
|
||||
|
||||
if (options.companyId) {
|
||||
@@ -957,12 +989,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
cargoTypeId: options.cargoTypeId,
|
||||
});
|
||||
}
|
||||
if (options.freightType) {
|
||||
if (omit !== 'freightType' && options.freightType) {
|
||||
qb.andWhere('booking.freight_type = :freightType', {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
if (omit !== 'bookingType' && options.bookingType) {
|
||||
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
||||
// drawdowns included — see contract-booking.service create), so the
|
||||
// one-time vs general split keys on the denormalized contract_kind:
|
||||
@@ -1011,12 +1043,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} else if (options.isGovernment === 'false') {
|
||||
qb.andWhere('booking.is_government = FALSE');
|
||||
}
|
||||
if (options.tradeDirection) {
|
||||
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirections) {
|
||||
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
||||
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
|
||||
}
|
||||
if (options.paymentCurrency) {
|
||||
@@ -1024,7 +1056,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
});
|
||||
}
|
||||
if (options.paymentStatus) {
|
||||
if (omit !== 'paymentStatus' && options.paymentStatus) {
|
||||
qb.andWhere('booking.payment_status = :paymentStatus', {
|
||||
paymentStatus: options.paymentStatus,
|
||||
});
|
||||
|
||||
@@ -2051,8 +2051,9 @@ export class BookingsService {
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
};
|
||||
|
||||
const [statusCounts, metrics] = await Promise.all([
|
||||
const [statusCounts, facets, metrics] = await Promise.all([
|
||||
this.bookingsRepository.getStatusCounts(),
|
||||
this.bookingsRepository.getFacets(listFilter),
|
||||
this.bookingsRepository.getListSummaryMetrics({
|
||||
...listFilter,
|
||||
page,
|
||||
@@ -2064,7 +2065,9 @@ export class BookingsService {
|
||||
|
||||
return {
|
||||
metrics,
|
||||
// Tabs stay unfiltered (whole-set) on purpose — see the DTO comment.
|
||||
tabs: mapStatusCountsToTabs(statusCounts),
|
||||
facets,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { FacetBucket } from '../../../common/utils/facets.util';
|
||||
|
||||
export class BookingListSummaryMetricsDto {
|
||||
@ApiProperty({ example: 42 })
|
||||
@@ -31,4 +32,18 @@ export class BookingListSummaryDto {
|
||||
|
||||
@ApiProperty({ type: BookingListSummaryTabsDto })
|
||||
tabs!: BookingListSummaryTabsDto;
|
||||
|
||||
/**
|
||||
* Per-column value counts for the filter bar's enum popovers, scoped to
|
||||
* every OTHER currently-active filter (own predicate omitted per column —
|
||||
* see `BookingsRepository.getFacets`). Unlike `tabs`, which is
|
||||
* deliberately unfiltered so tab counts stay stable while you filter
|
||||
* within a tab, these move with the filter set.
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'Facet counts keyed by filter field, for the pill filter bar',
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'array', items: { type: 'object' } },
|
||||
})
|
||||
facets!: Record<string, FacetBucket[]>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
@@ -416,14 +417,22 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
return { inQueue, onThisPage, needsAction };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param omit skip this one predicate — used by `getFacets` so a facet's
|
||||
* own filter doesn't hide its own sibling values (see class doc on
|
||||
* `getFacets`). Every other list/summary/count caller passes nothing.
|
||||
*/
|
||||
private applyListFilters(
|
||||
qb: SelectQueryBuilder<Contract>,
|
||||
options: ContractListFilterOptions,
|
||||
omit?: keyof ContractListFilterOptions | 'status',
|
||||
): void {
|
||||
if (options.statuses?.length) {
|
||||
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
||||
} else if (options.status) {
|
||||
qb.andWhere('contract.status = :status', { status: options.status });
|
||||
if (omit !== 'status') {
|
||||
if (options.statuses?.length) {
|
||||
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
||||
} else if (options.status) {
|
||||
qb.andWhere('contract.status = :status', { status: options.status });
|
||||
}
|
||||
}
|
||||
if (options.companyId) {
|
||||
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
||||
@@ -433,7 +442,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
companyProfileId: options.companyProfileId,
|
||||
});
|
||||
}
|
||||
if (options.contractKind) {
|
||||
if (omit !== 'contractKind' && options.contractKind) {
|
||||
qb.andWhere('contract.contract_kind = :contractKind', {
|
||||
contractKind: options.contractKind,
|
||||
});
|
||||
@@ -454,20 +463,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
});
|
||||
}
|
||||
if (options.freightType) {
|
||||
if (omit !== 'freightType' && options.freightType) {
|
||||
qb.andWhere('contract.freight_type = :freightType', {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirection) {
|
||||
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
||||
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirections) {
|
||||
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
||||
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
||||
}
|
||||
if (options.paymentCurrency) {
|
||||
if (omit !== 'paymentCurrency' && options.paymentCurrency) {
|
||||
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
});
|
||||
@@ -482,6 +491,28 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
|
||||
* column, each with every OTHER active filter applied but its own
|
||||
* predicate omitted — so selecting `status=SUBMITTED` still shows
|
||||
* `APPROVED: 8` in the status popover (to switch), while the freightType
|
||||
* popover reflects only the SUBMITTED-scoped set. Supersedes
|
||||
* `getStatusCounts`, which ignores the active filter entirely.
|
||||
*/
|
||||
async getFacets(options: ContractListFilterOptions): Promise<Record<string, FacetBucket[]>> {
|
||||
return computeFacets(
|
||||
() => this.repository.createQueryBuilder('contract').where('contract.deleted_at IS NULL'),
|
||||
(qb, omit) => this.applyListFilters(qb, options, omit as keyof ContractListFilterOptions),
|
||||
{
|
||||
status: 'contract.status',
|
||||
contractKind: 'contract.contract_kind',
|
||||
freightType: 'contract.freight_type',
|
||||
tradeDirection: 'contract.trade_direction',
|
||||
paymentCurrency: 'contract.payment_currency',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Approval steps ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Lowest-order pending approval step (sequential enforcement). */
|
||||
|
||||
@@ -791,8 +791,8 @@ export class ContractsService {
|
||||
createdTo: filter.createdTo,
|
||||
};
|
||||
|
||||
const [statusCounts, metrics] = await Promise.all([
|
||||
this.contractsRepository.getStatusCounts(),
|
||||
const [facets, metrics] = await Promise.all([
|
||||
this.contractsRepository.getFacets(listFilter),
|
||||
this.contractsRepository.getListSummaryMetrics({
|
||||
...listFilter,
|
||||
page,
|
||||
@@ -801,7 +801,13 @@ export class ContractsService {
|
||||
}),
|
||||
]);
|
||||
|
||||
return { metrics, statusCounts };
|
||||
// statusCounts kept for existing callers; now filter-scoped like every
|
||||
// other facet instead of the unfiltered global count `getStatusCounts` gave.
|
||||
const statusCounts = Object.fromEntries(
|
||||
(facets.status ?? []).map((b) => [b.value, b.count]),
|
||||
);
|
||||
|
||||
return { metrics, statusCounts, facets };
|
||||
}
|
||||
|
||||
/** Get a single contract by ID with relations and signed file URLs. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { FacetBucket } from '../../../common/utils/facets.util';
|
||||
|
||||
export class ContractListSummaryMetricsDto {
|
||||
@ApiProperty({ example: 42 })
|
||||
@@ -15,6 +16,23 @@ export class ContractListSummaryDto {
|
||||
@ApiProperty({ type: ContractListSummaryMetricsDto })
|
||||
metrics!: ContractListSummaryMetricsDto;
|
||||
|
||||
/** @deprecated use `facets.status` — kept for existing callers, computed
|
||||
* from the same filter-scoped query now instead of `getStatusCounts`'s
|
||||
* unfiltered global count. */
|
||||
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
|
||||
statusCounts!: Record<string, number>;
|
||||
|
||||
/**
|
||||
* Per-column value counts for the filter bar's enum popovers, scoped to
|
||||
* every OTHER currently-active filter (each column's own predicate is
|
||||
* omitted from its own count — see `ContractsRepository.getFacets`).
|
||||
* Absent/omitted keys mean the frontend falls back to its static option
|
||||
* list with no counts, never an error.
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'Facet counts keyed by filter field, for the pill filter bar',
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'array', items: { type: 'object' } },
|
||||
})
|
||||
facets!: Record<string, FacetBucket[]>;
|
||||
}
|
||||
|
||||
@@ -29,52 +29,6 @@ export class DriversRepository extends BaseRepository<Driver> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findAllWithFilters(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 10;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
let queryBuilder = this.repository.createQueryBuilder('driver');
|
||||
|
||||
if (query.search) {
|
||||
queryBuilder = queryBuilder.where(
|
||||
'(driver.firstName ILIKE :search OR driver.lastName ILIKE :search OR driver.email ILIKE :search OR driver.phoneNumber ILIKE :search OR driver.licenseNumber ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
queryBuilder = queryBuilder.andWhere('driver.status = :status', {
|
||||
status: query.status,
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = query.sortBy || 'createdAt';
|
||||
const sortOrder = query.sortOrder || 'DESC';
|
||||
|
||||
queryBuilder = queryBuilder
|
||||
.orderBy(`driver.${sortBy}`, sortOrder)
|
||||
.skip(skip)
|
||||
.take(pageSize);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
async createDriver(driverData: any): Promise<Driver> {
|
||||
const driver = this.repository.create(driverData);
|
||||
const result = await this.repository.save(driver);
|
||||
|
||||
@@ -21,52 +21,6 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findAllWithFilters(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 10;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
let queryBuilder = this.repository.createQueryBuilder('vehicle');
|
||||
|
||||
if (query.search) {
|
||||
queryBuilder = queryBuilder.where(
|
||||
'(vehicle.plateNumber ILIKE :search OR vehicle.manufacturer ILIKE :search OR vehicle.model ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
queryBuilder = queryBuilder.andWhere('vehicle.status = :status', {
|
||||
status: query.status,
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = query.sortBy || 'createdAt';
|
||||
const sortOrder = query.sortOrder || 'DESC';
|
||||
|
||||
queryBuilder = queryBuilder
|
||||
.orderBy(`vehicle.${sortBy}`, sortOrder)
|
||||
.skip(skip)
|
||||
.take(pageSize);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
|
||||
const vehicle = this.repository.create(vehicleData);
|
||||
return this.repository.save(vehicle);
|
||||
|
||||
Reference in New Issue
Block a user