feat: implement governed project portfolio

This commit is contained in:
2026-08-01 17:48:38 +02:00
parent df8c3907c5
commit 8bfcf2d6b4
23 changed files with 4577 additions and 20 deletions
+28
View File
@@ -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
}
}
}
+107
View File
@@ -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" />;
}
+2
View File
@@ -0,0 +1,2 @@
export { default, projectsModule } from "./module";
export * from "./api/projects";
+53
View File
@@ -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;
+309
View File
@@ -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;
}
}