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.
192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
import X from 'lucide-solid/icons/x';
|
|
import type { JSX } from 'solid-js';
|
|
import {
|
|
createEffect,
|
|
createResource,
|
|
createUniqueId,
|
|
For,
|
|
Match,
|
|
onCleanup,
|
|
Show,
|
|
Switch,
|
|
} from 'solid-js';
|
|
|
|
import { api } from '@/api/client';
|
|
import { errorText } from '@/lib/errors';
|
|
import { statusLabel } from '@/lib/filterOpportunities';
|
|
import { formatDate, formatDateTime, formatMoney } from '@/lib/format';
|
|
import { noticeSite, uuidLabel } from '@/lib/opportunities';
|
|
import type { Opportunity } from '@/types/Opportunities';
|
|
import { ReleaseTabs } from './ReleaseTabs';
|
|
import { buttonPrimary, statusBadge } from './styles';
|
|
|
|
interface Props {
|
|
/** The opportunity to show, from the list; undefined when the dialog is closed. */
|
|
opportunity: Opportunity | undefined;
|
|
onClose: () => void;
|
|
/** Called with a message when the opportunity's releases didn't load. */
|
|
onError: (message: string) => void;
|
|
}
|
|
|
|
const detailList = 'grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1.5 text-sm';
|
|
const sectionHeading = 'mb-2 font-semibold';
|
|
|
|
const yesNo = (value: boolean): string => (value ? 'Yes' : 'No');
|
|
|
|
/**
|
|
* A modal dialog with an opportunity's details, and its releases as tabs.
|
|
* The details come from the list, so they show at once; the releases aren't
|
|
* in the list, so the opportunity is fetched with them when the dialog
|
|
* opens. The native <dialog> keeps focus inside, closes on Escape and
|
|
* returns focus to the button that opened it.
|
|
*/
|
|
export function OpportunityDialog(props: Props): JSX.Element {
|
|
let dialog!: HTMLDialogElement;
|
|
const titleId = createUniqueId();
|
|
let controller: AbortController | undefined;
|
|
onCleanup(() => controller?.abort());
|
|
|
|
const [loaded, { refetch }] = createResource(
|
|
() => props.opportunity?.uuid,
|
|
(uuid) => {
|
|
controller?.abort();
|
|
controller = new AbortController();
|
|
return api.getOpportunity(uuid, controller.signal);
|
|
},
|
|
);
|
|
// Reading an errored resource throws, so check its state first.
|
|
const releases = () => (loaded.state === 'ready' ? (loaded().releases ?? []) : []);
|
|
const loadError = () => (loaded.state === 'errored' ? errorText(loaded.error) : undefined);
|
|
|
|
createEffect(() => {
|
|
if (props.opportunity && !dialog.open) dialog.showModal();
|
|
else if (!props.opportunity && dialog.open) dialog.close();
|
|
});
|
|
createEffect(() => {
|
|
const err = loadError();
|
|
if (err !== undefined) props.onError(`The opportunity's releases didn't load: ${err}`);
|
|
});
|
|
|
|
return (
|
|
<dialog
|
|
ref={dialog}
|
|
aria-labelledby={titleId}
|
|
class="m-auto max-h-[90vh] w-[min(64rem,calc(100vw-2rem))] rounded-[10px] border border-line bg-surface p-0 text-ink backdrop:bg-black/50"
|
|
onClose={() => props.onClose()}
|
|
// A click on the backdrop lands on the dialog itself, outside its content.
|
|
onClick={(e) => {
|
|
if (e.target === dialog) dialog.close();
|
|
}}
|
|
>
|
|
<Show when={props.opportunity}>
|
|
{(opportunity) => (
|
|
<div class="flex flex-col gap-6 p-5 sm:p-6">
|
|
<header class="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 id={titleId} class="text-xl font-bold">
|
|
{opportunity().title || 'Untitled opportunity'}
|
|
</h2>
|
|
<p class="mt-1 text-muted">{opportunity().buyer_name}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="rounded-md p-1.5 text-muted hover:text-ink"
|
|
aria-label="Close"
|
|
onClick={() => dialog.close()}
|
|
>
|
|
<X size={20} strokeWidth={2} />
|
|
</button>
|
|
</header>
|
|
|
|
<section class="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
|
<dl class={detailList}>
|
|
<dt class="text-muted">Status</dt>
|
|
<dd>
|
|
<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">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">Contract end</dt>
|
|
<dd>{formatDate(opportunity().end_date)}</dd>
|
|
<dt class="text-muted">Value</dt>
|
|
<dd>{formatMoney(opportunity().gross_value)}</dd>
|
|
<dt class="text-muted">Procurement method</dt>
|
|
<dd>{opportunity().procurement_method || '—'}</dd>
|
|
<dt class="text-muted">Category</dt>
|
|
<dd>{opportunity().main_category || '—'}</dd>
|
|
</dl>
|
|
<dl class={detailList}>
|
|
<dt class="text-muted">Multiple lots</dt>
|
|
<dd>{yesNo(opportunity().multi_lot)}</dd>
|
|
<dt class="text-muted">SME suitable</dt>
|
|
<dd>{yesNo(opportunity().sme_suitable)}</dd>
|
|
<dt class="text-muted">VCSE suitable</dt>
|
|
<dd>{yesNo(opportunity().vcse_suitable)}</dd>
|
|
<dt class="text-muted">Documents</dt>
|
|
<dd>{yesNo(opportunity().has_documents)}</dd>
|
|
<dt class="text-muted">Source</dt>
|
|
<dd>{opportunity().source}</dd>
|
|
<dt class="text-muted">{uuidLabel(opportunity())}</dt>
|
|
<dd class="break-all">{opportunity().uuid}</dd>
|
|
</dl>
|
|
</section>
|
|
|
|
<Show when={opportunity().description}>
|
|
<section>
|
|
<h3 class={sectionHeading}>Description</h3>
|
|
<p class="text-sm whitespace-pre-line">{opportunity().description}</p>
|
|
</section>
|
|
</Show>
|
|
|
|
<Show when={(opportunity().classifications ?? []).length > 0}>
|
|
<section>
|
|
<h3 class={sectionHeading}>Classifications</h3>
|
|
<ul class="flex flex-col gap-1 text-sm">
|
|
<For each={opportunity().classifications ?? []}>
|
|
{(code) => (
|
|
<li>
|
|
<span class="text-muted tabular-nums">{code.code}</span>{' '}
|
|
{code.classification}
|
|
</li>
|
|
)}
|
|
</For>
|
|
</ul>
|
|
</section>
|
|
</Show>
|
|
|
|
<section aria-busy={loaded.loading || undefined}>
|
|
<h3 class={sectionHeading}>Releases</h3>
|
|
<Switch>
|
|
<Match when={loaded.loading}>
|
|
<p class="text-sm text-muted">Loading releases…</p>
|
|
</Match>
|
|
<Match when={loadError()}>
|
|
{(err) => (
|
|
<div class="text-sm text-danger" role="alert">
|
|
<p class="mb-2">The releases didn't load: {err()}</p>
|
|
<button type="button" class={buttonPrimary} onClick={() => void refetch()}>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
)}
|
|
</Match>
|
|
<Match when={loaded.state === 'ready'}>
|
|
<ReleaseTabs releases={releases()} site={noticeSite(opportunity())} />
|
|
</Match>
|
|
</Switch>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
</dialog>
|
|
);
|
|
}
|