Added the frontend in web/, following its code conventions, with linting and generated backend types
web/CODE_CONVENTIONS.md covers the Vite, SolidJS, Tailwind and TypeScript frontend: tooling, files, imports, exports, naming, TypeScript, formatting, Solid's reactivity, styling, the backend's types, API calls, errors, security, formatting values, comments, accessibility, storage, dependencies and tests. npm run check (strict tsc -b, ESLint and Prettier) passes, and npm run build builds it. ESLint (typescript-eslint with type information, eslint-plugin-solid, simple-import-sort, eslint-comments, and rules for the naming, type import, export type and promise conventions) and Prettier (with the Tailwind class plugin) check what they can. Tool versions are pinned exactly. Imports between folders use @/, which works in Vite as well as TypeScript. The backend's types are generated from its OpenAPI description into src/types/api.ts by npm run generate:api, which turns swag's x-nullable into null types first; src/types/Opportunities.ts names them for the app. The app works against the backend's API: it fetches every page of /api/opportunities with each opportunity's marks, the classifications to filter by from /api/classifications, sets and clears marks (hidden among them) with the CSRF token, and shows the OCDS tender statuses. Its lists' requests are aborted when reloaded or no longer needed. The dev server proxies /api and /ws to job-search serve, by default at localhost:9393. web/go.mod keeps web/ out of the Go module, as an npm package in node_modules ships Go code. The page stays up to date over the backend's WebSocket (src/api/events.ts, with hand-written event types in src/types/Events.ts), reconnecting with a growing delay. ClassificationsUpdated changes the filter's classifications in place and OpportunityMarksUpdated sets an opportunity's marks (unless this page is still saving its own change). OpportunitiesUpdated catches the list up: it asks for the opportunities changed after the last change batch it has seen (changed_after) and merges them in, reloading the whole list only when the backend doesn't know the batch. A reconnect catches up the same way and reloads the classifications.
This commit is contained in:
1 parent
9905ffb4cb
commit
1832ea7437
42 files changed
+8019
No files matched your search
@@ -0,0 +1,145 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
|
||||
|
||||
import type { ClassificationCode } from '@/types/Opportunities';
|
||||
import { fieldLabel, input, linkButton } from './styles';
|
||||
|
||||
const MAX_VISIBLE = 150;
|
||||
|
||||
interface Props {
|
||||
classifications: ClassificationCode[];
|
||||
isLoading: boolean;
|
||||
error: Error | undefined;
|
||||
onRetry: () => void;
|
||||
selected: ReadonlySet<number>;
|
||||
onChange: (next: Set<number>) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
/** A searchable list of classifications to filter by, with the chosen ones above it. */
|
||||
export function ClassificationPicker(props: Props): JSX.Element {
|
||||
const [query, setQuery] = createSignal('');
|
||||
const inputId = createUniqueId();
|
||||
|
||||
const sorted = createMemo(() =>
|
||||
[...props.classifications].sort((a, b) => a.code.localeCompare(b.code)),
|
||||
);
|
||||
const byId = createMemo(() => new Map(sorted().map((c) => [c.id, c])));
|
||||
|
||||
const matches = createMemo(() => {
|
||||
const q = query().trim().toLocaleLowerCase();
|
||||
if (!q) return sorted();
|
||||
return sorted().filter(
|
||||
(code) =>
|
||||
code.code.toLocaleLowerCase().startsWith(q) ||
|
||||
code.classification.toLocaleLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
const toggle = (id: number): void => {
|
||||
const next = new Set(props.selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
props.onChange(next);
|
||||
};
|
||||
|
||||
const isUnavailable = (): boolean => props.isLoading || !!props.error;
|
||||
|
||||
return (
|
||||
<div class={`min-w-0 ${props.class ?? ''}`}>
|
||||
<label class={fieldLabel} for={inputId}>
|
||||
Classification
|
||||
</label>
|
||||
|
||||
<Show when={props.selected.size > 0}>
|
||||
<ul class="mb-2 flex flex-wrap gap-1.5" aria-label="Selected classifications">
|
||||
<For each={[...props.selected]}>
|
||||
{(id) => {
|
||||
const code = (): ClassificationCode | undefined => byId().get(id);
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex max-w-72 items-baseline gap-1.5 rounded-md bg-accent-soft px-2 py-1 text-accent"
|
||||
onClick={() => toggle(id)}
|
||||
title="Remove this classification"
|
||||
>
|
||||
<span class="text-sm tabular-nums">{code()?.code ?? id}</span>
|
||||
<span class="truncate">{code()?.classification ?? 'Unknown'}</span>
|
||||
<span aria-hidden="true" class="font-bold">
|
||||
×
|
||||
</span>
|
||||
<span class="sr-only">Remove</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
type="search"
|
||||
class={input}
|
||||
placeholder="Find by code or name, e.g. 72000000 or software"
|
||||
value={query()}
|
||||
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||
disabled={isUnavailable()}
|
||||
/>
|
||||
|
||||
<Show when={props.isLoading}>
|
||||
<p class="mt-1.5 text-sm text-muted">Loading classifications…</p>
|
||||
</Show>
|
||||
|
||||
<Show when={props.error}>
|
||||
<p class="mt-1.5 text-sm text-mark-lost">
|
||||
Classifications didn't load.{' '}
|
||||
<button type="button" class={linkButton} onClick={() => props.onRetry()}>
|
||||
Try again
|
||||
</button>
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={!isUnavailable()}>
|
||||
<ul
|
||||
class="mt-1.5 max-h-72 overflow-y-auto rounded-md border border-line-soft p-1"
|
||||
role="group"
|
||||
aria-label="Classifications"
|
||||
>
|
||||
<For
|
||||
each={matches().slice(0, MAX_VISIBLE)}
|
||||
fallback={
|
||||
<li class="p-1.5 text-sm text-muted">No classifications match “{query()}”.</li>
|
||||
}
|
||||
>
|
||||
{(code) => (
|
||||
<li>
|
||||
<label class="grid cursor-pointer grid-cols-[auto_auto_1fr_auto] items-baseline gap-2 rounded px-1.5 py-1 hover:bg-paper">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.selected.has(code.id)}
|
||||
onChange={() => toggle(code.id)}
|
||||
/>
|
||||
<span class="text-sm text-muted tabular-nums">{code.code}</span>
|
||||
<span class="truncate">{code.classification}</span>
|
||||
<Show when={code.is_cpv}>
|
||||
<span class="text-xs text-muted" title="Common Procurement Vocabulary">
|
||||
CPV
|
||||
</span>
|
||||
</Show>
|
||||
</label>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={matches().length > MAX_VISIBLE}>
|
||||
<p class="mt-1.5 text-sm text-muted">
|
||||
Showing {MAX_VISIBLE} of {matches().length.toLocaleString('en-GB')}. Type more to narrow
|
||||
the list.
|
||||
</p>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createSignal, createUniqueId, For, Show } from 'solid-js';
|
||||
|
||||
import type { MarkFilter, OpportunityFilters } from '@/lib/filterOpportunities';
|
||||
import {
|
||||
activeFilterCount,
|
||||
hasActiveFilters,
|
||||
NOT_MARKED,
|
||||
statusLabel,
|
||||
} from '@/lib/filterOpportunities';
|
||||
import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
|
||||
import type { OpportunityMark } from '@/types/Annotations';
|
||||
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
|
||||
import type { ClassificationCode } from '@/types/Opportunities';
|
||||
import { ClassificationPicker } from './ClassificationPicker';
|
||||
import { buttonQuiet, fieldLabel, input, MARK_BG } from './styles';
|
||||
|
||||
interface Props {
|
||||
filters: OpportunityFilters;
|
||||
onChange: (next: OpportunityFilters) => void;
|
||||
onClear: () => void;
|
||||
statuses: string[];
|
||||
classifications: ClassificationCode[];
|
||||
isLoadingClassifications: boolean;
|
||||
classificationsError: Error | undefined;
|
||||
onRetryClassifications: () => void;
|
||||
shown: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const toggleChip =
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-3.5 py-1.5 hover:border-muted aria-pressed:border-ink aria-pressed:bg-ink aria-pressed:text-surface';
|
||||
|
||||
function toggled<T>(set: ReadonlySet<T>, value: T): Set<T> {
|
||||
const next = new Set(set);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapsible panel of filters (title, status, marks, classification,
|
||||
* hidden), with the count of opportunities shown.
|
||||
*/
|
||||
export function FilterPanel(props: Props): JSX.Element {
|
||||
const titleId = createUniqueId();
|
||||
const bodyId = createUniqueId();
|
||||
|
||||
// Open by default; the last choice is remembered.
|
||||
const [isOpen, setIsOpen] = createSignal(readStored(STORAGE_KEYS.filtersOpen) !== 'false');
|
||||
const toggleOpen = (): void => {
|
||||
const next = !isOpen();
|
||||
setIsOpen(next);
|
||||
writeStored(STORAGE_KEYS.filtersOpen, String(next));
|
||||
};
|
||||
|
||||
const update = (patch: Partial<OpportunityFilters>): void =>
|
||||
props.onChange({ ...props.filters, ...patch });
|
||||
|
||||
return (
|
||||
<section class="mb-4 rounded-[10px] border border-line bg-surface" aria-label="Filters">
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
class="-mx-1 inline-flex items-center gap-2 rounded-md px-1 py-1 font-semibold"
|
||||
aria-expanded={isOpen()}
|
||||
aria-controls={bodyId}
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="size-4 text-muted transition-transform duration-200 motion-reduce:transition-none"
|
||||
classList={{ 'rotate-90': isOpen() }}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 3.5 10.5 8 6 12.5z" />
|
||||
</svg>
|
||||
Filters
|
||||
<Show when={activeFilterCount(props.filters) > 0}>
|
||||
<span class="rounded-full bg-accent px-2 text-xs leading-5 font-semibold text-on-accent">
|
||||
{activeFilterCount(props.filters)}
|
||||
<span class="sr-only"> active</span>
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<p class="ml-auto text-muted tabular-nums" aria-live="polite">
|
||||
{props.shown === props.total
|
||||
? `${props.total.toLocaleString('en-GB')} opportunities`
|
||||
: `${props.shown.toLocaleString('en-GB')} of ${props.total.toLocaleString('en-GB')} opportunities`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class={buttonQuiet}
|
||||
onClick={() => props.onClear()}
|
||||
disabled={!hasActiveFilters(props.filters)}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Animating grid rows from 0fr to 1fr collapses to the content's real height. */}
|
||||
<div
|
||||
id={bodyId}
|
||||
class="grid transition-[grid-template-rows] duration-200 motion-reduce:transition-none"
|
||||
classList={{ 'grid-rows-[1fr]': isOpen(), 'grid-rows-[0fr]': !isOpen() }}
|
||||
inert={!isOpen()}
|
||||
>
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div class="border-t border-line-soft">
|
||||
<div class="grid grid-cols-1 items-start gap-x-8 gap-y-6 p-4 sm:p-5 md:grid-cols-2">
|
||||
<div class="flex min-w-0 flex-col gap-5">
|
||||
<div>
|
||||
<label class={fieldLabel} for={titleId}>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="search"
|
||||
class={`${input} px-3 py-2.5 text-base`}
|
||||
placeholder="Search opportunity titles"
|
||||
value={props.filters.title}
|
||||
onInput={(e) => update({ title: e.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset class="m-0 min-w-0 border-0 p-0">
|
||||
<legend class={fieldLabel}>Status</legend>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={props.statuses}>
|
||||
{(status) => (
|
||||
<button
|
||||
type="button"
|
||||
class={toggleChip}
|
||||
aria-pressed={props.filters.statuses.has(status)}
|
||||
onClick={() =>
|
||||
update({ statuses: toggled(props.filters.statuses, status) })
|
||||
}
|
||||
>
|
||||
{statusLabel(status)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="m-0 min-w-0 border-0 p-0">
|
||||
<legend class={fieldLabel}>Marks</legend>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={[...OPPORTUNITY_MARKS, NOT_MARKED] as MarkFilter[]}>
|
||||
{(mark) => (
|
||||
<button
|
||||
type="button"
|
||||
class={toggleChip}
|
||||
aria-pressed={props.filters.marks.has(mark)}
|
||||
onClick={() => update({ marks: toggled(props.filters.marks, mark) })}
|
||||
>
|
||||
<Show
|
||||
when={mark !== NOT_MARKED}
|
||||
fallback={
|
||||
<span
|
||||
class="size-2 rounded-full border border-current"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={`size-2 rounded-full ${MARK_BG[mark as OpportunityMark]}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Show>
|
||||
{mark === NOT_MARKED ? 'Not marked' : MARK_LABELS[mark]}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<ClassificationPicker
|
||||
classifications={props.classifications}
|
||||
isLoading={props.isLoadingClassifications}
|
||||
error={props.classificationsError}
|
||||
onRetry={props.onRetryClassifications}
|
||||
selected={props.filters.classificationIds}
|
||||
onChange={(classificationIds) => update({ classificationIds })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-line-soft px-4 py-3 sm:px-5">
|
||||
<label class="inline-flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.filters.showHidden}
|
||||
onChange={(e) => update({ showHidden: e.currentTarget.checked })}
|
||||
/>
|
||||
Show hidden opportunities
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createEffect, createSignal, createUniqueId, For, onCleanup, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
|
||||
import type { OpportunityMark } from '@/types/Annotations';
|
||||
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
|
||||
import { MARK_BG } from './styles';
|
||||
|
||||
interface Props {
|
||||
marks: OpportunityMark[];
|
||||
onToggle: (mark: OpportunityMark) => void;
|
||||
/** Used in the button's accessible name. */
|
||||
opportunityTitle: string;
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 200;
|
||||
const GAP = 4;
|
||||
|
||||
/**
|
||||
* A dropdown of mark checkboxes. Each mark toggles on its own and the menu
|
||||
* stays open, so several can be set in one go. The menu is portalled to
|
||||
* <body> with fixed positioning so the table's scroll container can't clip it.
|
||||
*/
|
||||
export function MarkMenu(props: Props): JSX.Element {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [pos, setPos] = createSignal({ top: 0, left: 0, isAbove: false });
|
||||
const menuId = createUniqueId();
|
||||
let button!: HTMLButtonElement;
|
||||
let menu: HTMLDivElement | undefined;
|
||||
|
||||
const place = () => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const height = menu?.offsetHeight ?? 220;
|
||||
const isAbove = rect.bottom + GAP + height > window.innerHeight && rect.top - GAP - height > 0;
|
||||
const left = Math.min(Math.max(8, rect.right - MENU_WIDTH), window.innerWidth - MENU_WIDTH - 8);
|
||||
setPos({ top: isAbove ? rect.top - GAP - height : rect.bottom + GAP, left, isAbove });
|
||||
};
|
||||
|
||||
const close = (shouldRefocus = false) => {
|
||||
setIsOpen(false);
|
||||
if (shouldRefocus) button.focus();
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!isOpen()) return;
|
||||
place();
|
||||
requestAnimationFrame(() => {
|
||||
place(); // re-measure now the menu has a real height
|
||||
menu?.querySelector<HTMLInputElement>('input')?.focus();
|
||||
});
|
||||
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (!button.contains(target) && !menu?.contains(target)) close();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close(true);
|
||||
};
|
||||
const onScroll = (e: Event) => {
|
||||
if (!menu?.contains(e.target as Node)) close();
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', place);
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('pointerdown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', place);
|
||||
});
|
||||
});
|
||||
|
||||
const summary = (): string =>
|
||||
props.marks.length === 0
|
||||
? 'not marked'
|
||||
: props.marks.map((mark) => MARK_LABELS[mark]).join(', ');
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
class="flex min-h-9 w-44 items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-muted"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen()}
|
||||
aria-controls={isOpen() ? menuId : undefined}
|
||||
aria-label={`Marks for ${props.opportunityTitle}: ${summary()}`}
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 flex-wrap gap-1">
|
||||
<Show when={props.marks.length > 0} fallback={<span class="text-muted">Not marked</span>}>
|
||||
<For each={props.marks}>
|
||||
{(mark) => (
|
||||
<span class="inline-flex items-center gap-1 rounded bg-paper px-1.5 text-xs leading-5 whitespace-nowrap text-ink">
|
||||
<span class={`size-2 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
|
||||
{MARK_LABELS[mark]}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="size-3.5 shrink-0 text-muted"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3.5 6 8 10.5 12.5 6z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<Portal>
|
||||
<div
|
||||
ref={menu}
|
||||
id={menuId}
|
||||
role="group"
|
||||
aria-label={`Marks for ${props.opportunityTitle}`}
|
||||
class="fixed z-50 rounded-lg border border-line bg-surface p-1 text-ink shadow-[0_8px_24px_rgb(0_0_0/0.18)]"
|
||||
style={{ top: `${pos().top}px`, left: `${pos().left}px`, width: `${MENU_WIDTH}px` }}
|
||||
>
|
||||
<For each={OPPORTUNITY_MARKS}>
|
||||
{(mark) => (
|
||||
<label class="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-paper">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.marks.includes(mark)}
|
||||
onChange={() => props.onToggle(mark)}
|
||||
/>
|
||||
<span class={`size-2.5 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
|
||||
{MARK_LABELS[mark]}
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import { For } from 'solid-js';
|
||||
|
||||
import { statusLabel } from '@/lib/filterOpportunities';
|
||||
import { formatDate, formatMoney } from '@/lib/format';
|
||||
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
|
||||
import type { Opportunity } from '@/types/Opportunities';
|
||||
import { MarkMenu } from './MarkMenu';
|
||||
import { buttonQuiet, MARK_BG, statusBadge } from './styles';
|
||||
|
||||
interface Props {
|
||||
opportunities: Opportunity[];
|
||||
annotations: OpportunityAnnotations;
|
||||
}
|
||||
|
||||
const th =
|
||||
'sticky top-0 z-10 whitespace-nowrap border-b border-line bg-surface p-3 text-left text-sm font-semibold text-muted';
|
||||
const td = 'border-b border-line-soft p-3 align-middle';
|
||||
|
||||
/** The table of opportunities, each with its marks and a hide button. */
|
||||
export function OpportunityTable(props: Props): JSX.Element {
|
||||
return (
|
||||
<div class="overflow-x-auto rounded-[10px] border border-line bg-surface">
|
||||
<table class="w-full border-separate border-spacing-0 tabular-nums">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class={`${th} min-w-80`}>
|
||||
Title
|
||||
</th>
|
||||
<th scope="col" class={th}>
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" class={`${th} text-center`}>
|
||||
SME suitable
|
||||
</th>
|
||||
<th scope="col" class={th}>
|
||||
Published
|
||||
</th>
|
||||
<th scope="col" class={th}>
|
||||
Start
|
||||
</th>
|
||||
<th scope="col" class={th}>
|
||||
End
|
||||
</th>
|
||||
<th scope="col" class={`${th} text-right`}>
|
||||
Value
|
||||
</th>
|
||||
<th scope="col" class={th}>
|
||||
Mark
|
||||
</th>
|
||||
<th scope="col" class={`${th} text-center`}>
|
||||
Hide
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&>tr:last-child>td]:border-b-0">
|
||||
<For each={props.opportunities}>
|
||||
{(opportunity) => (
|
||||
<OpportunityRow opportunity={opportunity} annotations={props.annotations} />
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
opportunity: Opportunity;
|
||||
annotations: OpportunityAnnotations;
|
||||
}
|
||||
|
||||
function OpportunityRow(props: RowProps): JSX.Element {
|
||||
const opportunity = (): Opportunity => props.opportunity;
|
||||
const annotation = () => props.annotations.get(opportunity().uuid);
|
||||
const marks = () => annotation().marks;
|
||||
const hidden = () => annotation().hidden;
|
||||
|
||||
return (
|
||||
<tr
|
||||
classList={{
|
||||
'text-muted': hidden() || marks().includes('skipped'),
|
||||
'bg-[repeating-linear-gradient(-45deg,transparent_0_8px,var(--color-paper)_8px_9px)]':
|
||||
hidden(),
|
||||
'opacity-70': props.annotations.isSaving(opportunity().uuid),
|
||||
}}
|
||||
aria-busy={props.annotations.isSaving(opportunity().uuid) || undefined}
|
||||
>
|
||||
{/* The marks colour the leading edge of each row, one segment per
|
||||
mark, so a scan down the left side shows where every opportunity stands. */}
|
||||
<td class={`${td} relative max-w-[520px] pl-[18px]`}>
|
||||
<span class="absolute inset-y-0 left-0 flex w-1 flex-col" aria-hidden="true">
|
||||
<For each={marks()}>{(m) => <span class={`flex-1 ${MARK_BG[m]}`} />}</For>
|
||||
</span>
|
||||
<span class="block font-semibold" title={opportunity().description || undefined}>
|
||||
{opportunity().title || 'Untitled opportunity'}
|
||||
</span>
|
||||
<span class="mt-0.5 block text-sm text-muted">{opportunity().buyer_name}</span>
|
||||
</td>
|
||||
<td class={td}>
|
||||
<span
|
||||
class={`inline-block rounded px-2 py-0.5 text-sm font-semibold whitespace-nowrap ${statusBadge(opportunity().status)}`}
|
||||
>
|
||||
{statusLabel(opportunity().status)}
|
||||
</span>
|
||||
</td>
|
||||
<td class={`${td} text-center`}>
|
||||
{opportunity().sme_suitable ? (
|
||||
<span class="font-semibold text-mark-won">Yes</span>
|
||||
) : (
|
||||
<span class="text-muted">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().latest_release_date)}</td>
|
||||
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().start_date)}</td>
|
||||
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().end_date)}</td>
|
||||
<td class={`${td} text-right whitespace-nowrap`}>{formatMoney(opportunity().gross_value)}</td>
|
||||
<td class={td}>
|
||||
<MarkMenu
|
||||
marks={marks()}
|
||||
opportunityTitle={opportunity().title}
|
||||
onToggle={(mark) => props.annotations.toggleMark(opportunity().uuid, mark)}
|
||||
/>
|
||||
</td>
|
||||
<td class={`${td} text-center`}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${buttonQuiet} px-2.5 py-1 text-sm text-ink`}
|
||||
aria-pressed={hidden()}
|
||||
aria-label={`${hidden() ? 'Show again' : 'Hide'}: ${opportunity().title}`}
|
||||
onClick={() => props.annotations.setHidden(opportunity().uuid, !hidden())}
|
||||
>
|
||||
{hidden() ? 'Unhide' : 'Hide'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import { For } from 'solid-js';
|
||||
|
||||
import type { Theme } from '@/primitives/createTheme';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
onChange: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const OPTIONS: { value: Theme; label: string; icon: string }[] = [
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
icon: 'M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z',
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
icon: 'M20.35 14.35A8.5 8.5 0 0 1 9.65 3.65 8.5 8.5 0 1 0 20.35 14.35Z',
|
||||
},
|
||||
];
|
||||
|
||||
/** A pair of buttons choosing the light or dark theme. */
|
||||
export function ThemeToggle(props: Props): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
class="inline-flex rounded-full border border-line bg-surface p-0.5"
|
||||
role="group"
|
||||
aria-label="Colour theme"
|
||||
>
|
||||
<For each={OPTIONS}>
|
||||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm text-muted hover:text-ink aria-pressed:bg-ink aria-pressed:text-surface"
|
||||
aria-pressed={props.theme === option.value}
|
||||
onClick={() => props.onChange(option.value)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="size-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={option.icon} />
|
||||
</svg>
|
||||
{option.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { OpportunityMark } from '@/types/Annotations';
|
||||
|
||||
// Tailwind only generates classes it can see written out in full, so these
|
||||
// lookups list every class literally rather than building names at runtime.
|
||||
|
||||
/** Badge colours for each known status; others get a neutral badge. */
|
||||
export const STATUS_BADGE: Record<string, string> = {
|
||||
planning: 'bg-status-planning-soft text-status-planning',
|
||||
planned: 'bg-status-planning-soft text-status-planning',
|
||||
active: 'bg-status-open-soft text-status-open',
|
||||
complete: 'bg-accent-soft text-accent',
|
||||
};
|
||||
/** The badge classes for status. */
|
||||
export const statusBadge = (status: string): string => STATUS_BADGE[status] ?? 'bg-paper text-ink';
|
||||
|
||||
/** Fill colour for each mark: row stripe segments, menu dots, pills. */
|
||||
export const MARK_BG: Record<OpportunityMark, string> = {
|
||||
interested: 'bg-mark-interested',
|
||||
skipped: 'bg-mark-skipped',
|
||||
bid_submitted: 'bg-mark-bid-submitted',
|
||||
lost: 'bg-mark-lost',
|
||||
won: 'bg-mark-won',
|
||||
};
|
||||
|
||||
export const input =
|
||||
'w-full rounded-md border border-line bg-surface px-2.5 py-2 placeholder:text-muted disabled:bg-paper';
|
||||
export const buttonQuiet =
|
||||
'rounded-md border border-line bg-surface px-3.5 py-1.5 hover:enabled:border-muted disabled:opacity-60 disabled:text-muted';
|
||||
export const buttonPrimary = 'rounded-md bg-accent px-3.5 py-1.5 font-semibold text-on-accent';
|
||||
export const linkButton = 'text-accent underline';
|
||||
export const fieldLabel = 'mb-1.5 block p-0 text-sm font-semibold';
|
||||
Reference in new issue
Block a user