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 -1
View File
@@ -81,7 +81,7 @@ export function MarkMenu(props: Props): JSX.Element {
<button
ref={button}
type="button"
class="flex min-h-9 w-44 items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-muted"
class="flex min-h-9 w-36 items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-muted"
aria-haspopup="true"
aria-expanded={isOpen()}
aria-controls={isOpen() ? menuId : undefined}
+10 -10
View File
@@ -14,7 +14,7 @@ import {
import { api } from '@/api/client';
import { errorText } from '@/lib/errors';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format';
import { formatDate, formatDateTime, formatMoney } from '@/lib/format';
import { noticeSite, uuidLabel } from '@/lib/opportunities';
import type { Opportunity } from '@/types/Opportunities';
import { ReleaseTabs } from './ReleaseTabs';
@@ -102,19 +102,19 @@ export function OpportunityDialog(props: Props): JSX.Element {
<dl class={detailList}>
<dt class="text-muted">Status</dt>
<dd>
<Show when={opportunity().status} fallback="—">
<span
class={`inline-block rounded px-2 py-0.5 font-semibold ${statusBadge(opportunity().status)}`}
>
{statusLabel(opportunity().status)}
</span>
</Show>
<span
class={`inline-block rounded px-2 py-0.5 font-semibold ${statusBadge(opportunity().status)}`}
>
{statusLabel(opportunity().status)}
</span>
</dd>
<dt class="text-muted">Published</dt>
<dd>{formatDate(opportunity().latest_release_date)}</dd>
<dt class="text-muted">Start</dt>
<dt class="text-muted">Closing</dt>
<dd>{formatDateTime(opportunity().closing_date)}</dd>
<dt class="text-muted">Contract start</dt>
<dd>{formatDate(opportunity().start_date)}</dd>
<dt class="text-muted">End</dt>
<dt class="text-muted">Contract end</dt>
<dd>{formatDate(opportunity().end_date)}</dd>
<dt class="text-muted">Value</dt>
<dd>{formatMoney(opportunity().gross_value)}</dd>
+31 -21
View File
@@ -9,7 +9,7 @@ import { createMemo, createSignal, For, Match, Show, Switch } from 'solid-js';
import { api } from '@/api/client';
import { errorText } from '@/lib/errors';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format';
import { formatDate, formatDateTime, formatMoney, parseDate } from '@/lib/format';
import type { NoticeSite } from '@/lib/opportunities';
import { latestRelease, noticeSite } from '@/lib/opportunities';
import type { OpportunitySort, SortColumn } from '@/lib/sortOpportunities';
@@ -34,6 +34,7 @@ interface Props {
const th =
'sticky top-0 z-10 whitespace-nowrap border-b border-line bg-surface p-3 text-left text-sm font-semibold text-muted';
const td = 'border-b border-line-soft p-3 align-middle';
const badge = 'inline-block rounded px-2 py-0.5 text-sm font-semibold whitespace-nowrap';
const sortIcon = { size: 14, strokeWidth: 2, 'aria-hidden': true } as const;
const iconButton =
'flex rounded-md border border-line bg-surface p-1 text-ink hover:enabled:border-muted disabled:opacity-60';
@@ -111,14 +112,6 @@ export function OpportunityTable(props: Props): JSX.Element {
<SortableHeader column="status" sort={props.sort} onSort={(next) => props.onSort(next)}>
Status
</SortableHeader>
<SortableHeader
column="smeSuitable"
sort={props.sort}
onSort={(next) => props.onSort(next)}
class="text-center"
>
SME suitable
</SortableHeader>
<SortableHeader
column="published"
sort={props.sort}
@@ -126,6 +119,13 @@ export function OpportunityTable(props: Props): JSX.Element {
>
Published
</SortableHeader>
<SortableHeader
column="closing"
sort={props.sort}
onSort={(next) => props.onSort(next)}
>
Closing
</SortableHeader>
<SortableHeader column="start" sort={props.sort} onSort={(next) => props.onSort(next)}>
Start
</SortableHeader>
@@ -272,20 +272,30 @@ function OpportunityRow(props: RowProps): JSX.Element {
</div>
</td>
<td class={td}>
<span
class={`inline-block rounded px-2 py-0.5 text-sm font-semibold whitespace-nowrap ${statusBadge(opportunity().status)}`}
>
{statusLabel(opportunity().status)}
</span>
</td>
<td class={`${td} text-center`}>
{opportunity().sme_suitable ? (
<span class="font-semibold text-mark-won">Yes</span>
) : (
<span class="text-muted">No</span>
)}
<div class="flex flex-wrap items-center gap-1.5">
<span class={`${badge} ${statusBadge(opportunity().status)}`}>
{statusLabel(opportunity().status)}
</span>
{/* Outlined, as it's a property of the opportunity, not a status. */}
<Show when={opportunity().sme_suitable}>
<span class={`${badge} text-ink ring-1 ring-line ring-inset`} title="Suitable for SMEs">
SME
</span>
</Show>
</div>
</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().latest_release_date)}</td>
{/* The time a bid is due by is in the tooltip, keeping the column narrow. */}
<td
class={`${td} whitespace-nowrap`}
title={
parseDate(opportunity().closing_date)
? formatDateTime(opportunity().closing_date)
: undefined
}
>
{formatDate(opportunity().closing_date)}
</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().start_date)}</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().end_date)}</td>
<td class={`${td} text-right whitespace-nowrap`}>{formatMoney(opportunity().gross_value)}</td>
+4 -1
View File
@@ -3,7 +3,10 @@ import type { OpportunityMark } from '@/types/Annotations';
// Tailwind only generates classes it can see written out in full, so these
// lookups list every class literally rather than building names at runtime.
/** Badge colours for each known status; others get a neutral badge. */
/**
* Badge colours for each known status; the others get a neutral badge, as
* cancelled, withdrawn and unknown (empty) ones do.
*/
export const STATUS_BADGE: Record<string, string> = {
planning: 'bg-status-planning-soft text-status-planning',
planned: 'bg-status-planning-soft text-status-planning',