Files
job-search/web/src/components/OpportunityDialog.tsx
T
bwilliams f8648d765a Added a dialog with each opportunity's details and releases
Every row has a details icon under the Find a Tender link. It opens a
dialog with the opportunity's details from the list, and fetches the
opportunity with its releases, shown as tabs, newest first. Each tab
summarises the release's tender, awards and contracts, links to its
Find a Tender notice where it is one, and has the release's JSON,
pretty-printed and highlighted, in a section laid out only when it's
opened.

The JSON is highlighted by splitting it into tokens shown as text, not
HTML, as releases come from outside sources. The README's saved
preferences now include the filters.
2026-10-19 10:37:27 +01:00

195 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, formatMoney } from '@/lib/format';
import { isFromFindATender } 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>
<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>
</dd>
<dt class="text-muted">Published</dt>
<dd>{formatDate(opportunity().latest_release_date)}</dd>
<dt class="text-muted">Start</dt>
<dd>{formatDate(opportunity().start_date)}</dd>
<dt class="text-muted">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">OCID</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()}
isFromFindATender={isFromFindATender(opportunity())}
/>
</Match>
</Switch>
</section>
</div>
)}
</Show>
</dialog>
);
}