Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/foundation/shell/operational-inbox.tsx
T

189 lines
5.8 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "@/components/link";
import type {
CanonicalHousekeepingHref,
HousekeepingSurface,
HousekeepingWorkItem,
} from "../contracts";
import type { HousekeepingInboxResponse } from "../inbox/inbox-service";
import { toHousekeepingHref } from "../routing/href";
export interface OperationalInboxLabels {
readonly title: string;
readonly domainFilter: string;
readonly stateFilter: string;
readonly priorityFilter: string;
readonly all: string;
readonly loading: string;
readonly empty: string;
readonly partial: string;
readonly open: string;
}
export interface OperationalInboxFilters {
readonly domain: string;
readonly state: string;
readonly priority: string;
}
export function filterOperationalInboxItems(
items: readonly HousekeepingWorkItem[],
filters: OperationalInboxFilters,
): readonly HousekeepingWorkItem[] {
return items.filter(
(item) =>
(filters.domain === "all" || item.domain === filters.domain) &&
(filters.state === "all" || item.state === filters.state) &&
(filters.priority === "all" || item.priority === filters.priority),
);
}
export function projectOperationalInboxHref(
href: CanonicalHousekeepingHref,
surface: HousekeepingSurface,
) {
return toHousekeepingHref(href, surface);
}
function unique(values: readonly string[]): readonly string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
}
export function OperationalInbox({
response,
labels,
surface,
loading = false,
titles = {},
}: {
readonly response: HousekeepingInboxResponse;
readonly labels: OperationalInboxLabels;
readonly surface: HousekeepingSurface;
readonly loading?: boolean;
readonly titles?: Readonly<Record<string, string>>;
}) {
const [domain, setDomain] = useState("all");
const [state, setState] = useState("all");
const [priority, setPriority] = useState("all");
const domains = useMemo(
() => unique(response.items.map((item) => item.domain)),
[response.items],
);
const states = useMemo(
() => unique(response.items.map((item) => item.state)),
[response.items],
);
const priorities = useMemo(
() => unique(response.items.map((item) => item.priority)),
[response.items],
);
const items = useMemo(
() =>
filterOperationalInboxItems(response.items, {
domain,
state,
priority,
}),
[domain, priority, response.items, state],
);
return (
<section
aria-labelledby="operational-inbox-title"
className="min-w-0 space-y-4"
>
<h2 id="operational-inbox-title" className="text-lg font-semibold">
{labels.title}
</h2>
{loading ? <p role="status">{labels.loading}</p> : null}
{!loading ? (
<>
<div className="grid gap-3 sm:grid-cols-3">
<select
aria-label={labels.domainFilter}
value={domain}
onChange={(event) => setDomain(event.target.value)}
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
<option value="all">{labels.all}</option>
{domains.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<select
aria-label={labels.stateFilter}
value={state}
onChange={(event) => setState(event.target.value)}
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
<option value="all">{labels.all}</option>
{states.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<select
aria-label={labels.priorityFilter}
value={priority}
onChange={(event) => setPriority(event.target.value)}
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
<option value="all">{labels.all}</option>
{priorities.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>
{response.errors.length > 0 ? (
<div
role="alert"
aria-live="assertive"
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
>
<p className="text-[var(--admin-warning)]">{labels.partial}</p>
</div>
) : null}
{items.length === 0 ? <p>{labels.empty}</p> : null}
<ul className="space-y-3">
{items.map((item) => (
<li
key={`${item.sourceId}:${item.itemId}`}
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 break-words">
<p className="font-medium">
{titles[item.titleKey] ?? item.titleKey}
</p>
<p className="text-sm text-[var(--admin-text-muted)]">
{item.domain} · {item.state} · {item.priority}
</p>
<time dateTime={item.occurredAt} className="text-xs">
{item.occurredAt}
</time>
</div>
<Link
href={projectOperationalInboxHref(item.href, surface)}
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{labels.open}
</Link>
</div>
</li>
))}
</ul>
</>
) : null}
</section>
);
}