Implement unified work inbox module
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/tasks-webui",
|
||||
"version": "0.1.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/tasks.css": "./src/styles/tasks.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type WorkStatus =
|
||||
| "open"
|
||||
| "in_progress"
|
||||
| "deferred"
|
||||
| "blocked"
|
||||
| "completed"
|
||||
| "cancelled";
|
||||
|
||||
export type WorkPriority = "low" | "normal" | "high" | "urgent";
|
||||
|
||||
export type WorkAssignment = {
|
||||
kind: "account" | "group" | "role" | "function" | "function_assignment" | "anyone";
|
||||
id: string;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
export type WorkSource = {
|
||||
module_id: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
revision?: string | null;
|
||||
url?: string | null;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
export type WorkItem = {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
owner_module: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
status: WorkStatus;
|
||||
priority: WorkPriority;
|
||||
summary?: string | null;
|
||||
required_action?: string | null;
|
||||
action_url?: string | null;
|
||||
due_at?: string | null;
|
||||
deferred_until?: string | null;
|
||||
assignments: WorkAssignment[];
|
||||
sources: WorkSource[];
|
||||
provenance: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
revision: string;
|
||||
etag?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type WorkProviderDiagnostic = {
|
||||
provider_id: string;
|
||||
owner_module: string;
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type WorkListResponse = {
|
||||
items: WorkItem[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
diagnostics: WorkProviderDiagnostic[];
|
||||
};
|
||||
|
||||
export type WorkSummary = {
|
||||
total: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
deferred: number;
|
||||
blocked: number;
|
||||
overdue: number;
|
||||
urgent: number;
|
||||
truncated: boolean;
|
||||
diagnostics: WorkProviderDiagnostic[];
|
||||
};
|
||||
|
||||
export type TaskCreatePayload = {
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
priority: WorkPriority;
|
||||
due_at?: string | null;
|
||||
required_action?: string | null;
|
||||
action_url?: string | null;
|
||||
assignments: WorkAssignment[];
|
||||
sources?: WorkSource[];
|
||||
provenance?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
idempotency_key: string;
|
||||
};
|
||||
|
||||
export function listWork(
|
||||
settings: ApiSettings,
|
||||
filters: {
|
||||
statuses?: WorkStatus[];
|
||||
priorities?: WorkPriority[];
|
||||
providers?: string[];
|
||||
modules?: string[];
|
||||
q?: string;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Promise<WorkListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
filters.statuses?.forEach((value) => params.append("status", value));
|
||||
filters.priorities?.forEach((value) => params.append("priority", value));
|
||||
filters.providers?.forEach((value) => params.append("provider", value));
|
||||
filters.modules?.forEach((value) => params.append("owner_module", value));
|
||||
if (filters.q) params.set("q", filters.q);
|
||||
if (filters.limit) params.set("limit", String(filters.limit));
|
||||
const query = params.toString();
|
||||
return apiFetch<WorkListResponse>(
|
||||
settings,
|
||||
`/api/v1/tasks${query ? `?${query}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
export function loadWorkSummary(settings: ApiSettings): Promise<WorkSummary> {
|
||||
return apiFetch<WorkSummary>(settings, "/api/v1/tasks/summary");
|
||||
}
|
||||
|
||||
export function createTask(
|
||||
settings: ApiSettings,
|
||||
payload: TaskCreatePayload
|
||||
): Promise<WorkItem> {
|
||||
return apiFetch<WorkItem>(settings, "/api/v1/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function transitionTask(
|
||||
settings: ApiSettings,
|
||||
task: WorkItem,
|
||||
action: "start" | "complete" | "defer" | "reopen" | "cancel",
|
||||
deferredUntil?: string | null
|
||||
): Promise<WorkItem> {
|
||||
if (!task.etag) throw new Error("i18n:govoplan-tasks.reason.refresh_required");
|
||||
return apiFetch<WorkItem>(settings, `/api/v1/tasks/${task.id}/actions`, {
|
||||
method: "POST",
|
||||
headers: { "If-Match": task.etag },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
expected_revision: Number(task.revision),
|
||||
deferred_until: deferredUntil || null
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
CalendarClock,
|
||||
Check,
|
||||
CirclePlay,
|
||||
ExternalLink,
|
||||
ListChecks,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
AdminIconButton,
|
||||
Button,
|
||||
DateTimeField,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
StatusBadge,
|
||||
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 (
|
||||
<main className="tasks-page" data-help-context-id="tasks.page.inbox">
|
||||
<div className="tasks-shell">
|
||||
<aside className="tasks-sidebar">
|
||||
<div className="tasks-sidebar-bar">
|
||||
<span className="tasks-title"><ListChecks size={18} /><strong>i18n:govoplan-tasks.work</strong></span>
|
||||
<span className="tasks-toolbar-actions">
|
||||
<AdminIconButton
|
||||
label="i18n:govoplan-tasks.refresh"
|
||||
icon={<RefreshCw size={16} aria-hidden="true" />}
|
||||
onClick={() => void load()}
|
||||
disabled={loading || busy}
|
||||
/>
|
||||
{canWrite ? (
|
||||
<AdminIconButton
|
||||
label="i18n:govoplan-tasks.create_task"
|
||||
icon={<Plus size={16} aria-hidden="true" />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={busy}
|
||||
helpContextId="tasks.action.create"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<form 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"
|
||||
/>
|
||||
</form>
|
||||
<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 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>
|
||||
</aside>
|
||||
|
||||
<section className="tasks-workspace" data-help-context-id="tasks.page.detail">
|
||||
<div className="tasks-topbar">
|
||||
<span className="tasks-detail-title"><ListChecks size={18} /><strong>{selected?.title ?? "i18n:govoplan-tasks.work_details"}</strong></span>
|
||||
<span className="tasks-toolbar-actions">
|
||||
<DocumentationHelpLink reference={DOCUMENTATION} />
|
||||
{selected?.provider_id === "tasks.explicit" && canWrite ? <TaskActions item={selected} busy={busy} onAction={(action) => action === "defer" ? setDeferOpen(true) : void runAction(action)} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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} /> : (
|
||||
<div className="tasks-empty-detail"><ListChecks size={24} /><h1>i18n:govoplan-tasks.work</h1><p>i18n:govoplan-tasks.select_help</p></div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskActions({ item, busy, onAction }: { item: WorkItem; busy: boolean; onAction: (action: "start" | "complete" | "defer" | "reopen" | "cancel") => 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>
|
||||
<Button variant="danger" onClick={() => onAction("cancel")} disabled={busy}><XCircle size={15} /> i18n:govoplan-tasks.cancel_task</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>
|
||||
<div 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>
|
||||
</div>
|
||||
<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";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import { loadWorkSummary, type WorkSummary } from "../../api/tasks";
|
||||
|
||||
export default function TasksSummaryWidget({ settings, refreshKey }: { settings: ApiSettings; refreshKey: number }) {
|
||||
const [summary, setSummary] = useState<WorkSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
void loadWorkSummary(settings)
|
||||
.then((value) => {
|
||||
if (active) {
|
||||
setSummary(value);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, refreshKey]);
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-tasks.loading_summary">
|
||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<div className="metric-grid inside dashboard-widget-metrics">
|
||||
<MetricCard label="i18n:govoplan-tasks.open" value={(summary?.open ?? 0) + (summary?.in_progress ?? 0)} tone="info" detail="i18n:govoplan-tasks.actionable_work" />
|
||||
<MetricCard label="i18n:govoplan-tasks.overdue" value={summary?.overdue ?? 0} tone={summary?.overdue ? "danger" : "good"} detail="i18n:govoplan-tasks.due_date_passed" />
|
||||
<MetricCard label="i18n:govoplan-tasks.blocked" value={summary?.blocked ?? 0} tone={summary?.blocked ? "warning" : "good"} detail="i18n:govoplan-tasks.needs_resolution" />
|
||||
</div>
|
||||
<div className="tasks-widget-actions"><Link className="btn btn-secondary" to="/tasks">i18n:govoplan-tasks.open_work_inbox</Link></div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
de: {
|
||||
"i18n:govoplan-tasks.work": "Arbeit",
|
||||
"i18n:govoplan-tasks.work_inbox": "Arbeitsvorrat",
|
||||
"i18n:govoplan-tasks.work_details": "Arbeitsdetails",
|
||||
"i18n:govoplan-tasks.work_items": "Arbeitsobjekte",
|
||||
"i18n:govoplan-tasks.work_category": "Arbeit und Verfahren",
|
||||
"i18n:govoplan-tasks.widget": "Widget für offene Arbeit",
|
||||
"i18n:govoplan-tasks.widget_description": "Zugewiesene, überfällige und blockierte Arbeit aus aktivierten Modulen.",
|
||||
"i18n:govoplan-tasks.refresh": "Arbeitsvorrat aktualisieren",
|
||||
"i18n:govoplan-tasks.create_task": "Aufgabe erstellen",
|
||||
"i18n:govoplan-tasks.create": "Erstellen",
|
||||
"i18n:govoplan-tasks.search": "Arbeitsvorrat durchsuchen",
|
||||
"i18n:govoplan-tasks.search_placeholder": "Titel, Beschreibung oder erforderliche Aktion",
|
||||
"i18n:govoplan-tasks.status_filter": "Statusfilter",
|
||||
"i18n:govoplan-tasks.active": "Aktiv",
|
||||
"i18n:govoplan-tasks.completed": "Abgeschlossen",
|
||||
"i18n:govoplan-tasks.all": "Alle",
|
||||
"i18n:govoplan-tasks.loading": "Arbeitsvorrat wird geladen.",
|
||||
"i18n:govoplan-tasks.empty": "In dieser Ansicht ist keine Arbeit vorhanden.",
|
||||
"i18n:govoplan-tasks.select_help": "Wählen Sie ein Arbeitsobjekt, um Kontext, Zuständigkeit und nächste Aktion zu prüfen.",
|
||||
"i18n:govoplan-tasks.start": "Beginnen",
|
||||
"i18n:govoplan-tasks.complete": "Abschließen",
|
||||
"i18n:govoplan-tasks.defer": "Zurückstellen",
|
||||
"i18n:govoplan-tasks.defer_task": "Aufgabe zurückstellen",
|
||||
"i18n:govoplan-tasks.reopen": "Wieder öffnen",
|
||||
"i18n:govoplan-tasks.cancel": "Abbrechen",
|
||||
"i18n:govoplan-tasks.cancel_task": "Aufgabe abbrechen",
|
||||
"i18n:govoplan-tasks.advance_task": "Aufgabenstatus fortschreiben",
|
||||
"i18n:govoplan-tasks.resume_at": "Wieder vorlegen am",
|
||||
"i18n:govoplan-tasks.title": "Titel",
|
||||
"i18n:govoplan-tasks.summary": "Beschreibung",
|
||||
"i18n:govoplan-tasks.priority": "Priorität",
|
||||
"i18n:govoplan-tasks.due_at": "Fällig am",
|
||||
"i18n:govoplan-tasks.required_action": "Erforderliche Aktion",
|
||||
"i18n:govoplan-tasks.assigned_to_you": "Die Aufgabe wird Ihnen zugewiesen. Weitere Zuständigkeitsarten stehen über angebundene Verfahren und die API zur Verfügung.",
|
||||
"i18n:govoplan-tasks.open_work": "Arbeit fortsetzen",
|
||||
"i18n:govoplan-tasks.context": "Kontext und Zuständigkeit",
|
||||
"i18n:govoplan-tasks.source_module": "Quellmodul",
|
||||
"i18n:govoplan-tasks.provider": "Quelle des Arbeitsobjekts",
|
||||
"i18n:govoplan-tasks.assigned_to": "Zugewiesen an",
|
||||
"i18n:govoplan-tasks.updated": "Aktualisiert",
|
||||
"i18n:govoplan-tasks.sources": "Verknüpfte Quellen",
|
||||
"i18n:govoplan-tasks.not_set": "Nicht festgelegt",
|
||||
"i18n:govoplan-tasks.no_due_date": "Ohne Frist",
|
||||
"i18n:govoplan-tasks.request_failed": "Der Arbeitsvorrat konnte nicht verarbeitet werden.",
|
||||
"i18n:govoplan-tasks.reason.refresh_required": "Die Aufgabe muss vor der Änderung aktualisiert werden.",
|
||||
"i18n:govoplan-tasks.loading_summary": "Arbeitsübersicht wird geladen",
|
||||
"i18n:govoplan-tasks.open": "Offen",
|
||||
"i18n:govoplan-tasks.overdue": "Überfällig",
|
||||
"i18n:govoplan-tasks.blocked": "Blockiert",
|
||||
"i18n:govoplan-tasks.actionable_work": "Offen oder in Bearbeitung",
|
||||
"i18n:govoplan-tasks.due_date_passed": "Frist ist überschritten",
|
||||
"i18n:govoplan-tasks.needs_resolution": "Hindernis muss geklärt werden",
|
||||
"i18n:govoplan-tasks.open_work_inbox": "Arbeitsvorrat öffnen",
|
||||
"i18n:govoplan-tasks.status.open": "Offen",
|
||||
"i18n:govoplan-tasks.status.in_progress": "In Bearbeitung",
|
||||
"i18n:govoplan-tasks.status.deferred": "Zurückgestellt",
|
||||
"i18n:govoplan-tasks.status.blocked": "Blockiert",
|
||||
"i18n:govoplan-tasks.status.completed": "Abgeschlossen",
|
||||
"i18n:govoplan-tasks.status.cancelled": "Abgebrochen",
|
||||
"i18n:govoplan-tasks.priority.low": "Niedrig",
|
||||
"i18n:govoplan-tasks.priority.normal": "Normal",
|
||||
"i18n:govoplan-tasks.priority.high": "Hoch",
|
||||
"i18n:govoplan-tasks.priority.urgent": "Dringend"
|
||||
},
|
||||
en: {
|
||||
"i18n:govoplan-tasks.work": "Work",
|
||||
"i18n:govoplan-tasks.work_inbox": "Work inbox",
|
||||
"i18n:govoplan-tasks.work_details": "Work details",
|
||||
"i18n:govoplan-tasks.work_items": "Work items",
|
||||
"i18n:govoplan-tasks.work_category": "Work and procedures",
|
||||
"i18n:govoplan-tasks.widget": "Open work widget",
|
||||
"i18n:govoplan-tasks.widget_description": "Assigned, overdue, and blocked work from enabled modules.",
|
||||
"i18n:govoplan-tasks.refresh": "Refresh work inbox",
|
||||
"i18n:govoplan-tasks.create_task": "Create task",
|
||||
"i18n:govoplan-tasks.create": "Create",
|
||||
"i18n:govoplan-tasks.search": "Search work inbox",
|
||||
"i18n:govoplan-tasks.search_placeholder": "Title, description, or required action",
|
||||
"i18n:govoplan-tasks.status_filter": "Status filter",
|
||||
"i18n:govoplan-tasks.active": "Active",
|
||||
"i18n:govoplan-tasks.completed": "Completed",
|
||||
"i18n:govoplan-tasks.all": "All",
|
||||
"i18n:govoplan-tasks.loading": "Loading work.",
|
||||
"i18n:govoplan-tasks.empty": "There is no work in this view.",
|
||||
"i18n:govoplan-tasks.select_help": "Select a work item to inspect its context, responsibility, and next action.",
|
||||
"i18n:govoplan-tasks.start": "Start",
|
||||
"i18n:govoplan-tasks.complete": "Complete",
|
||||
"i18n:govoplan-tasks.defer": "Defer",
|
||||
"i18n:govoplan-tasks.defer_task": "Defer task",
|
||||
"i18n:govoplan-tasks.reopen": "Reopen",
|
||||
"i18n:govoplan-tasks.cancel": "Cancel",
|
||||
"i18n:govoplan-tasks.cancel_task": "Cancel task",
|
||||
"i18n:govoplan-tasks.advance_task": "Advance task state",
|
||||
"i18n:govoplan-tasks.resume_at": "Resume at",
|
||||
"i18n:govoplan-tasks.title": "Title",
|
||||
"i18n:govoplan-tasks.summary": "Summary",
|
||||
"i18n:govoplan-tasks.priority": "Priority",
|
||||
"i18n:govoplan-tasks.due_at": "Due at",
|
||||
"i18n:govoplan-tasks.required_action": "Required action",
|
||||
"i18n:govoplan-tasks.assigned_to_you": "The task is assigned to you. Connected procedures and the API support further responsibility types.",
|
||||
"i18n:govoplan-tasks.open_work": "Continue work",
|
||||
"i18n:govoplan-tasks.context": "Context and responsibility",
|
||||
"i18n:govoplan-tasks.source_module": "Source module",
|
||||
"i18n:govoplan-tasks.provider": "Work source",
|
||||
"i18n:govoplan-tasks.assigned_to": "Assigned to",
|
||||
"i18n:govoplan-tasks.updated": "Updated",
|
||||
"i18n:govoplan-tasks.sources": "Linked sources",
|
||||
"i18n:govoplan-tasks.not_set": "Not set",
|
||||
"i18n:govoplan-tasks.no_due_date": "No due date",
|
||||
"i18n:govoplan-tasks.request_failed": "The work request failed.",
|
||||
"i18n:govoplan-tasks.reason.refresh_required": "Refresh the task before changing it.",
|
||||
"i18n:govoplan-tasks.loading_summary": "Loading work summary",
|
||||
"i18n:govoplan-tasks.open": "Open",
|
||||
"i18n:govoplan-tasks.overdue": "Overdue",
|
||||
"i18n:govoplan-tasks.blocked": "Blocked",
|
||||
"i18n:govoplan-tasks.actionable_work": "Open or in progress",
|
||||
"i18n:govoplan-tasks.due_date_passed": "Due date has passed",
|
||||
"i18n:govoplan-tasks.needs_resolution": "A blocker needs resolution",
|
||||
"i18n:govoplan-tasks.open_work_inbox": "Open work inbox",
|
||||
"i18n:govoplan-tasks.status.open": "Open",
|
||||
"i18n:govoplan-tasks.status.in_progress": "In progress",
|
||||
"i18n:govoplan-tasks.status.deferred": "Deferred",
|
||||
"i18n:govoplan-tasks.status.blocked": "Blocked",
|
||||
"i18n:govoplan-tasks.status.completed": "Completed",
|
||||
"i18n:govoplan-tasks.status.cancelled": "Cancelled",
|
||||
"i18n:govoplan-tasks.priority.low": "Low",
|
||||
"i18n:govoplan-tasks.priority.normal": "Normal",
|
||||
"i18n:govoplan-tasks.priority.high": "High",
|
||||
"i18n:govoplan-tasks.priority.urgent": "Urgent"
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { tasksModule as default, tasksModule } from "./module";
|
||||
export { default as TasksPage } from "./features/tasks/TasksPage";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
DashboardWidgetsUiCapability,
|
||||
PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import TasksSummaryWidget from "./features/tasks/TasksSummaryWidget";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/tasks.css";
|
||||
|
||||
const TasksPage = lazy(() => import("./features/tasks/TasksPage"));
|
||||
const readScope = ["tasks:item:read"];
|
||||
|
||||
const dashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
widgets: [
|
||||
{
|
||||
id: "tasks.open-work",
|
||||
surfaceId: "tasks.widget.open-work",
|
||||
title: "i18n:govoplan-tasks.work",
|
||||
description: "i18n:govoplan-tasks.widget_description",
|
||||
moduleId: "tasks",
|
||||
category: "i18n:govoplan-tasks.work_category",
|
||||
order: 20,
|
||||
defaultVisible: true,
|
||||
defaultSize: "medium",
|
||||
supportedSizes: ["medium", "wide"],
|
||||
anyOf: readScope,
|
||||
refreshIntervalMs: 30_000,
|
||||
render: ({ settings, refreshKey }) => createElement(TasksSummaryWidget, { settings, refreshKey })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const tasksModule: PlatformWebModule = {
|
||||
id: "tasks",
|
||||
label: "i18n:govoplan-tasks.work",
|
||||
version: "0.1.18",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["idm", "organizations", "workflow_engine", "workflow", "notifications", "postbox", "approvals", "views", "dashboard", "search"],
|
||||
translations: generatedTranslations,
|
||||
navItems: [
|
||||
{
|
||||
to: "/tasks",
|
||||
label: "i18n:govoplan-tasks.work",
|
||||
iconName: "list-checks",
|
||||
anyOf: readScope,
|
||||
order: 21,
|
||||
surfaceId: "tasks.route.work"
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "/tasks",
|
||||
anyOf: readScope,
|
||||
order: 21,
|
||||
surfaceId: "tasks.route.work",
|
||||
render: ({ settings, auth }) => createElement(TasksPage, { settings, auth })
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{ id: "tasks.page.inbox", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.work_inbox", parentId: "tasks.route.work", order: 20 },
|
||||
{ id: "tasks.page.detail", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.work_details", parentId: "tasks.route.work", order: 30 },
|
||||
{ id: "tasks.action.create", moduleId: "tasks", kind: "action", label: "i18n:govoplan-tasks.create_task", parentId: "tasks.page.inbox", order: 40 },
|
||||
{ id: "tasks.action.advance", moduleId: "tasks", kind: "action", label: "i18n:govoplan-tasks.advance_task", parentId: "tasks.page.detail", order: 50 },
|
||||
{ id: "tasks.widget.open-work", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.widget", order: 60 }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"dashboard.widgets": dashboardWidgets
|
||||
}
|
||||
};
|
||||
|
||||
export default tasksModule;
|
||||
@@ -0,0 +1,308 @@
|
||||
.tasks-page {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 115px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.tasks-page *,
|
||||
.tasks-page *::before,
|
||||
.tasks-page *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tasks-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(285px, 350px) minmax(0, 1fr);
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tasks-sidebar,
|
||||
.tasks-workspace {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tasks-sidebar {
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.tasks-sidebar-bar,
|
||||
.tasks-topbar,
|
||||
.tasks-title,
|
||||
.tasks-detail-title,
|
||||
.tasks-toolbar-actions,
|
||||
.tasks-list-heading,
|
||||
.tasks-list-meta,
|
||||
.tasks-detail-meta,
|
||||
.tasks-open-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tasks-sidebar-bar,
|
||||
.tasks-topbar {
|
||||
min-height: 54px;
|
||||
justify-content: space-between;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.tasks-detail-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasks-detail-title strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasks-toolbar-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tasks-toolbar-actions .btn {
|
||||
min-height: 32px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tasks-search {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: var(--border-line);
|
||||
border-radius: 5px;
|
||||
background: var(--surface);
|
||||
margin: 8px 8px 0;
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.tasks-search:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tasks-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tasks-status-filter {
|
||||
width: calc(100% - 16px);
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.tasks-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.tasks-list-item {
|
||||
min-height: 64px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tasks-list-heading,
|
||||
.tasks-list-meta {
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tasks-list-heading strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasks-list-meta,
|
||||
.tasks-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tasks-workspace > .alert {
|
||||
margin: 8px 12px 0;
|
||||
}
|
||||
|
||||
.tasks-detail {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.tasks-detail-main,
|
||||
.tasks-properties,
|
||||
.tasks-sources {
|
||||
border-bottom: var(--border-line);
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.tasks-detail-main h1 {
|
||||
margin: 10px 0 8px;
|
||||
color: var(--text-strong);
|
||||
font-size: 24px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tasks-detail-main > p {
|
||||
max-width: 850px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.tasks-detail-meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tasks-required-action {
|
||||
max-width: 850px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--info-soft);
|
||||
margin: 14px 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.tasks-open-action {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tasks-properties h2,
|
||||
.tasks-sources h2 {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-strong);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.tasks-properties dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
gap: 12px 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tasks-properties dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tasks-properties dd {
|
||||
margin: 3px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tasks-source {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
border-top: var(--border-line);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.tasks-source span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tasks-empty-detail {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.tasks-empty-detail h1 {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.tasks-create-form {
|
||||
width: min(620px, 75vw);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tasks-create-form textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tasks-create-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, .75fr) minmax(260px, 1.25fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tasks-assignment-note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tasks-widget-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.tasks-shell {
|
||||
grid-template-columns: minmax(230px, 42%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.tasks-topbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tasks-properties dl,
|
||||
.tasks-create-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.tasks-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(250px, 44%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.tasks-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.tasks-create-form {
|
||||
width: min(100%, 88vw);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference path="../../../govoplan-core/webui/src/vite-env.d.ts" />
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"preserveSymlinks": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
|
||||
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
|
||||
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
|
||||
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
|
||||
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"],
|
||||
"react-router": ["../../govoplan-core/webui/node_modules/react-router/dist/development/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user