feat: implement governed project portfolio
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/projects-webui",
|
||||
"version": "0.1.14",
|
||||
"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/projects.css": "./src/styles/projects.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"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,107 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type ProjectObjectKind = "portfolio" | "project" | "milestone";
|
||||
|
||||
export type ProjectSubjectRef = {
|
||||
kind: string;
|
||||
id: string;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectRecord = {
|
||||
tenant_id: string;
|
||||
object_kind: ProjectObjectKind;
|
||||
object_id: string;
|
||||
object_key: string;
|
||||
revision: number;
|
||||
title: string;
|
||||
state: string;
|
||||
description?: string | null;
|
||||
visibility: "tenant" | "restricted";
|
||||
parent_kind?: ProjectObjectKind | null;
|
||||
parent_id?: string | null;
|
||||
starts_at?: string | null;
|
||||
due_at?: string | null;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
owner?: ProjectSubjectRef | null;
|
||||
memberships: Array<Record<string, unknown>>;
|
||||
outcomes: Array<{ key: string; title: string; description?: string | null }>;
|
||||
benefits: Array<{ key: string; title: string; target?: string | null }>;
|
||||
dependencies: Array<Record<string, unknown>>;
|
||||
capacity_assumptions: Array<Record<string, unknown>>;
|
||||
change_impacts: Array<Record<string, unknown>>;
|
||||
benefit_reviews: Array<Record<string, unknown>>;
|
||||
resource_links: Array<Record<string, unknown>>;
|
||||
external_references: Array<Record<string, unknown>>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ProjectListResponse = {
|
||||
objects: ProjectRecord[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export function listProjectObjects(
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
objectKinds?: ProjectObjectKind[];
|
||||
states?: string[];
|
||||
parentKind?: ProjectObjectKind;
|
||||
parentId?: string;
|
||||
query?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<ProjectListResponse> {
|
||||
return apiFetch(settings, apiPath("/api/v1/projects/objects", {
|
||||
object_kind: options.objectKinds,
|
||||
state: options.states,
|
||||
parent_kind: options.parentKind,
|
||||
parent_id: options.parentId,
|
||||
query: options.query,
|
||||
offset: options.offset,
|
||||
limit: options.limit
|
||||
}), { signal });
|
||||
}
|
||||
|
||||
export function createProjectObject(
|
||||
settings: ApiSettings,
|
||||
record: ProjectRecord,
|
||||
idempotencyKey: string
|
||||
): Promise<ProjectRecord> {
|
||||
return apiFetch(settings, "/api/v1/projects/objects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ record, idempotency_key: idempotencyKey })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProjectObject(
|
||||
settings: ApiSettings,
|
||||
record: ProjectRecord,
|
||||
changes: Record<string, unknown>,
|
||||
changeReason: string
|
||||
): Promise<ProjectRecord> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/projects/objects/${encodeURIComponent(record.object_kind)}/${encodeURIComponent(record.object_id)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
changes
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import {
|
||||
CalendarDays,
|
||||
FolderKanban,
|
||||
Milestone,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Target
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createProjectObject,
|
||||
listProjectObjects,
|
||||
updateProjectObject,
|
||||
type ProjectObjectKind,
|
||||
type ProjectRecord
|
||||
} from "../../api/projects";
|
||||
|
||||
|
||||
type EditorValues = {
|
||||
kind: ProjectObjectKind;
|
||||
key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
description: string;
|
||||
visibility: "tenant" | "restricted";
|
||||
parentRef: string;
|
||||
startsAt: string;
|
||||
dueAt: string;
|
||||
changeReason: string;
|
||||
};
|
||||
|
||||
const STATES: Record<ProjectObjectKind, string[]> = {
|
||||
portfolio: ["draft", "active", "on_hold", "completed", "cancelled"],
|
||||
project: ["draft", "proposed", "approved", "active", "on_hold", "completed", "cancelled"],
|
||||
milestone: ["planned", "active", "achieved", "missed", "cancelled"]
|
||||
};
|
||||
|
||||
const INITIAL_STATE: Record<ProjectObjectKind, string> = {
|
||||
portfolio: "draft",
|
||||
project: "draft",
|
||||
milestone: "planned"
|
||||
};
|
||||
|
||||
|
||||
export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||
const [kind, setKind] = useState<ProjectObjectKind | "">("");
|
||||
const [objects, setObjects] = useState<ProjectRecord[]>([]);
|
||||
const [parentOptions, setParentOptions] = useState<ProjectRecord[]>([]);
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ProjectRecord | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const canWrite = hasScope(auth, "projects:project:write");
|
||||
|
||||
function reload(signal?: AbortSignal) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
return Promise.all([
|
||||
listProjectObjects(settings, {
|
||||
objectKinds: kind ? [kind] : undefined,
|
||||
query: submittedQuery,
|
||||
limit: 200
|
||||
}, signal),
|
||||
listProjectObjects(settings, {
|
||||
objectKinds: ["portfolio", "project"],
|
||||
limit: 200
|
||||
}, signal)
|
||||
]).
|
||||
then(([result, parents]) => {
|
||||
setObjects(result.objects);
|
||||
setParentOptions(parents.objects);
|
||||
setTotal(result.total);
|
||||
setSelectedKey((current) => {
|
||||
if (result.objects.some((item) => objectKey(item) === current)) return current;
|
||||
return result.objects[0] ? objectKey(result.objects[0]) : "";
|
||||
});
|
||||
}).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Projects could not be loaded.");
|
||||
}
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void reload(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [settings, kind, submittedQuery]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => objects.find((item) => objectKey(item) === selectedKey) ?? null,
|
||||
[objects, selectedKey]
|
||||
);
|
||||
|
||||
function submitSearch(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmittedQuery(query.trim());
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!selected) return;
|
||||
setEditing(selected);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
async function save(values: EditorValues) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
let saved: ProjectRecord;
|
||||
if (editing) {
|
||||
const parent = parseParentRef(values.parentRef);
|
||||
saved = await updateProjectObject(settings, editing, {
|
||||
title: values.title,
|
||||
state: values.state,
|
||||
description: values.description || null,
|
||||
visibility: values.visibility,
|
||||
parent_kind: parent?.kind ?? null,
|
||||
parent_id: parent?.id ?? null,
|
||||
starts_at: dateValue(values.startsAt),
|
||||
due_at: dateValue(values.dueAt)
|
||||
}, values.changeReason);
|
||||
} else {
|
||||
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||
const objectId = crypto.randomUUID();
|
||||
const parent = parseParentRef(values.parentRef);
|
||||
saved = await createProjectObject(settings, {
|
||||
tenant_id: tenantId,
|
||||
object_kind: values.kind,
|
||||
object_id: objectId,
|
||||
object_key: values.key,
|
||||
revision: 1,
|
||||
title: values.title,
|
||||
state: values.state,
|
||||
description: values.description || null,
|
||||
visibility: values.visibility,
|
||||
parent_kind: parent?.kind ?? null,
|
||||
parent_id: parent?.id ?? null,
|
||||
starts_at: dateValue(values.startsAt),
|
||||
due_at: dateValue(values.dueAt),
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: values.changeReason,
|
||||
owner: {
|
||||
kind: "account",
|
||||
id: auth.user.account_id,
|
||||
label: auth.user.display_name ?? auth.user.email
|
||||
},
|
||||
memberships: [],
|
||||
outcomes: [],
|
||||
benefits: [],
|
||||
dependencies: [],
|
||||
capacity_assumptions: [],
|
||||
change_impacts: [],
|
||||
benefit_reviews: [],
|
||||
resource_links: [],
|
||||
external_references: [],
|
||||
metadata: {}
|
||||
}, crypto.randomUUID());
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setEditing(null);
|
||||
await reload();
|
||||
setSelectedKey(objectKey(saved));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The project object could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="projects-page">
|
||||
<div className="projects-shell">
|
||||
<div className="projects-toolbar">
|
||||
<form className="projects-search" onSubmit={submitSearch}>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label="Search projects"
|
||||
placeholder="Search portfolios, projects, and milestones"
|
||||
/>
|
||||
<Button type="submit" variant="primary">Search</Button>
|
||||
</form>
|
||||
<label className="projects-kind-filter">
|
||||
<span>Type</span>
|
||||
<select value={kind} onChange={(event) => setKind(event.target.value as ProjectObjectKind | "")}>
|
||||
<option value="">All planning objects</option>
|
||||
<option value="portfolio">Portfolios</option>
|
||||
<option value="project">Projects</option>
|
||||
<option value="milestone">Milestones</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="projects-count">{total} objects</span>
|
||||
{canWrite &&
|
||||
<Button type="button" variant="primary" onClick={openCreate}>
|
||||
<Plus size={16} aria-hidden="true" /> New
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
{error &&
|
||||
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
}
|
||||
<div className="projects-workspace">
|
||||
<PageScrollViewport className="projects-list-viewport">
|
||||
{loading && <LoadingIndicator label="Loading projects" />}
|
||||
{!loading && objects.length === 0 &&
|
||||
<div className="projects-empty">No matching planning objects.</div>
|
||||
}
|
||||
<div className="projects-list" role="list">
|
||||
{objects.map((item) =>
|
||||
<button
|
||||
type="button"
|
||||
role="listitem"
|
||||
key={objectKey(item)}
|
||||
className={`project-row${objectKey(item) === selectedKey ? " is-selected" : ""}`}
|
||||
onClick={() => setSelectedKey(objectKey(item))}>
|
||||
<span className="project-row-icon">{kindIcon(item.object_kind)}</span>
|
||||
<span className="project-row-main">
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.object_key}</small>
|
||||
</span>
|
||||
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
||||
<span className="project-row-date">{formatDate(item.due_at)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
<PageScrollViewport className="project-detail-viewport">
|
||||
{selected ?
|
||||
<ProjectDetail record={selected} canWrite={canWrite} onEdit={openEdit} /> :
|
||||
<div className="projects-empty">Select a portfolio, project, or milestone.</div>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectEditorDialog
|
||||
open={editorOpen}
|
||||
record={editing}
|
||||
objects={parentOptions}
|
||||
saving={saving}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={save}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ProjectDetail({ record, canWrite, onEdit }: {
|
||||
record: ProjectRecord;
|
||||
canWrite: boolean;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="project-detail">
|
||||
<header className="project-detail-header">
|
||||
<div>
|
||||
<span className="project-eyebrow">{humanize(record.object_kind)} · {record.object_key}</span>
|
||||
<h1>{record.title}</h1>
|
||||
</div>
|
||||
<div className="project-detail-actions">
|
||||
<StatusBadge status={statusTone(record.state)} label={humanize(record.state)} />
|
||||
{canWrite &&
|
||||
<IconButton variant="ghost" label="Edit planning object" icon={<Pencil size={17} />} onClick={onEdit} />
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
{record.description && <p className="project-description">{record.description}</p>}
|
||||
<div className="project-facts">
|
||||
<div><span>Starts</span><strong>{formatDate(record.starts_at)}</strong></div>
|
||||
<div><span>Due</span><strong>{formatDate(record.due_at)}</strong></div>
|
||||
<div><span>Visibility</span><strong>{humanize(record.visibility)}</strong></div>
|
||||
<div><span>Revision</span><strong>{record.revision}</strong></div>
|
||||
</div>
|
||||
<section className="project-planning-section">
|
||||
<h2><Target size={17} /> Outcomes and benefits</h2>
|
||||
<div className="project-stat-grid">
|
||||
<PlanningStat label="Outcomes" value={record.outcomes.length} />
|
||||
<PlanningStat label="Benefits" value={record.benefits.length} />
|
||||
<PlanningStat label="Benefit reviews" value={record.benefit_reviews.length} />
|
||||
<PlanningStat label="Change impacts" value={record.change_impacts.length} />
|
||||
</div>
|
||||
{record.outcomes.length > 0 &&
|
||||
<div className="project-detail-list">
|
||||
{record.outcomes.map((item) =>
|
||||
<div key={item.key}><strong>{item.title}</strong><span>{item.description || item.key}</span></div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
<section className="project-planning-section">
|
||||
<h2><FolderKanban size={17} /> Governance links</h2>
|
||||
<div className="project-stat-grid">
|
||||
<PlanningStat label="Members" value={record.memberships.length + (record.owner ? 1 : 0)} />
|
||||
<PlanningStat label="Dependencies" value={record.dependencies.length} />
|
||||
<PlanningStat label="Resources" value={record.resource_links.length} />
|
||||
<PlanningStat label="External refs" value={record.external_references.length} />
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanningStat({ label, value }: { label: string; value: number }) {
|
||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }: {
|
||||
open: boolean;
|
||||
record: ProjectRecord | null;
|
||||
objects: ProjectRecord[];
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (values: EditorValues) => Promise<void>;
|
||||
}) {
|
||||
const [values, setValues] = useState<EditorValues>(() => editorValues(record));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setValues(editorValues(record));
|
||||
}, [open, record]);
|
||||
|
||||
function set<K extends keyof EditorValues>(key: K, value: EditorValues[K]) {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
void onSave(values);
|
||||
}
|
||||
|
||||
const eligibleParents = objects.filter((item) =>
|
||||
values.kind === "project"
|
||||
? item.object_kind === "portfolio"
|
||||
: values.kind === "milestone" && item.object_kind === "project"
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={record ? "Edit planning object" : "New planning object"}
|
||||
onClose={onClose}
|
||||
closeDisabled={saving}
|
||||
className="project-editor-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button type="submit" form="project-editor-form" variant="primary" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>Type</span>
|
||||
<select
|
||||
value={values.kind}
|
||||
disabled={Boolean(record)}
|
||||
onChange={(event) => {
|
||||
const nextKind = event.target.value as ProjectObjectKind;
|
||||
setValues((current) => ({ ...current, kind: nextKind, state: INITIAL_STATE[nextKind] }));
|
||||
}}>
|
||||
<option value="portfolio">Portfolio</option>
|
||||
<option value="project">Project</option>
|
||||
<option value="milestone">Milestone</option>
|
||||
</select>
|
||||
</label>
|
||||
{values.kind !== "portfolio" &&
|
||||
<label className="project-editor-wide">
|
||||
<span>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</span>
|
||||
<select
|
||||
value={values.parentRef}
|
||||
required={values.kind === "milestone"}
|
||||
onChange={(event) => set("parentRef", event.target.value)}>
|
||||
<option value="">{values.kind === "project" ? "No portfolio" : "Select a project"}</option>
|
||||
{eligibleParents.map((item) =>
|
||||
<option key={objectKey(item)} value={`${item.object_kind}:${item.object_id}`}>
|
||||
{item.title} ({item.object_key})
|
||||
</option>
|
||||
)}
|
||||
{record?.parent_kind && record.parent_id && !eligibleParents.some(
|
||||
(item) => item.object_kind === record.parent_kind && item.object_id === record.parent_id
|
||||
) &&
|
||||
<option value={`${record.parent_kind}:${record.parent_id}`}>{record.parent_id}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
<span>Key</span>
|
||||
<input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
|
||||
</label>
|
||||
<label className="project-editor-wide">
|
||||
<span>Title</span>
|
||||
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>State</span>
|
||||
<select value={values.state} onChange={(event) => set("state", event.target.value)}>
|
||||
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Visibility</span>
|
||||
<select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}>
|
||||
<option value="tenant">Tenant</option>
|
||||
<option value="restricted">Restricted</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Starts</span>
|
||||
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Due</span>
|
||||
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
|
||||
</label>
|
||||
<label className="project-editor-wide">
|
||||
<span>Description</span>
|
||||
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
|
||||
</label>
|
||||
<label className="project-editor-wide">
|
||||
<span>Change reason</span>
|
||||
<input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
|
||||
</label>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function editorValues(record: ProjectRecord | null): EditorValues {
|
||||
const kind = record?.object_kind ?? "project";
|
||||
return {
|
||||
kind,
|
||||
key: record?.object_key ?? "",
|
||||
title: record?.title ?? "",
|
||||
state: record?.state ?? INITIAL_STATE[kind],
|
||||
description: record?.description ?? "",
|
||||
visibility: record?.visibility ?? "tenant",
|
||||
parentRef: record?.parent_kind && record.parent_id ? `${record.parent_kind}:${record.parent_id}` : "",
|
||||
startsAt: dateInput(record?.starts_at),
|
||||
dueAt: dateInput(record?.due_at),
|
||||
changeReason: record ? "Updated project planning information." : "Created planning object."
|
||||
};
|
||||
}
|
||||
|
||||
function parseParentRef(value: string): { kind: ProjectObjectKind; id: string } | null {
|
||||
const separator = value.indexOf(":");
|
||||
if (separator < 1) return null;
|
||||
return {
|
||||
kind: value.slice(0, separator) as ProjectObjectKind,
|
||||
id: value.slice(separator + 1)
|
||||
};
|
||||
}
|
||||
|
||||
function objectKey(record: ProjectRecord): string {
|
||||
return `${record.object_kind}:${record.object_id}`;
|
||||
}
|
||||
|
||||
function dateValue(value: string): string | null {
|
||||
return value ? new Date(`${value}T00:00:00.000Z`).toISOString() : null;
|
||||
}
|
||||
|
||||
function dateInput(value?: string | null): string {
|
||||
return value ? value.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(value)) : "Not set";
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function statusTone(state: string): "active" | "inactive" | "warning" {
|
||||
if (["active", "approved", "achieved", "completed"].includes(state)) return "active";
|
||||
if (["cancelled", "missed"].includes(state)) return "inactive";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function kindIcon(kind: ProjectObjectKind) {
|
||||
if (kind === "portfolio") return <FolderKanban size={17} aria-hidden="true" />;
|
||||
if (kind === "milestone") return <Milestone size={17} aria-hidden="true" />;
|
||||
return <CalendarDays size={17} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, projectsModule } from "./module";
|
||||
export * from "./api/projects";
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/projects.css";
|
||||
|
||||
|
||||
const ProjectsPage = lazy(() => import("./features/projects/ProjectsPage"));
|
||||
|
||||
export const projectsModule: PlatformWebModule = {
|
||||
id: "projects",
|
||||
label: "Projects",
|
||||
version: "0.1.14",
|
||||
optionalDependencies: [
|
||||
"tasks",
|
||||
"tickets",
|
||||
"cases",
|
||||
"wiki",
|
||||
"calendar",
|
||||
"files",
|
||||
"workflow_engine",
|
||||
"connectors",
|
||||
"search",
|
||||
"notifications",
|
||||
"reporting",
|
||||
"risk_compliance"
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "/projects",
|
||||
anyOf: ["projects:project:read"],
|
||||
order: 36,
|
||||
surfaceId: "projects.workspace",
|
||||
render: (context) => createElement(ProjectsPage, context)
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/projects",
|
||||
label: "Projects",
|
||||
iconName: "folder-kanban",
|
||||
anyOf: ["projects:project:read"],
|
||||
order: 36,
|
||||
surfaceId: "projects.navigation"
|
||||
}
|
||||
],
|
||||
viewSurfaces: [
|
||||
{ id: "projects.navigation", moduleId: "projects", kind: "navigation", label: "Projects navigation", order: 10 },
|
||||
{ id: "projects.workspace", moduleId: "projects", kind: "route", label: "Projects workspace", order: 20 },
|
||||
{ id: "projects.portfolios", moduleId: "projects", kind: "section", label: "Portfolio planning", parentId: "projects.workspace", order: 30 },
|
||||
{ id: "projects.outcomes", moduleId: "projects", kind: "section", label: "Outcomes and benefits", parentId: "projects.workspace", order: 40 }
|
||||
]
|
||||
};
|
||||
|
||||
export default projectsModule;
|
||||
@@ -0,0 +1,309 @@
|
||||
.projects-page,
|
||||
.projects-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.projects-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.projects-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 58px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.projects-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(560px, 48vw);
|
||||
}
|
||||
|
||||
.projects-search input {
|
||||
min-width: 160px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.projects-kind-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.projects-kind-filter > span,
|
||||
.projects-count {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.projects-count {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.projects-workspace {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(360px, 42%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.projects-list-viewport,
|
||||
.project-detail-viewport {
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.projects-list-viewport {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
}
|
||||
|
||||
.projects-list {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.project-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto minmax(96px, auto);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.project-row:hover,
|
||||
.project-row:focus-visible,
|
||||
.project-row.is-selected {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.project-row.is-selected {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.project-row-icon {
|
||||
display: grid;
|
||||
color: var(--text-soft);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.project-row-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-row-main strong,
|
||||
.project-row-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-row-main small,
|
||||
.project-row-date {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.project-detail {
|
||||
width: min(100%, 980px);
|
||||
margin: 0 auto;
|
||||
padding: 4px 8px 28px;
|
||||
}
|
||||
|
||||
.project-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-detail-header h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.project-eyebrow {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.project-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
max-width: 78ch;
|
||||
margin: 18px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.project-facts,
|
||||
.project-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.project-facts > div,
|
||||
.project-stat-grid > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 11px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.project-facts span,
|
||||
.project-stat-grid span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.project-planning-section {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.project-planning-section h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.96rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.project-detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-detail-list > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 9px 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-detail-list span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.projects-empty {
|
||||
padding: 36px 10px;
|
||||
color: var(--text-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-editor-dialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.project-editor-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-editor-form label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.project-editor-form label > span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.project-editor-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.projects-toolbar {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.projects-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.projects-count {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.projects-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.projects-list-viewport {
|
||||
max-height: 42vh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-facts,
|
||||
.project-stat-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.project-row {
|
||||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.project-row-date {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-editor-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-editor-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user