Files
job-search/web/src/components/ReleaseTabs.tsx
T

236 lines
8.3 KiB
TypeScript

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 type { NoticeSite } from '@/lib/opportunities';
import type { OpportunityRelease } from '@/types/Opportunities';
import { JsonView } from './JsonView';
interface Props {
releases: OpportunityRelease[];
/** The site the releases are notices on, so each can link to its notice; undefined if none. */
site: NoticeSite | undefined;
}
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;
site: NoticeSite | undefined;
}
/** 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 notice = () => {
const site = props.site;
const url = site?.urlFor(props.release);
return site && url ? { name: site.name, url } : undefined;
};
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={notice()}>
{(notice) => (
<a
class="inline-flex items-center gap-1.5 text-sm text-accent underline"
href={notice().url}
target="_blank"
rel="noopener noreferrer"
>
Open notice on {notice().name}
<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} site={props.site} />}
</Show>
</div>
</Show>
);
}