221 lines
7.4 KiB
TypeScript
221 lines
7.4 KiB
TypeScript
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";
|
|
}
|