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.
This commit is contained in:
1 parent
6fbf7e56b2
commit
f8648d765a
12 files changed
+847
-10
No files matched your search
@@ -0,0 +1,230 @@
|
||||
import ExternalLink from 'lucide-solid/icons/external-link';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
|
||||
|
||||
import { statusLabel } from '@/lib/filterOpportunities';
|
||||
import { formatDate, formatDateTime, formatValue } from '@/lib/format';
|
||||
import type { ReleaseValue } from '@/lib/ocdsRelease';
|
||||
import { parseReleaseData, summariseRelease } from '@/lib/ocdsRelease';
|
||||
import { findATenderNoticeUrl } from '@/lib/opportunities';
|
||||
import type { OpportunityRelease } from '@/types/Opportunities';
|
||||
import { JsonView } from './JsonView';
|
||||
|
||||
interface Props {
|
||||
releases: OpportunityRelease[];
|
||||
/** Whether the releases are Find a Tender notices, so each can link to its notice. */
|
||||
isFromFindATender: boolean;
|
||||
}
|
||||
|
||||
const tabClass =
|
||||
'shrink-0 border-b-2 border-transparent px-3 py-2 text-sm whitespace-nowrap text-muted hover:text-ink aria-selected:border-accent aria-selected:font-semibold aria-selected:text-ink';
|
||||
const sectionHeading = 'mb-1.5 text-sm font-semibold';
|
||||
const summaryList = 'grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm';
|
||||
|
||||
const value = (money: ReleaseValue | undefined): string =>
|
||||
money ? formatValue(money.amount, money.currency) : '—';
|
||||
|
||||
const tagsOf = (release: OpportunityRelease): string =>
|
||||
(release.release_tags ?? []).map((tag) => tag.tag).join(', ');
|
||||
|
||||
interface FieldProps {
|
||||
term: string;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
/** A term and its description in a summary list. */
|
||||
function Field(props: FieldProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<dt class="text-muted">{props.term}</dt>
|
||||
<dd>{props.children}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReleasePanelProps {
|
||||
release: OpportunityRelease;
|
||||
isFromFindATender: boolean;
|
||||
}
|
||||
|
||||
/** One release: its notice, a summary of its tender, awards and contracts, and its JSON. */
|
||||
function ReleasePanel(props: ReleasePanelProps): JSX.Element {
|
||||
const parsed = createMemo(() => parseReleaseData(props.release.data));
|
||||
const summary = createMemo(() => summariseRelease(parsed()));
|
||||
// The JSON is only laid out once it's opened: a release can be 100KB or more.
|
||||
const [isJsonOpen, setIsJsonOpen] = createSignal(false);
|
||||
const json = () => {
|
||||
const data = parsed();
|
||||
return data === undefined ? props.release.data : JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<dl class={summaryList}>
|
||||
<Field term="Release">{props.release.release_id}</Field>
|
||||
<Field term="Published">{formatDateTime(props.release.release_date)}</Field>
|
||||
<Field term="Tags">{tagsOf(props.release) || '—'}</Field>
|
||||
</dl>
|
||||
<Show when={props.isFromFindATender}>
|
||||
<a
|
||||
class="inline-flex items-center gap-1.5 text-sm text-accent underline"
|
||||
href={findATenderNoticeUrl(props.release.release_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open notice on Find a Tender
|
||||
<ExternalLink size={14} strokeWidth={2} />
|
||||
</a>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={summary().tender}>
|
||||
{(tender) => (
|
||||
<section>
|
||||
<h4 class={sectionHeading}>Tender</h4>
|
||||
<dl class={summaryList}>
|
||||
<Field term="Title">{tender().title || '—'}</Field>
|
||||
<Field term="Status">{tender().status ? statusLabel(tender().status) : '—'}</Field>
|
||||
<Field term="Value">{value(tender().value)}</Field>
|
||||
<Field term="Deadline">{formatDate(tender().deadline)}</Field>
|
||||
<Field term="Lots">{tender().lotCount || '—'}</Field>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={summary().awards.length > 0}>
|
||||
<section>
|
||||
<h4 class={sectionHeading}>Awards</h4>
|
||||
<ul class="flex flex-col gap-3">
|
||||
<For each={summary().awards}>
|
||||
{(award) => (
|
||||
<li>
|
||||
<dl class={summaryList}>
|
||||
<Field term="Award">{award.title || award.id || '—'}</Field>
|
||||
<Field term="Status">{award.status ? statusLabel(award.status) : '—'}</Field>
|
||||
<Field term="Suppliers">{award.suppliers.join(', ') || '—'}</Field>
|
||||
<Field term="Value">{value(award.value)}</Field>
|
||||
<Field term="Date">{formatDate(award.date)}</Field>
|
||||
</dl>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={summary().contracts.length > 0}>
|
||||
<section>
|
||||
<h4 class={sectionHeading}>Contracts</h4>
|
||||
<ul class="flex flex-col gap-3">
|
||||
<For each={summary().contracts}>
|
||||
{(contract) => (
|
||||
<li>
|
||||
<dl class={summaryList}>
|
||||
<Field term="Contract">{contract.title || contract.id || '—'}</Field>
|
||||
<Field term="Status">
|
||||
{contract.status ? statusLabel(contract.status) : '—'}
|
||||
</Field>
|
||||
<Field term="Value">{value(contract.value)}</Field>
|
||||
<Field term="Signed">{formatDate(contract.dateSigned)}</Field>
|
||||
<Field term="Period">
|
||||
{formatDate(contract.start)} to {formatDate(contract.end)}
|
||||
</Field>
|
||||
</dl>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<details onToggle={(e) => setIsJsonOpen(e.currentTarget.open)}>
|
||||
<summary class="cursor-pointer text-sm font-semibold">Release JSON</summary>
|
||||
<div class="mt-2">
|
||||
<Show when={isJsonOpen()}>
|
||||
<JsonView json={json()} />
|
||||
</Show>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An opportunity's releases as tabs, newest first, each with a summary and
|
||||
* its JSON. The tabs follow the ARIA tabs pattern: the arrow keys, Home and
|
||||
* End move between them, and Tab moves into the open one.
|
||||
*/
|
||||
export function ReleaseTabs(props: Props): JSX.Element {
|
||||
const id = createUniqueId();
|
||||
const releases = createMemo(() =>
|
||||
[...props.releases].sort(
|
||||
(first, second) => Date.parse(second.release_date) - Date.parse(first.release_date),
|
||||
),
|
||||
);
|
||||
const [selected, setSelected] = createSignal(0);
|
||||
const tabs: HTMLButtonElement[] = [];
|
||||
|
||||
const select = (index: number): void => {
|
||||
const count = releases().length;
|
||||
const next = (index + count) % count;
|
||||
setSelected(next);
|
||||
tabs[next]?.focus();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
const moves: Record<string, number> = {
|
||||
ArrowRight: selected() + 1,
|
||||
ArrowLeft: selected() - 1,
|
||||
Home: 0,
|
||||
End: releases().length - 1,
|
||||
};
|
||||
const index = moves[event.key];
|
||||
if (index === undefined) return;
|
||||
event.preventDefault();
|
||||
select(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={releases().length > 0}
|
||||
fallback={<p class="text-sm text-muted">This opportunity has no releases.</p>}
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Releases"
|
||||
class="flex overflow-x-auto border-b border-line"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<For each={releases()}>
|
||||
{(release, index) => (
|
||||
<button
|
||||
ref={(tab) => (tabs[index()] = tab)}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${id}-tab-${index()}`}
|
||||
class={tabClass}
|
||||
aria-selected={selected() === index()}
|
||||
aria-controls={`${id}-panel`}
|
||||
tabindex={selected() === index() ? 0 : -1}
|
||||
onClick={() => setSelected(index())}
|
||||
>
|
||||
{formatDateTime(release.release_date)}
|
||||
<Show when={tagsOf(release)}>
|
||||
<span class="ml-1.5 font-normal text-muted">{tagsOf(release)}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${selected()}`}>
|
||||
<Show when={releases()[selected()]} keyed>
|
||||
{(release) => (
|
||||
<ReleasePanel release={release} isFromFindATender={props.isFromFindATender} />
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user