feat(webui): complete bounded work quick access
This commit is contained in:
@@ -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";
|
||||
}
|
||||
+2
-4
@@ -5,6 +5,7 @@ import type {
|
||||
QuickAccessToolsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import TasksSummaryWidget from "./features/tasks/TasksSummaryWidget";
|
||||
import TasksQuickAccess from "./features/tasks/TasksQuickAccess";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/tasks.css";
|
||||
|
||||
@@ -35,10 +36,7 @@ const quickAccessTools: QuickAccessToolsUiCapability = {
|
||||
tools: [
|
||||
{
|
||||
id: "tasks.work",
|
||||
render: ({ settings }) => createElement(TasksSummaryWidget, {
|
||||
settings,
|
||||
refreshKey: 0
|
||||
})
|
||||
render: (context) => createElement(TasksQuickAccess, context)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -205,6 +205,26 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.tasks-quick-detail {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
border-top: var(--border-line);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.tasks-quick-detail-heading {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tasks-quick-detail-heading span,
|
||||
.tasks-quick-detail > p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.tasks-topbar {
|
||||
align-items: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user