236 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|