mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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())
|
@Transform(({ value }) => String(value).toUpperCase())
|
||||||
@IsIn(['ASC', 'DESC'])
|
@IsIn(['ASC', 'DESC'])
|
||||||
sortOrder?: '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),
|
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,
|
SelectQueryBuilder,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { Contract } from '../contracts/entities/contract.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>> {
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
const rows = await this.repository
|
const rows = await this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
@@ -915,16 +939,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return { inQueue, onThisPage, needsAction, urgent };
|
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(
|
private applyListFilters(
|
||||||
qb: SelectQueryBuilder<Booking>,
|
qb: SelectQueryBuilder<Booking>,
|
||||||
options: BookingListFilterOptions,
|
options: BookingListFilterOptions,
|
||||||
|
omit?: keyof BookingListFilterOptions | 'status',
|
||||||
): void {
|
): void {
|
||||||
if (options.statuses?.length) {
|
if (omit !== 'status') {
|
||||||
qb.andWhere('booking.status IN (:...statuses)', {
|
if (options.statuses?.length) {
|
||||||
statuses: options.statuses,
|
qb.andWhere('booking.status IN (:...statuses)', {
|
||||||
});
|
statuses: options.statuses,
|
||||||
} else if (options.status) {
|
});
|
||||||
qb.andWhere('booking.status = :status', { status: options.status });
|
} else if (options.status) {
|
||||||
|
qb.andWhere('booking.status = :status', { status: options.status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.companyId) {
|
if (options.companyId) {
|
||||||
@@ -957,12 +989,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
cargoTypeId: options.cargoTypeId,
|
cargoTypeId: options.cargoTypeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.freightType) {
|
if (omit !== 'freightType' && options.freightType) {
|
||||||
qb.andWhere('booking.freight_type = :freightType', {
|
qb.andWhere('booking.freight_type = :freightType', {
|
||||||
freightType: options.freightType,
|
freightType: options.freightType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.bookingType) {
|
if (omit !== 'bookingType' && options.bookingType) {
|
||||||
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
||||||
// drawdowns included — see contract-booking.service create), so the
|
// drawdowns included — see contract-booking.service create), so the
|
||||||
// one-time vs general split keys on the denormalized contract_kind:
|
// 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') {
|
} else if (options.isGovernment === 'false') {
|
||||||
qb.andWhere('booking.is_government = FALSE');
|
qb.andWhere('booking.is_government = FALSE');
|
||||||
}
|
}
|
||||||
if (options.tradeDirection) {
|
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
||||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||||
tradeDirection: options.tradeDirection,
|
tradeDirection: options.tradeDirection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.tradeDirections) {
|
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
||||||
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
|
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
|
||||||
}
|
}
|
||||||
if (options.paymentCurrency) {
|
if (options.paymentCurrency) {
|
||||||
@@ -1024,7 +1056,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
paymentCurrency: options.paymentCurrency,
|
paymentCurrency: options.paymentCurrency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.paymentStatus) {
|
if (omit !== 'paymentStatus' && options.paymentStatus) {
|
||||||
qb.andWhere('booking.payment_status = :paymentStatus', {
|
qb.andWhere('booking.payment_status = :paymentStatus', {
|
||||||
paymentStatus: options.paymentStatus,
|
paymentStatus: options.paymentStatus,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2051,8 +2051,9 @@ export class BookingsService {
|
|||||||
consolidationPaired: filter.consolidationPaired,
|
consolidationPaired: filter.consolidationPaired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [statusCounts, metrics] = await Promise.all([
|
const [statusCounts, facets, metrics] = await Promise.all([
|
||||||
this.bookingsRepository.getStatusCounts(),
|
this.bookingsRepository.getStatusCounts(),
|
||||||
|
this.bookingsRepository.getFacets(listFilter),
|
||||||
this.bookingsRepository.getListSummaryMetrics({
|
this.bookingsRepository.getListSummaryMetrics({
|
||||||
...listFilter,
|
...listFilter,
|
||||||
page,
|
page,
|
||||||
@@ -2064,7 +2065,9 @@ export class BookingsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
metrics,
|
metrics,
|
||||||
|
// Tabs stay unfiltered (whole-set) on purpose — see the DTO comment.
|
||||||
tabs: mapStatusCountsToTabs(statusCounts),
|
tabs: mapStatusCountsToTabs(statusCounts),
|
||||||
|
facets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { FacetBucket } from '../../../common/utils/facets.util';
|
||||||
|
|
||||||
export class BookingListSummaryMetricsDto {
|
export class BookingListSummaryMetricsDto {
|
||||||
@ApiProperty({ example: 42 })
|
@ApiProperty({ example: 42 })
|
||||||
@@ -31,4 +32,18 @@ export class BookingListSummaryDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: BookingListSummaryTabsDto })
|
@ApiProperty({ type: BookingListSummaryTabsDto })
|
||||||
tabs!: 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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from '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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||||
@@ -416,14 +417,22 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
return { inQueue, onThisPage, needsAction };
|
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(
|
private applyListFilters(
|
||||||
qb: SelectQueryBuilder<Contract>,
|
qb: SelectQueryBuilder<Contract>,
|
||||||
options: ContractListFilterOptions,
|
options: ContractListFilterOptions,
|
||||||
|
omit?: keyof ContractListFilterOptions | 'status',
|
||||||
): void {
|
): void {
|
||||||
if (options.statuses?.length) {
|
if (omit !== 'status') {
|
||||||
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
if (options.statuses?.length) {
|
||||||
} else if (options.status) {
|
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
||||||
qb.andWhere('contract.status = :status', { status: options.status });
|
} else if (options.status) {
|
||||||
|
qb.andWhere('contract.status = :status', { status: options.status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (options.companyId) {
|
if (options.companyId) {
|
||||||
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
||||||
@@ -433,7 +442,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
companyProfileId: options.companyProfileId,
|
companyProfileId: options.companyProfileId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.contractKind) {
|
if (omit !== 'contractKind' && options.contractKind) {
|
||||||
qb.andWhere('contract.contract_kind = :contractKind', {
|
qb.andWhere('contract.contract_kind = :contractKind', {
|
||||||
contractKind: options.contractKind,
|
contractKind: options.contractKind,
|
||||||
});
|
});
|
||||||
@@ -454,20 +463,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
serviceTypeId: options.serviceTypeId,
|
serviceTypeId: options.serviceTypeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.freightType) {
|
if (omit !== 'freightType' && options.freightType) {
|
||||||
qb.andWhere('contract.freight_type = :freightType', {
|
qb.andWhere('contract.freight_type = :freightType', {
|
||||||
freightType: options.freightType,
|
freightType: options.freightType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.tradeDirection) {
|
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
||||||
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
||||||
tradeDirection: options.tradeDirection,
|
tradeDirection: options.tradeDirection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.tradeDirections) {
|
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
||||||
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
||||||
}
|
}
|
||||||
if (options.paymentCurrency) {
|
if (omit !== 'paymentCurrency' && options.paymentCurrency) {
|
||||||
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
||||||
paymentCurrency: options.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 ─────────────────────────────────────────────────────────
|
// ── Approval steps ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Lowest-order pending approval step (sequential enforcement). */
|
/** Lowest-order pending approval step (sequential enforcement). */
|
||||||
|
|||||||
@@ -791,8 +791,8 @@ export class ContractsService {
|
|||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [statusCounts, metrics] = await Promise.all([
|
const [facets, metrics] = await Promise.all([
|
||||||
this.contractsRepository.getStatusCounts(),
|
this.contractsRepository.getFacets(listFilter),
|
||||||
this.contractsRepository.getListSummaryMetrics({
|
this.contractsRepository.getListSummaryMetrics({
|
||||||
...listFilter,
|
...listFilter,
|
||||||
page,
|
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. */
|
/** Get a single contract by ID with relations and signed file URLs. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { FacetBucket } from '../../../common/utils/facets.util';
|
||||||
|
|
||||||
export class ContractListSummaryMetricsDto {
|
export class ContractListSummaryMetricsDto {
|
||||||
@ApiProperty({ example: 42 })
|
@ApiProperty({ example: 42 })
|
||||||
@@ -15,6 +16,23 @@ export class ContractListSummaryDto {
|
|||||||
@ApiProperty({ type: ContractListSummaryMetricsDto })
|
@ApiProperty({ type: ContractListSummaryMetricsDto })
|
||||||
metrics!: 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' } })
|
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
|
||||||
statusCounts!: Record<string, 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 } });
|
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> {
|
async createDriver(driverData: any): Promise<Driver> {
|
||||||
const driver = this.repository.create(driverData);
|
const driver = this.repository.create(driverData);
|
||||||
const result = await this.repository.save(driver);
|
const result = await this.repository.save(driver);
|
||||||
|
|||||||
@@ -21,52 +21,6 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
|
|||||||
return this.repository.findOne({ where: { id } });
|
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> {
|
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
|
||||||
const vehicle = this.repository.create(vehicleData);
|
const vehicle = this.repository.create(vehicleData);
|
||||||
return this.repository.save(vehicle);
|
return this.repository.save(vehicle);
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Anchor, Divider, Group, TextInput } from "@mantine/core";
|
||||||
|
import { Search, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import type { FilterDef, SortOption } from "./types";
|
||||||
|
import type { UseFilters } from "./useFilters";
|
||||||
|
import { FilterPill } from "./FilterPill";
|
||||||
|
import { MoreFiltersMenu } from "./MoreFiltersMenu";
|
||||||
|
import { SavedViews } from "./SavedViews";
|
||||||
|
import { SortControl } from "./SortControl";
|
||||||
|
|
||||||
|
export interface FilterBarProps {
|
||||||
|
defs: FilterDef[];
|
||||||
|
controls: UseFilters;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
showSearch?: boolean;
|
||||||
|
/** value already "field:DIR" — the page's existing SORT_OPTIONS, moved not rewritten. */
|
||||||
|
sortOptions?: SortOption[];
|
||||||
|
/** localStorage namespace for saved views. Omit to hide the control. */
|
||||||
|
viewId?: string;
|
||||||
|
/** Escape hatch: tabs, row count, a "New" button — rendered at the far right. */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
defs,
|
||||||
|
controls,
|
||||||
|
searchPlaceholder = "Search…",
|
||||||
|
showSearch = true,
|
||||||
|
sortOptions,
|
||||||
|
viewId,
|
||||||
|
children,
|
||||||
|
}: FilterBarProps) {
|
||||||
|
// Filters just picked from "More filters" render as an already-open pill
|
||||||
|
// until the popover closes, then fall back to the ordinary pinned/active split.
|
||||||
|
const [justPicked, setJustPicked] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const pinned = defs.filter((d) => !d.secondary || controls.values[d.key] || justPicked.includes(d.key));
|
||||||
|
const secondary = defs.filter((d) => !pinned.includes(d));
|
||||||
|
// Applied filters read first, left to right — a stable partition keeps
|
||||||
|
// each group in its original def order rather than resorting on every apply.
|
||||||
|
const orderedPinned = [
|
||||||
|
...pinned.filter((d) => controls.values[d.key]),
|
||||||
|
...pinned.filter((d) => !controls.values[d.key]),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Two independent flex zones, not one big wrapping Group: the left side
|
||||||
|
// (search + pills + more filters + clear) wraps to as many lines as it
|
||||||
|
// needs; the right side (sort) stays put on the first line — `nowrap` +
|
||||||
|
// `flexShrink: 0` on the right zone stop it from ever getting pushed
|
||||||
|
// down when the left side overflows.
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, flexWrap: "nowrap" }}>
|
||||||
|
<Group gap="xs" wrap="wrap" align="center" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{viewId && (
|
||||||
|
<SavedViews
|
||||||
|
viewId={viewId}
|
||||||
|
currentQueryString={controls.currentQueryString}
|
||||||
|
applyQueryString={controls.applyQueryString}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<TextInput
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
leftSection={<Search size={14} />}
|
||||||
|
value={controls.searchText}
|
||||||
|
onChange={(e) => controls.setSearchText(e.currentTarget.value)}
|
||||||
|
size="xs"
|
||||||
|
radius="lg"
|
||||||
|
style={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orderedPinned.map((def) => (
|
||||||
|
<FilterPill
|
||||||
|
key={def.key}
|
||||||
|
def={def}
|
||||||
|
value={controls.values[def.key]}
|
||||||
|
onChange={(v) => controls.setFilter(def.key, v)}
|
||||||
|
autoOpen={justPicked.includes(def.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<MoreFiltersMenu
|
||||||
|
defs={secondary}
|
||||||
|
onPick={(key) => setJustPicked((prev) => [...prev, key])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{controls.activeCount > 0 && (
|
||||||
|
<Anchor
|
||||||
|
size="xs"
|
||||||
|
c="red.6"
|
||||||
|
underline="never"
|
||||||
|
onClick={() => {
|
||||||
|
controls.clearFilters();
|
||||||
|
setJustPicked([]);
|
||||||
|
}}
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
Clear
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Sorting is a different kind of control (view order, not scope) —
|
||||||
|
cut off from the filter pills by a vertical divider and pinned to
|
||||||
|
the right, independent of how the left side wraps. */}
|
||||||
|
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
|
{children}
|
||||||
|
{sortOptions && sortOptions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
<SortControl options={sortOptions} value={controls.sort} onChange={controls.setSort} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ActionIcon, Button, Popover } from "@mantine/core";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type { FilterDef, FilterValue } from "./types";
|
||||||
|
import { BooleanBody } from "./bodies/BooleanBody";
|
||||||
|
import { DateBody } from "./bodies/DateBody";
|
||||||
|
import { EnumBody } from "./bodies/EnumBody";
|
||||||
|
import { NumberBody } from "./bodies/NumberBody";
|
||||||
|
import { TextBody } from "./bodies/TextBody";
|
||||||
|
|
||||||
|
const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
|
||||||
|
text: TextBody,
|
||||||
|
enum: EnumBody,
|
||||||
|
date: DateBody,
|
||||||
|
number: NumberBody,
|
||||||
|
boolean: BooleanBody,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatValue(def: FilterDef, value: FilterValue): string {
|
||||||
|
if (def.format) return def.format(value, def);
|
||||||
|
if (def.type === "enum") {
|
||||||
|
const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v);
|
||||||
|
return labels.join(", ");
|
||||||
|
}
|
||||||
|
if (def.type === "date" && value.v.length === 2) {
|
||||||
|
return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`;
|
||||||
|
}
|
||||||
|
return value.v.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterPillProps {
|
||||||
|
def: FilterDef;
|
||||||
|
value: FilterValue | undefined;
|
||||||
|
onChange: (v: FilterValue | undefined) => void;
|
||||||
|
/** Opened immediately (used when picked from "More filters"). */
|
||||||
|
autoOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) {
|
||||||
|
const [opened, setOpened] = useState(Boolean(autoOpen));
|
||||||
|
const Body = BODIES[def.type];
|
||||||
|
const active = Boolean(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover position="bottom-start" withinPortal shadow="md" opened={opened} onChange={setOpened}>
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="xl"
|
||||||
|
// Inactive: "default" variant (solid border, opaque text) reads far
|
||||||
|
// less faint than a color-tinted outline — dashed border is the only
|
||||||
|
// thing marking it as "not set yet". Active: "light" (soft tinted
|
||||||
|
// fill), not "filled" — a whole row of solid green buttons was the
|
||||||
|
// "too loud" complaint; light keeps the active/inactive contrast
|
||||||
|
// without shouting.
|
||||||
|
variant={active ? "light" : "default"}
|
||||||
|
color={active ? "edr-green" : undefined}
|
||||||
|
styles={active ? undefined : { root: { borderStyle: "dashed" } }}
|
||||||
|
leftSection={!active && <Plus size={12} />}
|
||||||
|
rightSection={
|
||||||
|
active && (
|
||||||
|
<ActionIcon
|
||||||
|
component="span"
|
||||||
|
size={16}
|
||||||
|
radius="xl"
|
||||||
|
variant="subtle"
|
||||||
|
color="edr-green"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => setOpened((o) => !o)}
|
||||||
|
>
|
||||||
|
{active ? `${def.label} | ${formatValue(def, value!)}` : def.label}
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown miw={260} p="xs">
|
||||||
|
<Body def={def} value={value} onChange={onChange} onClose={() => setOpened(false)} />
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||||
|
import { Plus, Search } from "lucide-react";
|
||||||
|
|
||||||
|
import type { FilterDef } from "./types";
|
||||||
|
|
||||||
|
export interface MoreFiltersMenuProps {
|
||||||
|
defs: FilterDef[];
|
||||||
|
/** Called with the picked def's key — the caller pins it and opens its popover. */
|
||||||
|
onPick: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Searchable list over the page's secondary/inactive filters. Plain filter + list,
|
||||||
|
* not cmdk — a handful of static strings doesn't need a Combobox store. */
|
||||||
|
export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) {
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() => defs.filter((d) => d.label.toLowerCase().includes(query.toLowerCase())),
|
||||||
|
[defs, query],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (defs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover position="bottom-start" withinPortal shadow="md" opened={opened} onChange={setOpened}>
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="xl"
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setOpened((o) => !o)}
|
||||||
|
>
|
||||||
|
More filters
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown miw={220} p="xs">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search filters…"
|
||||||
|
leftSection={<Search size={14} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<ScrollArea.Autosize mah={280}>
|
||||||
|
<Stack gap={2}>
|
||||||
|
{visible.map((d) => (
|
||||||
|
<UnstyledButton
|
||||||
|
key={d.key}
|
||||||
|
px="xs"
|
||||||
|
py={6}
|
||||||
|
className="hover:bg-gray-100 transition-colors"
|
||||||
|
style={{ borderRadius: 6, display: "flex", alignItems: "center", gap: 8 }}
|
||||||
|
onClick={() => {
|
||||||
|
setOpened(false);
|
||||||
|
setQuery("");
|
||||||
|
onPick(d.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="text-[var(--mantine-color-edr-green-6)]" />
|
||||||
|
<Text size="sm" c="edr-green.7">
|
||||||
|
{d.label}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
))}
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<Text size="xs" c="dimmed" px="xs" py={6}>
|
||||||
|
No matching filters
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { SegmentedControl } from "@mantine/core";
|
||||||
|
import { DEFAULT_OP, OPERATOR_LABELS, type FilterDef, type Operator } from "./types";
|
||||||
|
|
||||||
|
export interface OperatorSelectProps {
|
||||||
|
def: FilterDef;
|
||||||
|
value: Operator;
|
||||||
|
onChange: (op: Operator) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders nothing when a def has <= 1 operator — most defs, by design: type-aware
|
||||||
|
* operators are a capability, not a dropdown forced into every popover. */
|
||||||
|
export function OperatorSelect({ def, value, onChange }: OperatorSelectProps) {
|
||||||
|
const operators = def.operators ?? [DEFAULT_OP[def.type]];
|
||||||
|
if (operators.length <= 1) return null;
|
||||||
|
return (
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange(v as Operator)}
|
||||||
|
data={operators.map((op) => ({ value: op, label: OPERATOR_LABELS[op] }))}
|
||||||
|
mb="xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ActionIcon, Button, Menu, Modal, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { useLocalStorage } from "@mantine/hooks";
|
||||||
|
import { Bookmark, Check, Save, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface SavedView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedViewsProps {
|
||||||
|
/** localStorage namespace — one page, not one user (single staff login per
|
||||||
|
* browser profile). ponytail: add ":<userId>" if shared-terminal login appears. */
|
||||||
|
viewId: string;
|
||||||
|
currentQueryString: () => string;
|
||||||
|
applyQueryString: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL always wins: this menu only ever WRITES the URL, on click. Nothing
|
||||||
|
* reads a saved view at mount, so a shared link always beats a saved view —
|
||||||
|
* there is no "which one applies" branch to get wrong. */
|
||||||
|
export function SavedViews({ viewId, currentQueryString, applyQueryString }: SavedViewsProps) {
|
||||||
|
const [views, setViews] = useLocalStorage<SavedView[]>({
|
||||||
|
key: `edr:saved-views:${viewId}`,
|
||||||
|
defaultValue: [],
|
||||||
|
});
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const activeQuery = currentQueryString();
|
||||||
|
const active = views.find((v) => v.query === activeQuery);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setViews((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: crypto.randomUUID(), name: name.trim(), query: currentQueryString() },
|
||||||
|
]);
|
||||||
|
setName("");
|
||||||
|
setSaveOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu position="bottom-start" withinPortal shadow="md">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button size="xs" variant="subtle" color="gray" leftSection={<Bookmark size={14} />}>
|
||||||
|
{active?.name ?? "All"}
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown miw={220}>
|
||||||
|
{views.length === 0 && (
|
||||||
|
<Menu.Item disabled>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
No saved views yet
|
||||||
|
</Text>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{views.map((v) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={v.id}
|
||||||
|
leftSection={v.id === active?.id ? <Check size={14} /> : <span style={{ width: 14 }} />}
|
||||||
|
rightSection={
|
||||||
|
<ActionIcon
|
||||||
|
size="xs"
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
remove(v.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
onClick={() => applyQueryString(v.query)}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item leftSection={<Save size={14} />} onClick={() => setSaveOpen(true)}>
|
||||||
|
Save current view…
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Modal opened={saveOpen} onClose={() => setSaveOpen(false)} title="Save current view" size="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
placeholder="View name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button onClick={save} disabled={!name.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Button, Menu } from "@mantine/core";
|
||||||
|
import { ArrowUpDown, Check } from "lucide-react";
|
||||||
|
|
||||||
|
import type { SortOption } from "./types";
|
||||||
|
|
||||||
|
export interface SortControlProps {
|
||||||
|
options: SortOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A control, not a form field — Menu (not Select) gives the check-mark +
|
||||||
|
* trigger-label read Stripe's sort control has. Rendered only when a page
|
||||||
|
* passes sortOptions; inventing options for an endpoint without sortBy
|
||||||
|
* support would ship a control that silently does nothing. */
|
||||||
|
export function SortControl({ options, value, onChange }: SortControlProps) {
|
||||||
|
if (options.length === 0) return null;
|
||||||
|
const current = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu position="bottom-end" withinPortal shadow="md">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button size="xs" variant="outline" color="gray" leftSection={<ArrowUpDown size={14} />}>
|
||||||
|
{current?.label ?? "Sort"}
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{options.map((o) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={o.value}
|
||||||
|
leftSection={o.value === value ? <Check size={14} /> : <span style={{ width: 14 }} />}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Radio, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { BooleanFilterDef, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps<BooleanFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.boolean);
|
||||||
|
const [v, setV] = useState(value?.v[0] ?? "");
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
onChange(v ? { op, v: [v] } : undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
<Radio.Group value={v} onChange={setV}>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Radio value="true" label={def.trueLabel ?? "Yes"} size="sm" />
|
||||||
|
<Radio value="false" label={def.falseLabel ?? "No"} size="sm" />
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
<Button size="sm" onClick={apply}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Stack } from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
|
|
||||||
|
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||||
|
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates";
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { DateFilterDef, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
// ponytail: Gregorian only. Record-management pages need the Ethiopian
|
||||||
|
// calendar (see shared/common/form/fields/AmharicDatePicker.tsx) — add an
|
||||||
|
// i18n.language !== "en" branch here when this body is first wired into a
|
||||||
|
// record-management page (Phase 4 of the filter-bar rollout).
|
||||||
|
export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<DateFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.date);
|
||||||
|
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects.
|
||||||
|
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null);
|
||||||
|
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (op === "between") {
|
||||||
|
onChange(
|
||||||
|
from && to
|
||||||
|
? { op, v: [startOfDayIso(parseDateStr(from)), endOfDayIso(parseDateStr(to))] }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onChange(
|
||||||
|
from
|
||||||
|
? {
|
||||||
|
op,
|
||||||
|
v: [
|
||||||
|
op === "before"
|
||||||
|
? startOfDayIso(parseDateStr(from))
|
||||||
|
: endOfDayIso(parseDateStr(from)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// This popover already lives inside FilterPill's own Popover. Mantine's
|
||||||
|
// DatePickerInput opens ITS calendar in a separate portal by default, so a
|
||||||
|
// click on a day registers as "outside" the outer Popover and closes the
|
||||||
|
// whole filter before the range can be picked (or Apply reached) — the
|
||||||
|
// reported "date picker doesn't work". Keeping the calendar un-portalled
|
||||||
|
// renders it inside the outer popover's own DOM subtree instead, so
|
||||||
|
// outside-click detection sees it as inside.
|
||||||
|
const nestedPopoverProps = { withinPortal: false } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
{op === "between" ? (
|
||||||
|
<DatePickerInput
|
||||||
|
type="range"
|
||||||
|
placeholder="Any"
|
||||||
|
value={[from, to]}
|
||||||
|
onChange={([f, t]) => {
|
||||||
|
setFrom(f);
|
||||||
|
setTo(t);
|
||||||
|
}}
|
||||||
|
presets={getDateRangePresets()}
|
||||||
|
popoverProps={nestedPopoverProps}
|
||||||
|
clearable
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DatePickerInput
|
||||||
|
placeholder="Any"
|
||||||
|
value={from}
|
||||||
|
onChange={setFrom}
|
||||||
|
popoverProps={nestedPopoverProps}
|
||||||
|
clearable
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={apply} disabled={op === "between" ? !(from && to) : !from}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button, Checkbox, Group, Radio, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { EnumFilterDef, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
/** How many options before a search box appears above the list. */
|
||||||
|
const SEARCH_THRESHOLD = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stretches the Checkbox/Radio's native <label> across the full popover
|
||||||
|
* width and pads it, so the clickable/tappable area is the whole row —
|
||||||
|
* not just the ~14px input square — plus a hover cue. `body`/`labelWrapper`
|
||||||
|
* are Mantine's part names for this; `cursor: pointer` on the row (not just
|
||||||
|
* the input) makes the affordance visible before you even click.
|
||||||
|
*/
|
||||||
|
const ROW_STYLES = {
|
||||||
|
root: { padding: "10px 10px", borderRadius: 6 },
|
||||||
|
body: { alignItems: "center" as const },
|
||||||
|
labelWrapper: { flex: 1 },
|
||||||
|
label: { cursor: "pointer", paddingLeft: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function OptionLabel({ label, count }: { label: string; count?: number }) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Text size="sm">{label}</Text>
|
||||||
|
{count !== undefined && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<EnumFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.enum);
|
||||||
|
const [selected, setSelected] = useState<string[]>(value?.v ?? []);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const multiple = def.multiple ?? true;
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const byQuery = query
|
||||||
|
? def.options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
: def.options;
|
||||||
|
if (!def.counts || showAll) return byQuery;
|
||||||
|
// Hide zero-count options, but never hide one the user already picked —
|
||||||
|
// otherwise a filter that narrows to zero rows becomes impossible to un-select.
|
||||||
|
return byQuery.filter((o) => (def.counts![o.value] ?? 0) > 0 || selected.includes(o.value));
|
||||||
|
}, [def.options, def.counts, query, showAll, selected]);
|
||||||
|
|
||||||
|
const hiddenCount = def.options.length - visible.length;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
onChange(selected.length ? { op, v: selected } : undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
{def.options.length > SEARCH_THRESHOLD && (
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search options…"
|
||||||
|
leftSection={<Search size={14} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stack gap={0} mah={260} style={{ overflowY: "auto" }}>
|
||||||
|
{multiple ? (
|
||||||
|
<Checkbox.Group value={selected} onChange={setSelected} aria-label={`Filter by ${def.label}`}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{visible.map((o) => (
|
||||||
|
<Checkbox
|
||||||
|
key={o.value}
|
||||||
|
value={o.value}
|
||||||
|
size="sm"
|
||||||
|
// The count sits INSIDE the label, so it's part of the
|
||||||
|
// native <label> the input is bound to — clicking it (not
|
||||||
|
// just the tiny checkbox square) toggles the option too.
|
||||||
|
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
|
||||||
|
styles={ROW_STYLES}
|
||||||
|
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Checkbox.Group>
|
||||||
|
) : (
|
||||||
|
<Radio.Group
|
||||||
|
value={selected[0] ?? ""}
|
||||||
|
onChange={(v) => setSelected(v ? [v] : [])}
|
||||||
|
aria-label={`Filter by ${def.label}`}
|
||||||
|
>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{visible.map((o) => (
|
||||||
|
<Radio
|
||||||
|
key={o.value}
|
||||||
|
value={o.value}
|
||||||
|
size="sm"
|
||||||
|
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
|
||||||
|
styles={ROW_STYLES}
|
||||||
|
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
)}
|
||||||
|
{!showAll && hiddenCount > 0 && (
|
||||||
|
<UnstyledButton onClick={() => setShowAll(true)}>
|
||||||
|
<Text size="sm" c="edr-green.6">
|
||||||
|
Show all {def.options.length} options
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Button size="sm" onClick={apply}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Group, NumberInput, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { NumberFilterDef, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
import type { FilterBodyProps } from "./TextBody";
|
||||||
|
|
||||||
|
export function NumberBody({ def, value, onChange, onClose }: FilterBodyProps<NumberFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.number);
|
||||||
|
const [from, setFrom] = useState<number | "">(value?.v[0] ? Number(value.v[0]) : "");
|
||||||
|
const [to, setTo] = useState<number | "">(op === "between" ? (Number(value?.v[1]) || "") : "");
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (op === "between") {
|
||||||
|
onChange(from !== "" && to !== "" ? { op, v: [String(from), String(to)] } : undefined);
|
||||||
|
} else {
|
||||||
|
onChange(from !== "" ? { op, v: [String(from)] } : undefined);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
{op === "between" ? (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<NumberInput placeholder="Min" value={from} onChange={(v) => setFrom(v as number | "")} rightSection={def.unit} autoFocus />
|
||||||
|
<NumberInput placeholder="Max" value={to} onChange={(v) => setTo(v as number | "")} rightSection={def.unit} />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<NumberInput placeholder="Value" value={from} onChange={(v) => setFrom(v as number | "")} rightSection={def.unit} autoFocus />
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={apply}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Stack, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import { DEFAULT_OP } from "../types";
|
||||||
|
import type { TextFilterDef, FilterValue, Operator } from "../types";
|
||||||
|
import { OperatorSelect } from "../OperatorSelect";
|
||||||
|
|
||||||
|
export interface FilterBodyProps<Def> {
|
||||||
|
def: Def;
|
||||||
|
value: FilterValue | undefined;
|
||||||
|
onChange: (v: FilterValue | undefined) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextBody({ def, value, onChange, onClose }: FilterBodyProps<TextFilterDef>) {
|
||||||
|
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.text);
|
||||||
|
const [text, setText] = useState(value?.v[0] ?? "");
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
onChange(text.trim() ? { op, v: [text.trim()] } : undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||||
|
<TextInput
|
||||||
|
placeholder={def.placeholder ?? `Filter by ${def.label.toLowerCase()}…`}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && apply()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={apply}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** Local start-of-day -> ISO, for inclusive "from" date filters. Lifted out of
|
||||||
|
* ContractRequestsPage (where it was duplicated into BookingRequestsPage) so
|
||||||
|
* every date filter shares one definition. */
|
||||||
|
export function startOfDayIso(d: Date): string {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(0, 0, 0, 0);
|
||||||
|
return x.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local end-of-day -> ISO, for inclusive "to" date filters. */
|
||||||
|
export function endOfDayIso(d: Date): string {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(23, 59, 59, 999);
|
||||||
|
return x.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `YYYY-MM-DD` date-picker string into a LOCAL-midnight Date.
|
||||||
|
*
|
||||||
|
* `new Date("2026-01-01")` is a date-ONLY ISO string, which the spec parses
|
||||||
|
* as UTC midnight, not local midnight. For anyone east of UTC (Ethiopia is
|
||||||
|
* UTC+3) that instant already falls on the PREVIOUS local day, so
|
||||||
|
* `startOfDayIso`/`endOfDayIso` built from it silently shift the picked date
|
||||||
|
* back by one — the picker looks fine, the filtered results are wrong. This
|
||||||
|
* constructor form (`new Date(y, m, d)`) is local by definition; use it for
|
||||||
|
* every date-only string instead of `new Date(dateString)`.
|
||||||
|
*/
|
||||||
|
export function parseDateStr(dateStr: string): Date {
|
||||||
|
const [y, m, d] = dateStr.split("-").map(Number);
|
||||||
|
return new Date(y, (m || 1) - 1, d || 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./url";
|
||||||
|
export * from "./dates";
|
||||||
|
export * from "./useFilters";
|
||||||
|
export { FilterBar } from "./FilterBar";
|
||||||
|
export type { FilterBarProps } from "./FilterBar";
|
||||||
|
export { FilterPill } from "./FilterPill";
|
||||||
|
export { SortControl } from "./SortControl";
|
||||||
|
export { SavedViews } from "./SavedViews";
|
||||||
|
export { MoreFiltersMenu } from "./MoreFiltersMenu";
|
||||||
101
apps/edr-freight-web/backoffice/src/components/filters/types.ts
Normal file
101
apps/edr-freight-web/backoffice/src/components/filters/types.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
export type FilterType = "text" | "enum" | "date" | "number" | "boolean";
|
||||||
|
|
||||||
|
export type Operator = "is" | "isNot" | "contains" | "between" | "before" | "after";
|
||||||
|
|
||||||
|
/** Operator implied by a filter's type when the def doesn't say otherwise. */
|
||||||
|
export const DEFAULT_OP: Record<FilterType, Operator> = {
|
||||||
|
text: "contains",
|
||||||
|
enum: "is",
|
||||||
|
date: "between",
|
||||||
|
number: "is",
|
||||||
|
boolean: "is",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPERATOR_LABELS: Record<Operator, string> = {
|
||||||
|
is: "is",
|
||||||
|
isNot: "is not",
|
||||||
|
contains: "contains",
|
||||||
|
between: "is between",
|
||||||
|
before: "is before",
|
||||||
|
after: "is after",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filter's current value. `v` holds:
|
||||||
|
* - 1 entry for is / isNot / contains / before / after
|
||||||
|
* - 2 entries for between (range)
|
||||||
|
* - n entries for a multi-select enum (isAnyOf is expressed as op "is" + n values)
|
||||||
|
*/
|
||||||
|
export interface FilterValue {
|
||||||
|
op: Operator;
|
||||||
|
v: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacetBucket {
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterDefBase {
|
||||||
|
/** URL key and, by default, the API param name. */
|
||||||
|
key: string;
|
||||||
|
/** Plain string — the caller applies i18n's t() before passing it in. */
|
||||||
|
label: string;
|
||||||
|
type: FilterType;
|
||||||
|
/** Defaults to `[DEFAULT_OP[type]]`. Widen only where the endpoint implements it. */
|
||||||
|
operators?: Operator[];
|
||||||
|
/** Pill text override. Default: "Label | value(s)". */
|
||||||
|
format?: (v: FilterValue, def: FilterDef) => string;
|
||||||
|
/** Map to API query params. Default `{ [key]: v.join(",") }`. */
|
||||||
|
toParams?: (v: FilterValue) => Record<string, string | undefined>;
|
||||||
|
/** Lives behind "More filters" until it has a value. Default false. */
|
||||||
|
secondary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFilterDef extends FilterDefBase {
|
||||||
|
type: "text";
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnumFilterDef extends FilterDefBase {
|
||||||
|
type: "enum";
|
||||||
|
options: FilterOption[];
|
||||||
|
/** Default true — checkbox list. false renders a single-select radio list. */
|
||||||
|
multiple?: boolean;
|
||||||
|
/** value -> count in the current (filtered) result set. Absent = no counts, hide nothing. */
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateFilterDef extends FilterDefBase {
|
||||||
|
type: "date";
|
||||||
|
calendar?: "gregorian" | "ethiopian";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberFilterDef extends FilterDefBase {
|
||||||
|
type: "number";
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanFilterDef extends FilterDefBase {
|
||||||
|
type: "boolean";
|
||||||
|
trueLabel?: string;
|
||||||
|
falseLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterDef =
|
||||||
|
| TextFilterDef
|
||||||
|
| EnumFilterDef
|
||||||
|
| DateFilterDef
|
||||||
|
| NumberFilterDef
|
||||||
|
| BooleanFilterDef;
|
||||||
|
|
||||||
|
/** A page's sort options — value is already `"field:DIR"`, the codebase's existing convention. */
|
||||||
|
export interface SortOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { FilterDef, FilterValue } from "./types";
|
||||||
|
import {
|
||||||
|
decodeFilterValue,
|
||||||
|
encodeFilterValue,
|
||||||
|
parseFilters,
|
||||||
|
parseSort,
|
||||||
|
toApiParams,
|
||||||
|
writeFilter,
|
||||||
|
} from "./url";
|
||||||
|
|
||||||
|
const STATUS: FilterDef = {
|
||||||
|
key: "statuses",
|
||||||
|
label: "Status",
|
||||||
|
type: "enum",
|
||||||
|
options: [
|
||||||
|
{ value: "ACTIVE", label: "Active" },
|
||||||
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRECTION: FilterDef = {
|
||||||
|
key: "tradeDirection",
|
||||||
|
label: "Direction",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: [{ value: "IMPORT", label: "Import" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH: FilterDef = { key: "q", label: "Search", type: "text" };
|
||||||
|
|
||||||
|
const CREATED: FilterDef = {
|
||||||
|
key: "created",
|
||||||
|
label: "Created",
|
||||||
|
type: "date",
|
||||||
|
toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("encodeFilterValue / decodeFilterValue round-trip", () => {
|
||||||
|
const cases: Array<{ name: string; type: FilterDef["type"]; value: FilterValue }> = [
|
||||||
|
{ name: "text contains (default op omitted)", type: "text", value: { op: "contains", v: ["maersk"] } },
|
||||||
|
{ name: "enum is (default op omitted, multi value)", type: "enum", value: { op: "is", v: ["ACTIVE", "DRAFT"] } },
|
||||||
|
{ name: "enum isNot (non-default op prefixed)", type: "enum", value: { op: "isNot", v: ["GOV"] } },
|
||||||
|
{ name: "date between (default op omitted)", type: "date", value: { op: "between", v: ["2026-01-01", "2026-03-01"] } },
|
||||||
|
{ name: "date before (non-default op prefixed)", type: "date", value: { op: "before", v: ["2026-01-01"] } },
|
||||||
|
{ name: "number is", type: "number", value: { op: "is", v: ["42"] } },
|
||||||
|
{ name: "boolean is", type: "boolean", value: { op: "is", v: ["true"] } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, type, value } of cases) {
|
||||||
|
it(`round-trips: ${name}`, () => {
|
||||||
|
const encoded = encodeFilterValue(type, value);
|
||||||
|
const decoded = decodeFilterValue(type, encoded);
|
||||||
|
expect(decoded).toEqual(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("omits the operator prefix only when it is the type default", () => {
|
||||||
|
expect(encodeFilterValue("enum", { op: "is", v: ["ACTIVE"] })).toBe("ACTIVE");
|
||||||
|
expect(encodeFilterValue("enum", { op: "isNot", v: ["ACTIVE"] })).toBe("isNot:ACTIVE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never comma-splits a text value, so a literal comma survives", () => {
|
||||||
|
const encoded = encodeFilterValue("text", { op: "contains", v: ["Addis, Ethiopia"] });
|
||||||
|
expect(decodeFilterValue("text", encoded)).toEqual({ op: "contains", v: ["Addis, Ethiopia"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeFilterValue malformed-input tolerance", () => {
|
||||||
|
it("returns null for an empty string", () => {
|
||||||
|
expect(decodeFilterValue("text", "")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat an unknown prefix as an operator", () => {
|
||||||
|
// "foo" isn't a known Operator, so "foo:bar" is a literal text value, not op:value.
|
||||||
|
expect(decodeFilterValue("text", "foo:bar")).toEqual({ op: "contains", v: ["foo:bar"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades a one-sided 'between' to null (not applied) instead of guessing a half-open range", () => {
|
||||||
|
expect(decodeFilterValue("date", "between:2026-01-01")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on garbage input", () => {
|
||||||
|
expect(() => decodeFilterValue("enum", "isNot:")).not.toThrow();
|
||||||
|
expect(() => decodeFilterValue("date", "between:")).not.toThrow();
|
||||||
|
expect(() => decodeFilterValue("number", ":::")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseFilters / writeFilter", () => {
|
||||||
|
it("parses only the defs present, ignoring unrelated params", () => {
|
||||||
|
const params = new URLSearchParams("statuses=ACTIVE,DRAFT&unrelated=x&q=addis");
|
||||||
|
const values = parseFilters([STATUS, SEARCH], params);
|
||||||
|
expect(values).toEqual({
|
||||||
|
statuses: { op: "is", v: ["ACTIVE", "DRAFT"] },
|
||||||
|
q: { op: "contains", v: ["addis"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writeFilter deletes the param when value is undefined", () => {
|
||||||
|
const params = new URLSearchParams("statuses=ACTIVE");
|
||||||
|
const next = writeFilter(params, STATUS, undefined);
|
||||||
|
expect(next.has("statuses")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writeFilter round-trips through parseFilters", () => {
|
||||||
|
const value: FilterValue = { op: "is", v: ["IMPORT"] };
|
||||||
|
const next = writeFilter(new URLSearchParams(), DIRECTION, value);
|
||||||
|
expect(parseFilters([DIRECTION], next)).toEqual({ tradeDirection: value });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("namespaces keys when ns is given, so two tables on one page don't collide", () => {
|
||||||
|
const next = writeFilter(new URLSearchParams(), STATUS, { op: "is", v: ["ACTIVE"] }, "a");
|
||||||
|
expect(next.get("a.statuses")).toBe("ACTIVE");
|
||||||
|
expect(parseFilters([STATUS], new URLSearchParams(), "b")).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("existing deep-link backward compatibility", () => {
|
||||||
|
it("parses the BookingRequestsPage-style ?statuses=A,B&tradeDirection=IMPORT link unchanged", () => {
|
||||||
|
const params = new URLSearchParams("statuses=SUBMITTED,APPROVED&tradeDirection=IMPORT");
|
||||||
|
expect(parseFilters([STATUS, DIRECTION], params)).toEqual({
|
||||||
|
statuses: { op: "is", v: ["SUBMITTED", "APPROVED"] },
|
||||||
|
tradeDirection: { op: "is", v: ["IMPORT"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSort", () => {
|
||||||
|
const options = [
|
||||||
|
{ value: "createdAt:DESC", label: "Newest first" },
|
||||||
|
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns the fallback when sort is absent", () => {
|
||||||
|
expect(parseSort(new URLSearchParams(), options, "createdAt:DESC")).toBe("createdAt:DESC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the fallback for an unrecognized sort value", () => {
|
||||||
|
expect(parseSort(new URLSearchParams("sort=bogus:DESC"), options, "createdAt:DESC")).toBe(
|
||||||
|
"createdAt:DESC",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the URL value when it is a known option", () => {
|
||||||
|
expect(parseSort(new URLSearchParams("sort=createdAt:ASC"), options, "createdAt:DESC")).toBe(
|
||||||
|
"createdAt:ASC",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toApiParams", () => {
|
||||||
|
it("uses the default mapping (key: joined csv) when toParams is absent", () => {
|
||||||
|
const values = { statuses: { op: "is" as const, v: ["ACTIVE", "DRAFT"] } };
|
||||||
|
expect(toApiParams([STATUS], values)).toEqual({ statuses: "ACTIVE,DRAFT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a custom toParams to reproduce an existing API's exact param names", () => {
|
||||||
|
const values = { created: { op: "between" as const, v: ["2026-01-01", "2026-03-01"] } };
|
||||||
|
expect(toApiParams([CREATED], values)).toEqual({
|
||||||
|
createdFrom: "2026-01-01",
|
||||||
|
createdTo: "2026-03-01",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits defs with no value", () => {
|
||||||
|
expect(toApiParams([STATUS, SEARCH], {})).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
117
apps/edr-freight-web/backoffice/src/components/filters/url.ts
Normal file
117
apps/edr-freight-web/backoffice/src/components/filters/url.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { DEFAULT_OP, type FilterDef, type FilterValue, type Operator } from "./types";
|
||||||
|
|
||||||
|
const OPERATORS: readonly Operator[] = ["is", "isNot", "contains", "between", "before", "after"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode one filter value as `[op:]csv`, omitting the operator prefix when it
|
||||||
|
* matches the type's default — that keeps the common case short and, more
|
||||||
|
* importantly, keeps the existing `?statuses=A,B` deep links this app already
|
||||||
|
* generates (e.g. the header document-review alarm) parsing identically.
|
||||||
|
*
|
||||||
|
* `,` and `:` are structural inside an encoded value. A `text` filter is
|
||||||
|
* never comma-split (its `v` always has exactly one entry), which is what
|
||||||
|
* lets a free-text search contain a literal comma safely.
|
||||||
|
* ponytail: if a value ever legitimately needs a literal "op:" prefix or a
|
||||||
|
* comma inside a multi-value filter, switch that filter to a JSON-in-one-param
|
||||||
|
* encoding rather than trying to escape these two characters.
|
||||||
|
*/
|
||||||
|
export function encodeFilterValue(type: FilterDef["type"], value: FilterValue): string {
|
||||||
|
const csv = value.v.map(encodeURIComponent).join(",");
|
||||||
|
return value.op === DEFAULT_OP[type] ? csv : `${value.op}:${csv}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of `encodeFilterValue`. Returns null for anything malformed — a bad
|
||||||
|
* URL is user input and must degrade to "filter not applied", never throw. */
|
||||||
|
export function decodeFilterValue(type: FilterDef["type"], raw: string): FilterValue | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const firstColon = raw.indexOf(":");
|
||||||
|
let op: Operator = DEFAULT_OP[type];
|
||||||
|
let rest = raw;
|
||||||
|
if (firstColon > 0) {
|
||||||
|
const prefix = raw.slice(0, firstColon);
|
||||||
|
if ((OPERATORS as string[]).includes(prefix)) {
|
||||||
|
op = prefix as Operator;
|
||||||
|
rest = raw.slice(firstColon + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Text filters are single-value and never comma-split, so a literal comma
|
||||||
|
// in a search term round-trips unchanged.
|
||||||
|
const v =
|
||||||
|
type === "text"
|
||||||
|
? [decodeURIComponent(rest)]
|
||||||
|
: rest.split(",").filter(Boolean).map(decodeURIComponent);
|
||||||
|
if (v.length === 0) return null;
|
||||||
|
// A malformed range (wrong arity) has no safe single-sided interpretation —
|
||||||
|
// "between:2026-01-01" doesn't say whether that's the from or the to — so
|
||||||
|
// it degrades to "filter not applied" rather than guessing a half-open range.
|
||||||
|
if (op === "between" && v.length !== 2) return null;
|
||||||
|
return { op, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every FilterDef's current value, parsed from the URL. Unknown/malformed entries are dropped. */
|
||||||
|
export function parseFilters(
|
||||||
|
defs: FilterDef[],
|
||||||
|
params: URLSearchParams,
|
||||||
|
ns?: string,
|
||||||
|
): Record<string, FilterValue> {
|
||||||
|
const out: Record<string, FilterValue> = {};
|
||||||
|
for (const def of defs) {
|
||||||
|
const raw = params.get(nsKey(def.key, ns));
|
||||||
|
if (!raw) continue;
|
||||||
|
const value = decodeFilterValue(def.type, raw);
|
||||||
|
if (value) out[def.key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write (or delete) one filter's value into a URLSearchParams, returning a new instance. */
|
||||||
|
export function writeFilter(
|
||||||
|
params: URLSearchParams,
|
||||||
|
def: FilterDef,
|
||||||
|
value: FilterValue | undefined,
|
||||||
|
ns?: string,
|
||||||
|
): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
const key = nsKey(def.key, ns);
|
||||||
|
if (!value || value.v.length === 0) next.delete(key);
|
||||||
|
else next.set(key, encodeFilterValue(def.type, value));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nsKey(key: string, ns?: string): string {
|
||||||
|
return ns ? `${ns}.${key}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `?sort=field:DIR` -> `"field:DIR"`, defaulting when absent/unrecognized. */
|
||||||
|
export function parseSort(params: URLSearchParams, options: { value: string }[], fallback: string): string {
|
||||||
|
const raw = params.get("sort");
|
||||||
|
if (raw && options.some((o) => o.value === raw)) return raw;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten every def's parsed value into the flat param object a page's
|
||||||
|
* react-query filter object / axios params already expect. `toParams`
|
||||||
|
* defaults to `{ [key]: v.join(",") }`, which reproduces exactly what
|
||||||
|
* `?statuses=A,B` meant before this bar existed.
|
||||||
|
*/
|
||||||
|
export function toApiParams(
|
||||||
|
defs: FilterDef[],
|
||||||
|
values: Record<string, FilterValue>,
|
||||||
|
): Record<string, string | undefined> {
|
||||||
|
const out: Record<string, string | undefined> = {};
|
||||||
|
for (const def of defs) {
|
||||||
|
const value = values[def.key];
|
||||||
|
if (!value) continue;
|
||||||
|
const mapped = def.toParams ? def.toParams(value) : { [def.key]: value.v.join(",") };
|
||||||
|
Object.assign(out, mapped);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete emptystring/undefined/null entries — never send them, never write them to the URL. */
|
||||||
|
export function cleanParams<T extends Record<string, unknown>>(params: T): Partial<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ""),
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import type { DataTablePagination, DataTableProps } from "@edr/ui-common";
|
||||||
|
import type { FilterDef, FilterValue } from "./types";
|
||||||
|
import { cleanParams, parseFilters, toApiParams, writeFilter } from "./url";
|
||||||
|
|
||||||
|
export interface UseFiltersOptions {
|
||||||
|
/** value = "field:DIR", matching the codebase's existing sort convention. */
|
||||||
|
defaultSort?: string;
|
||||||
|
pageSize?: number;
|
||||||
|
/** "page" (freight: {page,pageSize}) or "skip" (record-management: {skip,take}). */
|
||||||
|
paginationStyle?: "page" | "skip";
|
||||||
|
/** Namespaces URL keys ("<ns>.<key>") for pages with two independent tables. */
|
||||||
|
ns?: string;
|
||||||
|
/** Search box debounce, ms. */
|
||||||
|
searchDebounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFilters {
|
||||||
|
values: Record<string, FilterValue>;
|
||||||
|
/** Flat params ready for the react-query key + axios `params` — IS the query key input. */
|
||||||
|
params: Record<string, string | number>;
|
||||||
|
sort: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
searchText: string;
|
||||||
|
setSearchText: (s: string) => void;
|
||||||
|
setFilter: (key: string, value: FilterValue | undefined) => void;
|
||||||
|
removeFilter: (key: string) => void;
|
||||||
|
clearFilters: () => void;
|
||||||
|
setSort: (s: string) => void;
|
||||||
|
setPage: (p: number) => void;
|
||||||
|
activeCount: number;
|
||||||
|
/** Spread onto <DataTable/>. Same shape useListControls.tableProps returns today. */
|
||||||
|
tableProps: (total: number) => Pick<DataTableProps<any, any>, "pagination" | "tableOptions">;
|
||||||
|
/** Replace the whole URL (saved-view restore). Pushes, so Back undoes it. */
|
||||||
|
applyQueryString: (query: string) => void;
|
||||||
|
/** Current filter state as a raw query string, for saving as a view (page stripped). */
|
||||||
|
currentQueryString: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}): UseFilters {
|
||||||
|
const {
|
||||||
|
defaultSort = "",
|
||||||
|
pageSize: defaultPageSize = DEFAULT_PAGE_SIZE,
|
||||||
|
paginationStyle = "page",
|
||||||
|
ns,
|
||||||
|
searchDebounceMs = 300,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [sp, setSp] = useSearchParams();
|
||||||
|
const searchKey = ns ? `${ns}.q` : "q";
|
||||||
|
const pageKey = ns ? `${ns}.page` : "page";
|
||||||
|
|
||||||
|
const values = useMemo(() => parseFilters(defs, sp, ns), [defs, sp, ns]);
|
||||||
|
const sort = sp.get(ns ? `${ns}.sort` : "sort") ?? defaultSort;
|
||||||
|
const page = Math.max(1, Number(sp.get(pageKey)) || 1);
|
||||||
|
const pageSize = defaultPageSize;
|
||||||
|
|
||||||
|
// Free text: local draft debounced into the URL with `replace`, so typing
|
||||||
|
// leaves exactly one history entry instead of one per keystroke.
|
||||||
|
const [searchText, setSearchTextState] = useState(() => sp.get(searchKey) ?? "");
|
||||||
|
const [debouncedSearch] = useDebouncedValue(searchText, searchDebounceMs);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlValue = sp.get(searchKey) ?? "";
|
||||||
|
if (urlValue === debouncedSearch) return;
|
||||||
|
setSp(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (debouncedSearch) next.set(searchKey, debouncedSearch);
|
||||||
|
else next.delete(searchKey);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// External navigation (back/forward, saved-view restore, deep link) —
|
||||||
|
// sync the local draft from the URL. Comparing against the debounced
|
||||||
|
// value (not `searchText`) is what stops this from clobbering an
|
||||||
|
// in-flight keystroke: mid-type, urlValue !== debouncedSearch is expected.
|
||||||
|
const urlValue = sp.get(searchKey) ?? "";
|
||||||
|
if (urlValue !== debouncedSearch) setSearchTextState(urlValue);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sp]);
|
||||||
|
|
||||||
|
const setFilter = useCallback(
|
||||||
|
(key: string, value: FilterValue | undefined) => {
|
||||||
|
const def = defs.find((d) => d.key === key);
|
||||||
|
if (!def) return;
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = writeFilter(prev, def, value, ns);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defs, ns, pageKey, setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeFilter = useCallback((key: string) => setFilter(key, undefined), [setFilter]);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
for (const def of defs) next.delete(ns ? `${ns}.${def.key}` : def.key);
|
||||||
|
next.delete(searchKey);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSearchTextState("");
|
||||||
|
}, [defs, ns, pageKey, searchKey, setSp]);
|
||||||
|
|
||||||
|
const setSort = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
const key = ns ? `${ns}.sort` : "sort";
|
||||||
|
if (value === defaultSort) next.delete(key);
|
||||||
|
else next.set(key, value);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaultSort, ns, pageKey, setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPage = useCallback(
|
||||||
|
(p: number) => {
|
||||||
|
setSp((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (p <= 1) next.delete(pageKey);
|
||||||
|
else next.set(pageKey, String(p));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[pageKey, setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = useMemo(() => {
|
||||||
|
const filterParams = toApiParams(defs, values);
|
||||||
|
const base: Record<string, string | number | undefined> =
|
||||||
|
paginationStyle === "skip"
|
||||||
|
? { skip: (page - 1) * pageSize, take: pageSize }
|
||||||
|
: { page, pageSize };
|
||||||
|
if (debouncedSearch) base.search = debouncedSearch;
|
||||||
|
if (sort) {
|
||||||
|
if (paginationStyle === "skip") base.orderBy = sort;
|
||||||
|
else {
|
||||||
|
const [sortBy, sortOrder] = sort.split(":");
|
||||||
|
base.sortBy = sortBy;
|
||||||
|
base.sortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanParams({ ...filterParams, ...base }) as Record<string, string | number>;
|
||||||
|
}, [defs, values, paginationStyle, page, pageSize, debouncedSearch, sort]);
|
||||||
|
|
||||||
|
const activeCount = Object.keys(values).length + (debouncedSearch ? 1 : 0);
|
||||||
|
|
||||||
|
const tableProps = useCallback(
|
||||||
|
(total: number): Pick<DataTableProps<any, any>, "pagination" | "tableOptions"> => {
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const pagination: DataTablePagination = { pageIndex: page - 1, pageSize, pageCount, totalCount: total };
|
||||||
|
return {
|
||||||
|
pagination,
|
||||||
|
tableOptions: {
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
state: { pagination: { pageIndex: page - 1, pageSize } },
|
||||||
|
onPaginationChange: (updater) => {
|
||||||
|
const current = { pageIndex: page - 1, pageSize };
|
||||||
|
const next = typeof updater === "function" ? updater(current) : updater;
|
||||||
|
setPage(next.pageIndex + 1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, pageSize, setPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyQueryString = useCallback(
|
||||||
|
(query: string) => setSp(new URLSearchParams(query)),
|
||||||
|
[setSp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentQueryString = useCallback(() => {
|
||||||
|
const next = new URLSearchParams(sp);
|
||||||
|
next.delete(pageKey);
|
||||||
|
return next.toString();
|
||||||
|
}, [sp, pageKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
params,
|
||||||
|
sort,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
searchText,
|
||||||
|
setSearchText: setSearchTextState,
|
||||||
|
setFilter,
|
||||||
|
removeFilter,
|
||||||
|
clearFilters,
|
||||||
|
setSort,
|
||||||
|
setPage,
|
||||||
|
activeCount,
|
||||||
|
tableProps,
|
||||||
|
applyQueryString,
|
||||||
|
currentQueryString,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toApiParams } from "./url";
|
||||||
@@ -3,20 +3,11 @@ import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Collapse,
|
|
||||||
Group,
|
|
||||||
MultiSelect,
|
|
||||||
Select,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
|
||||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -24,20 +15,16 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
FilterX,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Repeat,
|
Repeat,
|
||||||
Search,
|
|
||||||
User,
|
User,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||||
import { FilterToggle } from "@/components/common/FilterToggle";
|
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import {
|
import {
|
||||||
@@ -61,9 +48,9 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
usePagination,
|
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
|
||||||
|
|
||||||
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
|
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
|
||||||
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
|
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
|
||||||
@@ -112,99 +99,74 @@ const COLUMN_META = {
|
|||||||
cellClassName: "whitespace-normal break-words align-top",
|
cellClassName: "whitespace-normal break-words align-top",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
|
||||||
function startOfDayIso(d: Date): string {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setHours(0, 0, 0, 0);
|
|
||||||
return x.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local end-of-day → ISO, for inclusive "to" date filters. */
|
|
||||||
function endOfDayIso(d: Date): string {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setHours(23, 59, 59, 999);
|
|
||||||
return x.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContractRequestsPage() {
|
export default function ContractRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
|
||||||
// Filter controls (empty/null = "all").
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
|
||||||
const { filterOptions } = useMyTradeAccess();
|
const { filterOptions } = useMyTradeAccess();
|
||||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
|
||||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
|
// Static shape only (no facet counts) — this is what useFilters needs to
|
||||||
null,
|
// parse the URL and build API params. Counts are attached separately below,
|
||||||
|
// for rendering only, once the summary query (which itself depends on
|
||||||
|
// these params) has resolved.
|
||||||
|
const filterDefs: FilterDef[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||||
|
{
|
||||||
|
key: "contractKind",
|
||||||
|
label: "Kind",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: CONTRACT_KIND_OPTIONS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tradeDirection",
|
||||||
|
label: "Direction",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
||||||
|
secondary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "freightType",
|
||||||
|
label: "Freight",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: FREIGHT_TYPE_OPTIONS,
|
||||||
|
secondary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "paymentCurrency",
|
||||||
|
label: "Currency",
|
||||||
|
type: "enum",
|
||||||
|
multiple: false,
|
||||||
|
options: CURRENCY_OPTIONS,
|
||||||
|
secondary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
label: "Created",
|
||||||
|
type: "date",
|
||||||
|
secondary: true,
|
||||||
|
// DateBody already converts to start/end-of-day ISO before calling
|
||||||
|
// onChange, so this just routes to the API's existing param names.
|
||||||
|
toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[filterOptions],
|
||||||
);
|
);
|
||||||
const [kindFilter, setKindFilter] = useState<string | null>(null);
|
|
||||||
const [currencyFilter, setCurrencyFilter] = useState<string | null>(null);
|
|
||||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
|
||||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
|
||||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
|
||||||
// All filters start empty (no URL params on this page), so collapsed is safe.
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
|
|
||||||
const resetPage = useCallback(() => {
|
const controls = useFilters(filterDefs, {
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
defaultSort: "createdAt:DESC",
|
||||||
}, [setPagination, pagination.pageSize]);
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
const filter: ContractListFilter = useMemo(() => {
|
const filter: ContractListFilter = useMemo(
|
||||||
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
|
() => ({
|
||||||
return {
|
...(controls.params as unknown as ContractListFilter),
|
||||||
page: pagination.pageIndex + 1,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
|
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
|
||||||
tab: "all",
|
tab: "all",
|
||||||
// Server-side free-text search (contract reference, customer name).
|
}),
|
||||||
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
[controls.params],
|
||||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
);
|
||||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
|
||||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
|
||||||
...(kindFilter ? { contractKind: kindFilter } : {}),
|
|
||||||
...(currencyFilter ? { paymentCurrency: currencyFilter } : {}),
|
|
||||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
|
||||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
pagination.pageIndex,
|
|
||||||
pagination.pageSize,
|
|
||||||
debouncedQuery,
|
|
||||||
statusFilter,
|
|
||||||
directionFilter,
|
|
||||||
freightTypeFilter,
|
|
||||||
kindFilter,
|
|
||||||
currencyFilter,
|
|
||||||
createdFrom,
|
|
||||||
createdTo,
|
|
||||||
sort,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const activeFilterCount =
|
|
||||||
(statusFilter.length ? 1 : 0) +
|
|
||||||
(directionFilter ? 1 : 0) +
|
|
||||||
(freightTypeFilter ? 1 : 0) +
|
|
||||||
(kindFilter ? 1 : 0) +
|
|
||||||
(currencyFilter ? 1 : 0) +
|
|
||||||
(createdFrom || createdTo ? 1 : 0);
|
|
||||||
|
|
||||||
// Badge on the advanced-filters toggle — active filters hidden behind it.
|
|
||||||
const advancedFilterCount =
|
|
||||||
activeFilterCount - (statusFilter.length ? 1 : 0) - (kindFilter ? 1 : 0);
|
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
|
||||||
setStatusFilter([]);
|
|
||||||
setDirectionFilter(null);
|
|
||||||
setFreightTypeFilter(null);
|
|
||||||
setKindFilter(null);
|
|
||||||
setCurrencyFilter(null);
|
|
||||||
setCreatedFrom(null);
|
|
||||||
setCreatedTo(null);
|
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
||||||
}, [setPagination, pagination.pageSize]);
|
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch, isFetching } =
|
const { data, isLoading, isError, refetch, isFetching } =
|
||||||
useContractList(filter);
|
useContractList(filter);
|
||||||
@@ -214,13 +176,27 @@ export default function ContractRequestsPage() {
|
|||||||
refetch: refetchSummary,
|
refetch: refetchSummary,
|
||||||
} = useContractListSummary(filter);
|
} = useContractListSummary(filter);
|
||||||
|
|
||||||
|
const statusCounts = useMemo(
|
||||||
|
() => Object.fromEntries((summary?.facets?.status ?? []).map((b) => [b.value, b.count])),
|
||||||
|
[summary?.facets],
|
||||||
|
);
|
||||||
|
|
||||||
|
// filterDefs + counts, for the bar to render. Kept separate from filterDefs
|
||||||
|
// itself so the URL-parsing hook above never has to wait on this query.
|
||||||
|
const defs: FilterDef[] = useMemo(
|
||||||
|
() =>
|
||||||
|
filterDefs.map((d) =>
|
||||||
|
d.key === "statuses" && d.type === "enum" ? { ...d, counts: statusCounts } : d,
|
||||||
|
),
|
||||||
|
[filterDefs, statusCounts],
|
||||||
|
);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => (data?.items ?? []).map(toContractListRow),
|
() => (data?.items ?? []).map(toContractListRow),
|
||||||
[data?.items],
|
[data?.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
||||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||||
|
|
||||||
const metrics = summary?.metrics;
|
const metrics = summary?.metrics;
|
||||||
@@ -450,153 +426,14 @@ export default function ContractRequestsPage() {
|
|||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="sm" pb="xs" w="100%">
|
||||||
<Stack gap="sm">
|
<FilterBar
|
||||||
<Group gap="sm" wrap="wrap">
|
defs={defs}
|
||||||
<TextInput
|
controls={controls}
|
||||||
placeholder="Search reference or customer…"
|
searchPlaceholder="Search reference or customer…"
|
||||||
leftSection={<Search size={18} />}
|
sortOptions={SORT_OPTIONS}
|
||||||
value={query}
|
viewId="contract-requests"
|
||||||
onChange={(e) => {
|
/>
|
||||||
setQuery(e.target.value);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
rightSection={
|
|
||||||
query && (
|
|
||||||
<ActionIcon
|
|
||||||
size="sm"
|
|
||||||
color="gray"
|
|
||||||
radius="md"
|
|
||||||
variant="transparent"
|
|
||||||
onClick={() => {
|
|
||||||
setQuery("");
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{ flex: 1, minWidth: "200px" }}
|
|
||||||
radius="lg"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
data={SORT_OPTIONS}
|
|
||||||
value={sort}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSort(v ?? "createdAt:DESC");
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
allowDeselect={false}
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 170 }}
|
|
||||||
aria-label="Sort contracts"
|
|
||||||
/>
|
|
||||||
<MultiSelect
|
|
||||||
placeholder={
|
|
||||||
statusFilter.length ? undefined : "All statuses"
|
|
||||||
}
|
|
||||||
data={STATUS_OPTIONS}
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setStatusFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
searchable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 220 }}
|
|
||||||
aria-label="Filter by status"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All kinds"
|
|
||||||
data={CONTRACT_KIND_OPTIONS}
|
|
||||||
value={kindFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setKindFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 160 }}
|
|
||||||
aria-label="Filter by contract kind"
|
|
||||||
/>
|
|
||||||
<FilterToggle
|
|
||||||
count={advancedFilterCount}
|
|
||||||
expanded={showAdvanced}
|
|
||||||
onClick={() => setShowAdvanced((v) => !v)}
|
|
||||||
/>
|
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
radius="lg"
|
|
||||||
leftSection={<FilterX size={16} />}
|
|
||||||
onClick={clearFilters}
|
|
||||||
>
|
|
||||||
Clear filters ({activeFilterCount})
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
<Collapse expanded={showAdvanced}>
|
|
||||||
<Group gap="sm" wrap="wrap">
|
|
||||||
<Select
|
|
||||||
placeholder="All directions"
|
|
||||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
|
||||||
value={directionFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setDirectionFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
aria-label="Filter by trade direction"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All freight types"
|
|
||||||
data={FREIGHT_TYPE_OPTIONS}
|
|
||||||
value={freightTypeFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setFreightTypeFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 160 }}
|
|
||||||
aria-label="Filter by freight type"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All currencies"
|
|
||||||
data={CURRENCY_OPTIONS}
|
|
||||||
value={currencyFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setCurrencyFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 140 }}
|
|
||||||
aria-label="Filter by payment currency"
|
|
||||||
/>
|
|
||||||
<DatePickerInput
|
|
||||||
type="range"
|
|
||||||
placeholder="Created date range"
|
|
||||||
value={[createdFrom, createdTo]}
|
|
||||||
onChange={([from, to]) => {
|
|
||||||
setCreatedFrom(from ? new Date(from) : null);
|
|
||||||
setCreatedTo(to ? new Date(to) : null);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
presets={getDateRangePresets()}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 220 }}
|
|
||||||
aria-label="Created date range"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Collapse>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showEmpty ? (
|
{showEmpty ? (
|
||||||
@@ -615,18 +452,7 @@ export default function ContractRequestsPage() {
|
|||||||
isLoading ? "loading" : isError ? "error" : "success"
|
isLoading ? "loading" : isError ? "error" : "success"
|
||||||
}
|
}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
pagination={{
|
{...controls.tableProps(total)}
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
}}
|
|
||||||
// table-fixed makes the per-column widths stick; without
|
// table-fixed makes the per-column widths stick; without
|
||||||
// it auto-layout re-widens columns once cells wrap.
|
// it auto-layout re-widens columns once cells wrap.
|
||||||
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ export interface ContractListSummaryTabs {
|
|||||||
export interface ContractListSummary {
|
export interface ContractListSummary {
|
||||||
metrics: ContractListSummaryMetrics;
|
metrics: ContractListSummaryMetrics;
|
||||||
tabs: ContractListSummaryTabs;
|
tabs: ContractListSummaryTabs;
|
||||||
|
/**
|
||||||
|
* Per-column value counts for the filter bar's enum popovers, scoped to
|
||||||
|
* every OTHER currently-active filter. Optional: pages built before the
|
||||||
|
* pill filter bar don't read it, and it degrades gracefully — an absent
|
||||||
|
* key just means that popover shows no counts.
|
||||||
|
*/
|
||||||
|
facets?: Record<string, { value: string; count: number }[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContractView {
|
export interface ContractView {
|
||||||
|
|||||||
Reference in New Issue
Block a user