feat(webui): complete bounded work quick access

This commit is contained in:
2026-08-19 19:28:14 +02:00
parent a8646e76a8
commit 39bb6c0d18
5 changed files with 291 additions and 8 deletions
@@ -0,0 +1,220 @@
import { Check, CirclePlay, ExternalLink, ListChecks } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import {
Button,
DismissibleAlert,
LoadingFrame,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatusBadge,
hasScope,
quickAccessLaunchState,
useDashboardWidgetData,
type QuickAccessToolRenderContext
} from "@govoplan/core-webui";
import {
listWork,
transitionTask,
type WorkItem,
type WorkStatus
} from "../../api/tasks";
const ACTIVE_STATUSES: WorkStatus[] = [
"open",
"in_progress",
"deferred",
"blocked"
];
type Props = Pick<
QuickAccessToolRenderContext,
"settings" | "auth" | "launchContext" | "complete"
>;
/**
* A bounded projection of the unified inbox. Every load and command goes back
* through Tasks, so optional providers retain ownership of visibility and
* completion semantics.
*/
export default function TasksQuickAccess({
settings,
auth,
launchContext,
complete
}: Props) {
const [refreshKey, setRefreshKey] = useState(0);
const [selectedKey, setSelectedKey] = useState("");
const [commandError, setCommandError] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(
() => listWork(settings, { statuses: ACTIVE_STATUSES, limit: 7 }),
[settings]
);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
const items = data?.items ?? [];
const selected = useMemo(
() => items.find((item) => workKey(item) === selectedKey) ?? items[0] ?? null,
[items, selectedKey]
);
const canWrite = hasScope(auth, "tasks:item:write");
useEffect(() => {
if (!selectedKey && items[0]) setSelectedKey(workKey(items[0]));
if (selectedKey && !items.some((item) => workKey(item) === selectedKey)) {
setSelectedKey(items[0] ? workKey(items[0]) : "");
}
}, [items, selectedKey]);
async function runCommand(action: "start" | "complete") {
if (!selected || selected.provider_id !== "tasks.explicit" || !canWrite) return;
setBusy(true);
setCommandError("");
try {
const updated = await transitionTask(settings, selected, action);
if (action === "complete") {
complete(workResult(updated, "completed"));
return;
}
setRefreshKey((value) => value + 1);
} catch (reason) {
setCommandError(errorMessage(reason));
} finally {
setBusy(false);
}
}
function selectForHost(item: WorkItem) {
complete(workResult(item, "selected"));
}
const actionPath = selected ? safeActionUrl(selected.action_url) : null;
return (
<LoadingFrame loading={loading} label="i18n:govoplan-tasks.loading">
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{commandError ? <DismissibleAlert tone="danger" resetKey={commandError}>{commandError}</DismissibleAlert> : null}
{data?.diagnostics.map((diagnostic) => (
<DismissibleAlert
key={`${diagnostic.provider_id}:${diagnostic.code}`}
tone="warning"
resetKey={`${diagnostic.provider_id}:${diagnostic.code}:${diagnostic.message}`}
>
{diagnostic.message}
</DismissibleAlert>
))}
{items.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-tasks.work_items">
{items.map((item) => (
<SelectionListItem
key={workKey(item)}
selected={selected ? workKey(item) === workKey(selected) : false}
onClick={() => setSelectedKey(workKey(item))}
>
<SelectionListItemContent
leading={<ListChecks size={16} aria-hidden="true" />}
title={item.title}
description={item.required_action || item.summary || moduleLabel(item.owner_module)}
/>
<StatusBadge status={item.status} label={statusLabel(item.status)} />
</SelectionListItem>
))}
</SelectionList>
) : !loading && !error ? (
<p className="muted">i18n:govoplan-tasks.empty</p>
) : null}
{selected ? (
<section className="tasks-quick-detail" aria-label="i18n:govoplan-tasks.work_details">
<div className="tasks-quick-detail-heading">
<strong>{selected.title}</strong>
<span>{moduleLabel(selected.owner_module)} · {dueLabel(selected.due_at)}</span>
</div>
{selected.summary ? <p>{selected.summary}</p> : null}
{selected.required_action ? (
<p><strong>i18n:govoplan-tasks.required_action:</strong> {selected.required_action}</p>
) : null}
<div className="button-row compact-actions">
{selected.provider_id === "tasks.explicit" && canWrite && ["open", "deferred"].includes(selected.status) ? (
<Button onClick={() => void runCommand("start")} disabled={busy}>
<CirclePlay size={15} aria-hidden="true" /> i18n:govoplan-tasks.start
</Button>
) : null}
{selected.provider_id === "tasks.explicit" && canWrite ? (
<Button variant="primary" onClick={() => void runCommand("complete")} disabled={busy}>
<Check size={15} aria-hidden="true" /> i18n:govoplan-tasks.complete
</Button>
) : null}
{actionPath ? (
<Link
className="btn btn-secondary"
to={actionPath}
state={quickAccessLaunchState(launchContext)}
onClick={() => selectForHost(selected)}
>
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-tasks.open_work
</Link>
) : (
<Button onClick={() => selectForHost(selected)}>
i18n:govoplan-tasks.select_help
</Button>
)}
</div>
</section>
) : null}
{data && data.total > items.length ? (
<p className="muted small-note">
{items.length} / {data.total} · i18n:govoplan-tasks.open_work_inbox
</p>
) : null}
</LoadingFrame>
);
}
function workResult(item: WorkItem, action: "selected" | "completed") {
return {
contractVersion: "1" as const,
outcome: "completed" as const,
action,
reference: {
ownerModule: "tasks",
kind: "work-item",
objectId: `${item.provider_id}:${item.id}`,
tenantId: item.tenant_id,
label: item.title,
version: item.revision,
path: safeActionUrl(item.action_url) || "/tasks"
}
};
}
function workKey(item: WorkItem): string {
return `${item.provider_id}:${item.id}`;
}
function safeActionUrl(value?: string | null): string | null {
if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return null;
return value;
}
function statusLabel(value: string): string {
return `i18n:govoplan-tasks.status.${value}`;
}
function moduleLabel(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function dueLabel(value?: string | null): string {
if (!value) return "i18n:govoplan-tasks.no_due_date";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed";
}