feat(housekeeping): compose operational inbox
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"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="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="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
>
|
||||
<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="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
>
|
||||
<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="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2"
|
||||
>
|
||||
<option value="all">{labels.all}</option>
|
||||
{priorities.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{response.errors.length > 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
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>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm">
|
||||
{response.errors.map((error) => (
|
||||
<li key={error.sourceId}>
|
||||
{error.sourceId}: {error.code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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="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>
|
||||
<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"
|
||||
>
|
||||
{labels.open}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user