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:
1 parent
c678b02662
commit
3bb2711276
10 files changed
+65
-42
No files matched your search
@@ -81,7 +81,7 @@ export function MarkMenu(props: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
ref={button}
|
ref={button}
|
||||||
type="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-haspopup="true"
|
||||||
aria-expanded={isOpen()}
|
aria-expanded={isOpen()}
|
||||||
aria-controls={isOpen() ? menuId : undefined}
|
aria-controls={isOpen() ? menuId : undefined}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { errorText } from '@/lib/errors';
|
import { errorText } from '@/lib/errors';
|
||||||
import { statusLabel } from '@/lib/filterOpportunities';
|
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 { noticeSite, uuidLabel } from '@/lib/opportunities';
|
||||||
import type { Opportunity } from '@/types/Opportunities';
|
import type { Opportunity } from '@/types/Opportunities';
|
||||||
import { ReleaseTabs } from './ReleaseTabs';
|
import { ReleaseTabs } from './ReleaseTabs';
|
||||||
@@ -102,19 +102,19 @@ export function OpportunityDialog(props: Props): JSX.Element {
|
|||||||
<dl class={detailList}>
|
<dl class={detailList}>
|
||||||
<dt class="text-muted">Status</dt>
|
<dt class="text-muted">Status</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<Show when={opportunity().status} fallback="—">
|
<span
|
||||||
<span
|
class={`inline-block rounded px-2 py-0.5 font-semibold ${statusBadge(opportunity().status)}`}
|
||||||
class={`inline-block rounded px-2 py-0.5 font-semibold ${statusBadge(opportunity().status)}`}
|
>
|
||||||
>
|
{statusLabel(opportunity().status)}
|
||||||
{statusLabel(opportunity().status)}
|
</span>
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="text-muted">Published</dt>
|
<dt class="text-muted">Published</dt>
|
||||||
<dd>{formatDate(opportunity().latest_release_date)}</dd>
|
<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>
|
<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>
|
<dd>{formatDate(opportunity().end_date)}</dd>
|
||||||
<dt class="text-muted">Value</dt>
|
<dt class="text-muted">Value</dt>
|
||||||
<dd>{formatMoney(opportunity().gross_value)}</dd>
|
<dd>{formatMoney(opportunity().gross_value)}</dd>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createMemo, createSignal, For, Match, Show, Switch } from 'solid-js';
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { errorText } from '@/lib/errors';
|
import { errorText } from '@/lib/errors';
|
||||||
import { statusLabel } from '@/lib/filterOpportunities';
|
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 type { NoticeSite } from '@/lib/opportunities';
|
||||||
import { latestRelease, noticeSite } from '@/lib/opportunities';
|
import { latestRelease, noticeSite } from '@/lib/opportunities';
|
||||||
import type { OpportunitySort, SortColumn } from '@/lib/sortOpportunities';
|
import type { OpportunitySort, SortColumn } from '@/lib/sortOpportunities';
|
||||||
@@ -34,6 +34,7 @@ interface Props {
|
|||||||
const th =
|
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';
|
'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 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 sortIcon = { size: 14, strokeWidth: 2, 'aria-hidden': true } as const;
|
||||||
const iconButton =
|
const iconButton =
|
||||||
'flex rounded-md border border-line bg-surface p-1 text-ink hover:enabled:border-muted disabled:opacity-60';
|
'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)}>
|
<SortableHeader column="status" sort={props.sort} onSort={(next) => props.onSort(next)}>
|
||||||
Status
|
Status
|
||||||
</SortableHeader>
|
</SortableHeader>
|
||||||
<SortableHeader
|
|
||||||
column="smeSuitable"
|
|
||||||
sort={props.sort}
|
|
||||||
onSort={(next) => props.onSort(next)}
|
|
||||||
class="text-center"
|
|
||||||
>
|
|
||||||
SME suitable
|
|
||||||
</SortableHeader>
|
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
column="published"
|
column="published"
|
||||||
sort={props.sort}
|
sort={props.sort}
|
||||||
@@ -126,6 +119,13 @@ export function OpportunityTable(props: Props): JSX.Element {
|
|||||||
>
|
>
|
||||||
Published
|
Published
|
||||||
</SortableHeader>
|
</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)}>
|
<SortableHeader column="start" sort={props.sort} onSort={(next) => props.onSort(next)}>
|
||||||
Start
|
Start
|
||||||
</SortableHeader>
|
</SortableHeader>
|
||||||
@@ -272,20 +272,30 @@ function OpportunityRow(props: RowProps): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class={td}>
|
<td class={td}>
|
||||||
<span
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
class={`inline-block rounded px-2 py-0.5 text-sm font-semibold whitespace-nowrap ${statusBadge(opportunity().status)}`}
|
<span class={`${badge} ${statusBadge(opportunity().status)}`}>
|
||||||
>
|
{statusLabel(opportunity().status)}
|
||||||
{statusLabel(opportunity().status)}
|
</span>
|
||||||
</span>
|
{/* Outlined, as it's a property of the opportunity, not a status. */}
|
||||||
</td>
|
<Show when={opportunity().sme_suitable}>
|
||||||
<td class={`${td} text-center`}>
|
<span class={`${badge} text-ink ring-1 ring-line ring-inset`} title="Suitable for SMEs">
|
||||||
{opportunity().sme_suitable ? (
|
SME
|
||||||
<span class="font-semibold text-mark-won">Yes</span>
|
</span>
|
||||||
) : (
|
</Show>
|
||||||
<span class="text-muted">No</span>
|
</div>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().latest_release_date)}</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().start_date)}</td>
|
||||||
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().end_date)}</td>
|
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().end_date)}</td>
|
||||||
<td class={`${td} text-right whitespace-nowrap`}>{formatMoney(opportunity().gross_value)}</td>
|
<td class={`${td} text-right whitespace-nowrap`}>{formatMoney(opportunity().gross_value)}</td>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type { OpportunityMark } from '@/types/Annotations';
|
|||||||
// Tailwind only generates classes it can see written out in full, so these
|
// Tailwind only generates classes it can see written out in full, so these
|
||||||
// lookups list every class literally rather than building names at runtime.
|
// 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> = {
|
export const STATUS_BADGE: Record<string, string> = {
|
||||||
planning: 'bg-status-planning-soft text-status-planning',
|
planning: 'bg-status-planning-soft text-status-planning',
|
||||||
planned: 'bg-status-planning-soft text-status-planning',
|
planned: 'bg-status-planning-soft text-status-planning',
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ test('statusOptions lists the known statuses, then others sorted', () => {
|
|||||||
expect(options.slice(-2)).toEqual(['aaa', 'zzz']);
|
expect(options.slice(-2)).toEqual(['aaa', 'zzz']);
|
||||||
expect(options[0]).toBe('planning');
|
expect(options[0]).toBe('planning');
|
||||||
expect(statusLabel('active')).toBe('Active');
|
expect(statusLabel('active')).toBe('Active');
|
||||||
|
expect(statusLabel('')).toBe('Unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stored filters', () => {
|
describe('stored filters', () => {
|
||||||
|
|||||||
@@ -169,6 +169,6 @@ export function statusOptions(opportunities: readonly Opportunity[]): string[] {
|
|||||||
return [...OPPORTUNITY_STATUSES, ...[...extra].sort()];
|
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 =>
|
export const statusLabel = (status: string): string =>
|
||||||
status.charAt(0).toLocaleUpperCase() + status.slice(1);
|
status ? status.charAt(0).toLocaleUpperCase() + status.slice(1) : 'Unknown';
|
||||||
@@ -17,6 +17,7 @@ const opportunities = [
|
|||||||
status: 'complete',
|
status: 'complete',
|
||||||
sme_suitable: true,
|
sme_suitable: true,
|
||||||
latest_release_date: '2026-10-01T12:00:00Z',
|
latest_release_date: '2026-10-01T12:00:00Z',
|
||||||
|
closing_date: '2026-11-20T12:00:00Z',
|
||||||
start_date: '2027-01-01T00:00:00Z',
|
start_date: '2027-01-01T00:00:00Z',
|
||||||
gross_value: 5000,
|
gross_value: 5000,
|
||||||
}),
|
}),
|
||||||
@@ -24,6 +25,7 @@ const opportunities = [
|
|||||||
title: 'Cloud hosting 10',
|
title: 'Cloud hosting 10',
|
||||||
status: 'planning',
|
status: 'planning',
|
||||||
latest_release_date: '2026-10-03T12:00:00Z',
|
latest_release_date: '2026-10-03T12:00:00Z',
|
||||||
|
closing_date: '2026-10-30T17:00:00Z',
|
||||||
end_date: '2027-06-01T00:00:00Z',
|
end_date: '2027-06-01T00:00:00Z',
|
||||||
gross_value: 0,
|
gross_value: 0,
|
||||||
}),
|
}),
|
||||||
@@ -38,6 +40,7 @@ const opportunities = [
|
|||||||
title: '',
|
title: '',
|
||||||
status: 'tendering',
|
status: 'tendering',
|
||||||
latest_release_date: '2026-10-04T12:00:00Z',
|
latest_release_date: '2026-10-04T12:00:00Z',
|
||||||
|
closing_date: '0001-01-01T00:00:00Z',
|
||||||
start_date: '2026-11-01T00:00:00Z',
|
start_date: '2026-11-01T00:00:00Z',
|
||||||
end_date: '2026-12-01T00:00:00Z',
|
end_date: '2026-12-01T00:00:00Z',
|
||||||
gross_value: 300,
|
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'] },
|
{ column: 'title', ascending: ['c', 'b', 'a', 'd'], descending: ['a', 'b', 'c', 'd'] },
|
||||||
// The filters' order; an unknown status after the known ones.
|
// The filters' order; an unknown status after the known ones.
|
||||||
{ column: 'status', ascending: ['b', 'c', 'a', 'd'], descending: ['d', 'a', 'c', 'b'] },
|
{ 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'] },
|
{ column: 'published', ascending: ['a', 'c', 'b', 'd'], descending: ['d', 'b', 'c', 'a'] },
|
||||||
// No date, or Go's zero time, last both ways.
|
// 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: 'start', ascending: ['d', 'a', 'b', 'c'], descending: ['a', 'd', 'b', 'c'] },
|
||||||
{ column: 'end', ascending: ['d', 'b', 'c', 'a'], descending: ['b', 'd', 'c', 'a'] },
|
{ column: 'end', ascending: ['d', 'b', 'c', 'a'], descending: ['b', 'd', 'c', 'a'] },
|
||||||
// No value (0) last both ways.
|
// No value (0) last both ways.
|
||||||
{ column: 'value', ascending: ['d', 'a', 'c', 'b'], descending: ['c', 'a', 'd', 'b'] },
|
{ column: 'value', ascending: ['d', 'a', 'c', 'b'], descending: ['c', 'a', 'd', 'b'] },
|
||||||
// Menu order, a second mark after none; unmarked last.
|
// Menu order, a second mark after none; unmarked last.
|
||||||
{ column: 'mark', ascending: ['c', 'b', 'a', 'd'], descending: ['a', 'b', 'c', 'd'] },
|
{ 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'] },
|
{ column: 'hidden', ascending: ['d', 'c', 'a', 'b'], descending: ['b', 'd', 'c', 'a'] },
|
||||||
])('sorts by $column', ({ column, ascending, descending }) => {
|
])('sorts by $column', ({ column, ascending, descending }) => {
|
||||||
expect(sorted(column, 'ascending')).toEqual(ascending);
|
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, 'title')).toEqual({ column: 'title', direction: 'ascending' });
|
||||||
expect(nextSort(DEFAULT_SORT, 'value')).toEqual({ column: 'value', direction: 'descending' });
|
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({
|
expect(nextSort({ column: 'title', direction: 'ascending' }, 'title')).toEqual({
|
||||||
column: 'title',
|
column: 'title',
|
||||||
direction: 'descending',
|
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":"buyer","direction":"ascending"}')).toEqual(DEFAULT_SORT);
|
||||||
expect(parseSort('{"column":"title","direction":"up"}')).toEqual(DEFAULT_SORT);
|
expect(parseSort('{"column":"title","direction":"up"}')).toEqual(DEFAULT_SORT);
|
||||||
expect(parseSort('["title"]')).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', () => {
|
test('titles sort by their words, ignoring punctuation', () => {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { parseDate } from './format';
|
|||||||
export const SORT_COLUMNS = [
|
export const SORT_COLUMNS = [
|
||||||
'title',
|
'title',
|
||||||
'status',
|
'status',
|
||||||
'smeSuitable',
|
|
||||||
'published',
|
'published',
|
||||||
|
'closing',
|
||||||
'start',
|
'start',
|
||||||
'end',
|
'end',
|
||||||
'value',
|
'value',
|
||||||
@@ -32,13 +32,14 @@ export interface OpportunitySort {
|
|||||||
export const DEFAULT_SORT: OpportunitySort = { column: 'published', direction: 'descending' };
|
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,
|
// 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,
|
// and the rest with the most useful first (the soonest closing date, the
|
||||||
// Yes before No, marks in the menu's order).
|
// latest other dates, highest values, hidden first, marks in the menu's
|
||||||
|
// order).
|
||||||
const FIRST_DIRECTION: Record<SortColumn, SortDirection> = {
|
const FIRST_DIRECTION: Record<SortColumn, SortDirection> = {
|
||||||
title: 'ascending',
|
title: 'ascending',
|
||||||
status: 'ascending',
|
status: 'ascending',
|
||||||
smeSuitable: 'descending',
|
|
||||||
published: 'descending',
|
published: 'descending',
|
||||||
|
closing: 'ascending',
|
||||||
start: 'descending',
|
start: 'descending',
|
||||||
end: 'descending',
|
end: 'descending',
|
||||||
value: 'descending',
|
value: 'descending',
|
||||||
@@ -100,8 +101,8 @@ interface SortRow {
|
|||||||
const SORT_KEYS: Record<SortColumn, (row: SortRow) => SortKey> = {
|
const SORT_KEYS: Record<SortColumn, (row: SortRow) => SortKey> = {
|
||||||
title: ({ opportunity }) => opportunity.title.trim() || null,
|
title: ({ opportunity }) => opportunity.title.trim() || null,
|
||||||
status: ({ opportunity }) => (opportunity.status ? statusOrder(opportunity.status) : null),
|
status: ({ opportunity }) => (opportunity.status ? statusOrder(opportunity.status) : null),
|
||||||
smeSuitable: ({ opportunity }) => Number(opportunity.sme_suitable),
|
|
||||||
published: ({ opportunity }) => dateKey(opportunity.latest_release_date),
|
published: ({ opportunity }) => dateKey(opportunity.latest_release_date),
|
||||||
|
closing: ({ opportunity }) => dateKey(opportunity.closing_date),
|
||||||
start: ({ opportunity }) => dateKey(opportunity.start_date),
|
start: ({ opportunity }) => dateKey(opportunity.start_date),
|
||||||
end: ({ opportunity }) => dateKey(opportunity.end_date),
|
end: ({ opportunity }) => dateKey(opportunity.end_date),
|
||||||
value: ({ opportunity }) => (opportunity.gross_value > 0 ? opportunity.gross_value : null),
|
value: ({ opportunity }) => (opportunity.gross_value > 0 ? opportunity.gross_value : null),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function testOpportunity(uuid: string, overrides: Partial<Opportunity> =
|
|||||||
multi_lot: false,
|
multi_lot: false,
|
||||||
sme_suitable: false,
|
sme_suitable: false,
|
||||||
vcse_suitable: false,
|
vcse_suitable: false,
|
||||||
|
closing_date: null,
|
||||||
start_date: null,
|
start_date: null,
|
||||||
end_date: null,
|
end_date: null,
|
||||||
gross_value: 0,
|
gross_value: 0,
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ export interface components {
|
|||||||
buyer_id: string;
|
buyer_id: string;
|
||||||
buyer_name: string;
|
buyer_name: string;
|
||||||
classifications: components["schemas"]["types.ClassificationCode"][] | null;
|
classifications: components["schemas"]["types.ClassificationCode"][] | null;
|
||||||
|
closing_date: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
gross_value: number;
|
gross_value: number;
|
||||||
|
|||||||
Reference in new issue
Block a user