Added the closing date to the table, and narrowed its columns to fit

The backend's new closing_date, the bid deadline, is a Closing column
before the start and end dates, which are now the contract's: the
details dialog calls them Contract start and Contract end. Closing
sorts soonest first, with no date last, and shows the time it closes
in the cell's tooltip and in the dialog. The API types are regenerated
for it.

To make room, the SME suitable column is now an SME badge beside the
status, shown only when it's suitable, so the Status column sorts by
status alone and a stored SME sort goes back to the default. The Mark
column is narrower. An opportunity without a status is shown as
Unknown, in the colours of the statuses without their own, such as
Cancelled and Withdrawn, rather than as a blank badge or a dash.
This commit is contained in:
bwilliams committed 2026-10-21 14:47:56 +01:00
1 parent c678b02662
commit 3bb2711276
10 files changed
+65 -42

No files matched your search

+1
View File
@@ -97,6 +97,7 @@ test('statusOptions lists the known statuses, then others sorted', () => {
expect(options.slice(-2)).toEqual(['aaa', 'zzz']);
expect(options[0]).toBe('planning');
expect(statusLabel('active')).toBe('Active');
expect(statusLabel('')).toBe('Unknown');
});
describe('stored filters', () => {
+2 -2
View File
@@ -169,6 +169,6 @@ export function statusOptions(opportunities: readonly Opportunity[]): string[] {
return [...OPPORTUNITY_STATUSES, ...[...extra].sort()];
}
/** A status as shown on screen: "active" is "Active". */
/** A status as shown on screen: "active" is "Active", and none is "Unknown". */
export const statusLabel = (status: string): string =>
status.charAt(0).toLocaleUpperCase() + status.slice(1);
status ? status.charAt(0).toLocaleUpperCase() + status.slice(1) : 'Unknown';
+8 -2
View File
@@ -17,6 +17,7 @@ const opportunities = [
status: 'complete',
sme_suitable: true,
latest_release_date: '2026-10-01T12:00:00Z',
closing_date: '2026-11-20T12:00:00Z',
start_date: '2027-01-01T00:00:00Z',
gross_value: 5000,
}),
@@ -24,6 +25,7 @@ const opportunities = [
title: 'Cloud hosting 10',
status: 'planning',
latest_release_date: '2026-10-03T12:00:00Z',
closing_date: '2026-10-30T17:00:00Z',
end_date: '2027-06-01T00:00:00Z',
gross_value: 0,
}),
@@ -38,6 +40,7 @@ const opportunities = [
title: '',
status: 'tendering',
latest_release_date: '2026-10-04T12:00:00Z',
closing_date: '0001-01-01T00:00:00Z',
start_date: '2026-11-01T00:00:00Z',
end_date: '2026-12-01T00:00:00Z',
gross_value: 300,
@@ -62,16 +65,16 @@ test.each<{ column: SortColumn; ascending: string[]; descending: string[] }>([
{ column: 'title', ascending: ['c', 'b', 'a', 'd'], descending: ['a', 'b', 'c', 'd'] },
// The filters' order; an unknown status after the known ones.
{ column: 'status', ascending: ['b', 'c', 'a', 'd'], descending: ['d', 'a', 'c', 'b'] },
// Ties are newest release first.
{ column: 'smeSuitable', ascending: ['d', 'b', 'c', 'a'], descending: ['a', 'd', 'b', 'c'] },
{ column: 'published', ascending: ['a', 'c', 'b', 'd'], descending: ['d', 'b', 'c', 'a'] },
// No date, or Go's zero time, last both ways.
{ column: 'closing', ascending: ['b', 'a', 'd', 'c'], descending: ['a', 'b', 'd', 'c'] },
{ column: 'start', ascending: ['d', 'a', 'b', 'c'], descending: ['a', 'd', 'b', 'c'] },
{ column: 'end', ascending: ['d', 'b', 'c', 'a'], descending: ['b', 'd', 'c', 'a'] },
// No value (0) last both ways.
{ column: 'value', ascending: ['d', 'a', 'c', 'b'], descending: ['c', 'a', 'd', 'b'] },
// Menu order, a second mark after none; unmarked last.
{ column: 'mark', ascending: ['c', 'b', 'a', 'd'], descending: ['a', 'b', 'c', 'd'] },
// Ties are newest release first.
{ column: 'hidden', ascending: ['d', 'c', 'a', 'b'], descending: ['b', 'd', 'c', 'a'] },
])('sorts by $column', ({ column, ascending, descending }) => {
expect(sorted(column, 'ascending')).toEqual(ascending);
@@ -91,6 +94,7 @@ test('nextSort flips the direction, or starts a new column in its first directio
});
expect(nextSort(DEFAULT_SORT, 'title')).toEqual({ column: 'title', direction: 'ascending' });
expect(nextSort(DEFAULT_SORT, 'value')).toEqual({ column: 'value', direction: 'descending' });
expect(nextSort(DEFAULT_SORT, 'closing')).toEqual({ column: 'closing', direction: 'ascending' });
expect(nextSort({ column: 'title', direction: 'ascending' }, 'title')).toEqual({
column: 'title',
direction: 'descending',
@@ -105,6 +109,8 @@ test('parseSort reads serialiseSort back, and falls back to DEFAULT_SORT', () =>
expect(parseSort('{"column":"buyer","direction":"ascending"}')).toEqual(DEFAULT_SORT);
expect(parseSort('{"column":"title","direction":"up"}')).toEqual(DEFAULT_SORT);
expect(parseSort('["title"]')).toEqual(DEFAULT_SORT);
// The SME column was merged into Status.
expect(parseSort('{"column":"smeSuitable","direction":"descending"}')).toEqual(DEFAULT_SORT);
});
test('titles sort by their words, ignoring punctuation', () => {
+6 -5
View File
@@ -8,8 +8,8 @@ import { parseDate } from './format';
export const SORT_COLUMNS = [
'title',
'status',
'smeSuitable',
'published',
'closing',
'start',
'end',
'value',
@@ -32,13 +32,14 @@ export interface OpportunitySort {
export const DEFAULT_SORT: OpportunitySort = { column: 'published', direction: 'descending' };
// The direction a column is sorted in when it's first chosen: text A to Z,
// and the rest with the most useful first (latest dates, highest values,
// Yes before No, marks in the menu's order).
// and the rest with the most useful first (the soonest closing date, the
// latest other dates, highest values, hidden first, marks in the menu's
// order).
const FIRST_DIRECTION: Record<SortColumn, SortDirection> = {
title: 'ascending',
status: 'ascending',
smeSuitable: 'descending',
published: 'descending',
closing: 'ascending',
start: 'descending',
end: 'descending',
value: 'descending',
@@ -100,8 +101,8 @@ interface SortRow {
const SORT_KEYS: Record<SortColumn, (row: SortRow) => SortKey> = {
title: ({ opportunity }) => opportunity.title.trim() || null,
status: ({ opportunity }) => (opportunity.status ? statusOrder(opportunity.status) : null),
smeSuitable: ({ opportunity }) => Number(opportunity.sme_suitable),
published: ({ opportunity }) => dateKey(opportunity.latest_release_date),
closing: ({ opportunity }) => dateKey(opportunity.closing_date),
start: ({ opportunity }) => dateKey(opportunity.start_date),
end: ({ opportunity }) => dateKey(opportunity.end_date),
value: ({ opportunity }) => (opportunity.gross_value > 0 ? opportunity.gross_value : null),
+1
View File
@@ -16,6 +16,7 @@ export function testOpportunity(uuid: string, overrides: Partial<Opportunity> =
multi_lot: false,
sme_suitable: false,
vcse_suitable: false,
closing_date: null,
start_date: null,
end_date: null,
gross_value: 0,