189 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|