Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
403 lines
17 KiB
TypeScript
403 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
|
import {
|
|
CalendarClock,
|
|
Check,
|
|
CirclePlay,
|
|
ExternalLink,
|
|
ListChecks,
|
|
Plus,
|
|
RotateCcw,
|
|
Search,
|
|
XCircle
|
|
} from "lucide-react";
|
|
import { Link } from "react-router";
|
|
import { FormGrid,
|
|
Button,
|
|
DateTimeField,
|
|
Dialog,
|
|
DismissibleAlert,
|
|
DocumentationHelpLink,
|
|
FormField,
|
|
FilterBar,
|
|
SegmentedControl,
|
|
SelectionList,
|
|
SelectionListItem,
|
|
StatePanel,
|
|
StatusBadge,
|
|
WorkspaceActionBar,
|
|
WorkspaceFrame,
|
|
WorkspaceLayout,
|
|
hasScope,
|
|
type ApiSettings,
|
|
type AuthInfo
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
createTask,
|
|
listWork,
|
|
transitionTask,
|
|
type TaskCreatePayload,
|
|
type WorkItem,
|
|
type WorkPriority,
|
|
type WorkStatus
|
|
} from "../../api/tasks";
|
|
|
|
type StatusView = "active" | "completed" | "all";
|
|
|
|
const ACTIVE_STATUSES: WorkStatus[] = ["open", "in_progress", "deferred", "blocked"];
|
|
const CLOSED_STATUSES: WorkStatus[] = ["completed", "cancelled"];
|
|
const ALL_STATUSES: WorkStatus[] = [...ACTIVE_STATUSES, ...CLOSED_STATUSES];
|
|
const DOCUMENTATION = {
|
|
contextId: "tasks.page.inbox",
|
|
topicId: "tasks.work-inbox",
|
|
documentationType: "user" as const
|
|
};
|
|
|
|
export default function TasksPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
|
const [items, setItems] = useState<WorkItem[]>([]);
|
|
const [selectedKey, setSelectedKey] = useState("");
|
|
const [statusView, setStatusView] = useState<StatusView>("active");
|
|
const [searchDraft, setSearchDraft] = useState("");
|
|
const [query, setQuery] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [diagnostics, setDiagnostics] = useState<string[]>([]);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [deferOpen, setDeferOpen] = useState(false);
|
|
const [deferredUntil, setDeferredUntil] = useState("");
|
|
|
|
const canWrite = hasScope(auth, "tasks:item:write");
|
|
const selected = useMemo(
|
|
() => items.find((item) => workKey(item) === selectedKey) ?? items[0] ?? null,
|
|
[items, selectedKey]
|
|
);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, statusView, query]);
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const response = await listWork(settings, {
|
|
statuses: statusView === "active" ? ACTIVE_STATUSES : statusView === "completed" ? CLOSED_STATUSES : ALL_STATUSES,
|
|
q: query,
|
|
limit: 500
|
|
});
|
|
setItems(response.items);
|
|
setDiagnostics(response.diagnostics.map((item) => `${item.owner_module}: ${item.message}`));
|
|
setSelectedKey((current) => response.items.some((item) => workKey(item) === current) ? current : response.items[0] ? workKey(response.items[0]) : "");
|
|
} catch (reason) {
|
|
setError(errorMessage(reason));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function runAction(action: "start" | "complete" | "defer" | "reopen" | "cancel") {
|
|
if (!selected || selected.provider_id !== "tasks.explicit") return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const next = await transitionTask(
|
|
settings,
|
|
selected,
|
|
action,
|
|
action === "defer" ? localDateTimeToIso(deferredUntil) : null
|
|
);
|
|
setItems((current) => current.map((item) => item.id === next.id ? next : item));
|
|
setDeferOpen(false);
|
|
setDeferredUntil("");
|
|
await load();
|
|
} catch (reason) {
|
|
setError(errorMessage(reason));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function submitSearch(event: FormEvent) {
|
|
event.preventDefault();
|
|
setQuery(searchDraft.trim());
|
|
}
|
|
|
|
return (
|
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="tasks-page" label="Task inbox" data-help-context-id="tasks.page.inbox">
|
|
<WorkspaceActionBar
|
|
scope="workspace"
|
|
variant="collection"
|
|
refreshable
|
|
reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "i18n:govoplan-tasks.refresh" }}
|
|
contextActions={<span className="tasks-title"><ListChecks size={18} /><strong>i18n:govoplan-tasks.work</strong></span>}
|
|
createAction={canWrite ? (
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => setCreateOpen(true)}
|
|
disabled={busy}
|
|
helpContextId="tasks.action.create"
|
|
>
|
|
<Plus size={16} aria-hidden="true" /> i18n:govoplan-tasks.create_task
|
|
</Button>
|
|
) : undefined}
|
|
/>
|
|
<WorkspaceLayout
|
|
variant="split"
|
|
primarySize="default"
|
|
primaryScrollable={false}
|
|
contentScrollable={false}
|
|
surface="contained"
|
|
primaryClassName="tasks-sidebar"
|
|
contentClassName="tasks-workspace"
|
|
primaryLabel="i18n:govoplan-tasks.work"
|
|
contentLabel="i18n:govoplan-tasks.work_details"
|
|
interfaceId="tasks.inbox.workspace"
|
|
helpContextId="tasks.page.inbox"
|
|
helpModuleId="tasks"
|
|
primary={<>
|
|
<FilterBar as="form" surface="control" wrap="never" className="tasks-search" onSubmit={submitSearch} role="search">
|
|
<Search size={15} aria-hidden="true" />
|
|
<input
|
|
value={searchDraft}
|
|
onChange={(event) => setSearchDraft(event.target.value)}
|
|
placeholder="i18n:govoplan-tasks.search_placeholder"
|
|
aria-label="i18n:govoplan-tasks.search"
|
|
/>
|
|
</FilterBar>
|
|
<SegmentedControl
|
|
className="tasks-status-filter"
|
|
options={[
|
|
{ id: "active", label: "i18n:govoplan-tasks.active" },
|
|
{ id: "completed", label: "i18n:govoplan-tasks.completed" },
|
|
{ id: "all", label: "i18n:govoplan-tasks.all" }
|
|
]}
|
|
value={statusView}
|
|
onChange={setStatusView}
|
|
ariaLabel="i18n:govoplan-tasks.status_filter"
|
|
width="fill"
|
|
/>
|
|
<div className="tasks-list">
|
|
{loading ? <p className="tasks-note">i18n:govoplan-tasks.loading</p> : null}
|
|
{!loading && items.length === 0 ? <p className="tasks-note">i18n:govoplan-tasks.empty</p> : null}
|
|
{items.length ? (
|
|
<SelectionList variant="navigation" label="i18n:govoplan-tasks.work_items">
|
|
{items.map((item) => (
|
|
<SelectionListItem
|
|
key={`${item.provider_id}:${item.id}`}
|
|
selected={selected ? workKey(selected) === workKey(item) : false}
|
|
onClick={() => setSelectedKey(workKey(item))}
|
|
className="tasks-list-item"
|
|
>
|
|
<span className="tasks-list-heading"><strong>{item.title}</strong><StatusBadge status={item.status} label={statusLabel(item.status)} /></span>
|
|
<span className="tasks-list-meta"><span>{moduleLabel(item.owner_module)}</span><span>{dueLabel(item.due_at)}</span></span>
|
|
</SelectionListItem>
|
|
))}
|
|
</SelectionList>
|
|
) : null}
|
|
</div>
|
|
</>}
|
|
>
|
|
|
|
<WorkspaceActionBar
|
|
scope="detail-pane"
|
|
variant="detail"
|
|
className="tasks-topbar"
|
|
data-help-context-id="tasks.page.detail"
|
|
title={<span className="tasks-detail-title"><ListChecks size={18} aria-hidden="true" /><strong>{selected?.title ?? "i18n:govoplan-tasks.work_details"}</strong></span>}
|
|
titleHelp={<DocumentationHelpLink reference={DOCUMENTATION} />}
|
|
primaryActions={selected?.provider_id === "tasks.explicit" && canWrite ? (
|
|
<TaskPrimaryActions item={selected} busy={busy} onAction={(action) => action === "defer" ? setDeferOpen(true) : void runAction(action)} />
|
|
) : undefined}
|
|
destructiveActions={selected?.provider_id === "tasks.explicit" && canWrite && !["completed", "cancelled"].includes(selected.status) ? (
|
|
<Button variant="danger" onClick={() => void runAction("cancel")} disabled={busy}><XCircle size={15} /> i18n:govoplan-tasks.cancel_task</Button>
|
|
) : undefined}
|
|
/>
|
|
|
|
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
|
|
{diagnostics.map((message) => <DismissibleAlert key={message} tone="warning" compact resetKey={message}>{message}</DismissibleAlert>)}
|
|
|
|
{selected ? <TaskDetails item={selected} /> : (
|
|
<StatePanel size="fill" icon={<ListChecks size={24} />} title="i18n:govoplan-tasks.work" description="i18n:govoplan-tasks.select_help" />
|
|
)}
|
|
</WorkspaceLayout>
|
|
|
|
<CreateTaskDialog
|
|
open={createOpen}
|
|
busy={busy}
|
|
settings={settings}
|
|
auth={auth}
|
|
onClose={() => setCreateOpen(false)}
|
|
onCreated={async (item) => {
|
|
setCreateOpen(false);
|
|
setSelectedKey(workKey(item));
|
|
await load();
|
|
}}
|
|
onError={setError}
|
|
setBusy={setBusy}
|
|
/>
|
|
<Dialog
|
|
open={deferOpen}
|
|
title="i18n:govoplan-tasks.defer_task"
|
|
onClose={() => setDeferOpen(false)}
|
|
closeDisabled={busy}
|
|
portal
|
|
helpContextId="tasks.action.advance"
|
|
footer={<><Button onClick={() => setDeferOpen(false)} disabled={busy}>i18n:govoplan-tasks.cancel</Button><Button variant="primary" onClick={() => void runAction("defer")} disabled={!deferredUntil || busy}>i18n:govoplan-tasks.defer</Button></>}
|
|
>
|
|
<FormField label="i18n:govoplan-tasks.resume_at" helpContextId="tasks.field.due-at">
|
|
<DateTimeField value={deferredUntil} onChange={setDeferredUntil} min={localDateTime(new Date())} />
|
|
</FormField>
|
|
</Dialog>
|
|
</WorkspaceFrame>
|
|
);
|
|
}
|
|
|
|
function TaskPrimaryActions({ item, busy, onAction }: { item: WorkItem; busy: boolean; onAction: (action: "start" | "complete" | "defer" | "reopen") => void }) {
|
|
if (["completed", "cancelled"].includes(item.status)) {
|
|
return <Button onClick={() => onAction("reopen")} disabled={busy}><RotateCcw size={15} /> i18n:govoplan-tasks.reopen</Button>;
|
|
}
|
|
return (
|
|
<>
|
|
{["open", "deferred"].includes(item.status) ? <Button onClick={() => onAction("start")} disabled={busy}><CirclePlay size={15} /> i18n:govoplan-tasks.start</Button> : null}
|
|
<Button variant="primary" onClick={() => onAction("complete")} disabled={busy}><Check size={15} /> i18n:govoplan-tasks.complete</Button>
|
|
<Button onClick={() => onAction("defer")} disabled={busy}><CalendarClock size={15} /> i18n:govoplan-tasks.defer</Button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TaskDetails({ item }: { item: WorkItem }) {
|
|
const safeAction = safeActionUrl(item.action_url);
|
|
return (
|
|
<div className="tasks-detail">
|
|
<section className="tasks-detail-main">
|
|
<div className="tasks-detail-meta"><StatusBadge status={item.status} label={statusLabel(item.status)} /><StatusBadge status={item.priority} label={priorityLabel(item.priority)} /><span>{moduleLabel(item.owner_module)}</span>{item.due_at ? <span>{dueLabel(item.due_at)}</span> : null}</div>
|
|
<h1>{item.title}</h1>
|
|
{item.summary ? <p>{item.summary}</p> : null}
|
|
{item.required_action ? <div className="tasks-required-action"><strong>i18n:govoplan-tasks.required_action</strong><span>{item.required_action}</span></div> : null}
|
|
{safeAction ? <Link className="btn btn-primary tasks-open-action" to={safeAction}><ExternalLink size={16} /> i18n:govoplan-tasks.open_work</Link> : null}
|
|
</section>
|
|
<section className="tasks-properties">
|
|
<h2>i18n:govoplan-tasks.context</h2>
|
|
<dl>
|
|
<div><dt>i18n:govoplan-tasks.source_module</dt><dd>{moduleLabel(item.owner_module)}</dd></div>
|
|
<div><dt>i18n:govoplan-tasks.provider</dt><dd>{item.provider_id}</dd></div>
|
|
<div><dt>i18n:govoplan-tasks.assigned_to</dt><dd>{item.assignments.map(assignmentLabel).join(", ") || "i18n:govoplan-tasks.not_set"}</dd></div>
|
|
<div><dt>i18n:govoplan-tasks.updated</dt><dd>{formatDate(item.updated_at)}</dd></div>
|
|
</dl>
|
|
</section>
|
|
{item.sources.length ? <section className="tasks-sources"><h2>i18n:govoplan-tasks.sources</h2>{item.sources.map((source) => <div key={`${source.module_id}:${source.resource_type}:${source.resource_id}`} className="tasks-source"><strong>{source.label || `${source.resource_type} ${source.resource_id}`}</strong><span>{moduleLabel(source.module_id)}{source.revision ? ` · ${source.revision}` : ""}</span></div>)}</section> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreateTaskDialog({ open, busy, settings, auth, onClose, onCreated, onError, setBusy }: { open: boolean; busy: boolean; settings: ApiSettings; auth: AuthInfo; onClose: () => void; onCreated: (item: WorkItem) => Promise<void>; onError: (message: string) => void; setBusy: (value: boolean) => void }) {
|
|
const [title, setTitle] = useState("");
|
|
const [summary, setSummary] = useState("");
|
|
const [requiredAction, setRequiredAction] = useState("");
|
|
const [priority, setPriority] = useState<WorkPriority>("normal");
|
|
const [dueAt, setDueAt] = useState("");
|
|
const accountId = auth.principal?.account_id || auth.user.account_id;
|
|
|
|
async function submit(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!title.trim() || !accountId) return;
|
|
setBusy(true);
|
|
onError("");
|
|
const payload: TaskCreatePayload = {
|
|
title: title.trim(),
|
|
summary: summary.trim() || null,
|
|
required_action: requiredAction.trim() || null,
|
|
priority,
|
|
due_at: dueAt ? localDateTimeToIso(dueAt) : null,
|
|
assignments: [{ kind: "account", id: accountId, label: auth.user.display_name || auth.user.email }],
|
|
idempotency_key: crypto.randomUUID()
|
|
};
|
|
try {
|
|
const item = await createTask(settings, payload);
|
|
setTitle("");
|
|
setSummary("");
|
|
setRequiredAction("");
|
|
setPriority("normal");
|
|
setDueAt("");
|
|
await onCreated(item);
|
|
} catch (reason) {
|
|
onError(errorMessage(reason));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title="i18n:govoplan-tasks.create_task"
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
portal
|
|
helpContextId="tasks.action.create"
|
|
footer={<><Button onClick={onClose} disabled={busy}>i18n:govoplan-tasks.cancel</Button><Button type="submit" form="tasks-create-form" variant="primary" disabled={!title.trim() || !accountId || busy}>i18n:govoplan-tasks.create</Button></>}
|
|
>
|
|
<form id="tasks-create-form" className="tasks-create-form" onSubmit={submit}>
|
|
<FormField label="i18n:govoplan-tasks.title"><input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={500} autoFocus required /></FormField>
|
|
<FormField label="i18n:govoplan-tasks.summary"><textarea value={summary} onChange={(event) => setSummary(event.target.value)} maxLength={4000} rows={4} /></FormField>
|
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="tasks-create-grid">
|
|
<FormField label="i18n:govoplan-tasks.priority" helpContextId="tasks.field.priority"><select value={priority} onChange={(event) => setPriority(event.target.value as WorkPriority)}><option value="low">i18n:govoplan-tasks.priority.low</option><option value="normal">i18n:govoplan-tasks.priority.normal</option><option value="high">i18n:govoplan-tasks.priority.high</option><option value="urgent">i18n:govoplan-tasks.priority.urgent</option></select></FormField>
|
|
<FormField label="i18n:govoplan-tasks.due_at" helpContextId="tasks.field.due-at"><DateTimeField value={dueAt} onChange={setDueAt} min={localDateTime(new Date())} /></FormField>
|
|
</FormGrid>
|
|
<FormField label="i18n:govoplan-tasks.required_action"><input value={requiredAction} onChange={(event) => setRequiredAction(event.target.value)} maxLength={500} /></FormField>
|
|
<p className="tasks-assignment-note">i18n:govoplan-tasks.assigned_to_you</p>
|
|
</form>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function safeActionUrl(value?: string | null): string | null {
|
|
if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return null;
|
|
return value;
|
|
}
|
|
|
|
function workKey(item: WorkItem): string {
|
|
return `${item.provider_id}:${item.id}`;
|
|
}
|
|
|
|
function assignmentLabel(value: WorkItem["assignments"][number]): string {
|
|
return value.label || `${value.kind}: ${value.id}`;
|
|
}
|
|
|
|
function statusLabel(value: string): string {
|
|
return `i18n:govoplan-tasks.status.${value}`;
|
|
}
|
|
|
|
function priorityLabel(value: string): string {
|
|
return `i18n:govoplan-tasks.priority.${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";
|
|
return formatDate(value);
|
|
}
|
|
|
|
function formatDate(value?: string | null): string {
|
|
if (!value) return "i18n:govoplan-tasks.not_set";
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) return value;
|
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
|
|
}
|
|
|
|
function localDateTime(value: Date): string {
|
|
const shifted = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
|
return shifted.toISOString().slice(0, 16);
|
|
}
|
|
|
|
function localDateTimeToIso(value: string): string {
|
|
return new Date(value).toISOString();
|
|
}
|
|
|
|
function errorMessage(reason: unknown): string {
|
|
return reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed";
|
|
}
|