Implement generic approval runtime
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@govoplan/approvals-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/approvals.css": "./src/styles/approvals.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": { "@govoplan/core-webui": { "optional": true } }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type ApprovalSelector = { kind: "account" | "group" | "role" | "function_assignment" | "any_account"; value: string; label?: string | null };
|
||||
export type ApprovalStep = { key: string; label: string; selectors: ApprovalSelector[]; required_approvals: number; rejection_policy: "fail_fast" | "collect"; due_at?: string | null; signature_required: boolean; forbidden_evidence_roles: string[]; metadata: Record<string, unknown> };
|
||||
export type ApprovalRequest = {
|
||||
id: string;
|
||||
revision: number;
|
||||
state: "pending" | "escalated" | "approved" | "rejected" | "cancelled" | "expired";
|
||||
current_step_key?: string | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
subject_module: string;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
subject_version?: string | null;
|
||||
subject_digest: string;
|
||||
steps: ApprovalStep[];
|
||||
current_step_index: number;
|
||||
separation_of_duties: boolean;
|
||||
unique_actors_across_steps: boolean;
|
||||
expires_at?: string | null;
|
||||
policy_refs: string[];
|
||||
evidence_actors: Record<string, string[]>;
|
||||
template_id?: string | null;
|
||||
template_revision?: number | null;
|
||||
requested_by?: string | null;
|
||||
completed_at?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
export type ApprovalDraft = Omit<ApprovalRequest, "id" | "revision" | "state" | "current_step_key" | "current_step_index" | "requested_by" | "completed_at">;
|
||||
export type ApprovalEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record<string, unknown> };
|
||||
|
||||
export function listApprovals(settings: ApiSettings, signal?: AbortSignal): Promise<{ requests: ApprovalRequest[] }> {
|
||||
return apiFetch(settings, apiPath("/api/v1/approvals", { limit: 200 }), { signal });
|
||||
}
|
||||
|
||||
export function getApproval(settings: ApiSettings, id: string, signal?: AbortSignal): Promise<ApprovalRequest> {
|
||||
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}`, { signal });
|
||||
}
|
||||
|
||||
export function approvalHistory(settings: ApiSettings, id: string, signal?: AbortSignal): Promise<ApprovalEvent[]> {
|
||||
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(id)}/history`, { signal });
|
||||
}
|
||||
|
||||
export function createApproval(settings: ApiSettings, request: ApprovalDraft): Promise<ApprovalRequest> {
|
||||
return apiFetch(settings, "/api/v1/approvals", { method: "POST", body: JSON.stringify({ request, idempotency_key: crypto.randomUUID() }) });
|
||||
}
|
||||
|
||||
export function decideApproval(settings: ApiSettings, request: ApprovalRequest, outcome: "approved" | "rejected", reason: string, signatureRef?: Record<string, unknown>): Promise<{ request: ApprovalRequest }> {
|
||||
return apiFetch(settings, `/api/v1/approvals/${encodeURIComponent(request.id)}/decisions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ outcome, reason, expected_revision: request.revision, idempotency_key: crypto.randomUUID(), signature_ref: signatureRef ?? null })
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Dialog, DismissibleAlert, FormField, IconButton, ToggleSwitch, type ApiSettings } from "@govoplan/core-webui";
|
||||
import { createApproval, type ApprovalDraft, type ApprovalRequest, type ApprovalStep } from "../../api/approvals";
|
||||
|
||||
export default function ApprovalRequestDialog({ settings, onClose, onSaved }: { settings: ApiSettings; onClose: () => void; onSaved: (value: ApprovalRequest) => void }) {
|
||||
const [draft, setDraft] = useState<ApprovalDraft>(() => initialDraft());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const valid = useMemo(() => Boolean(draft.title.trim() && draft.subject_module.trim() && draft.subject_type.trim() && draft.subject_id.trim() && /^[0-9a-f]{64}$/.test(draft.subject_digest) && draft.steps.every((step) => step.key.trim() && step.label.trim() && step.selectors.every((selector) => selector.value.trim()))), [draft]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
onSaved(await createApproval(settings, draft));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Approval request could not be created.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function patchStep(index: number, patch: Partial<ApprovalStep>) {
|
||||
setDraft((current) => ({ ...current, steps: current.steps.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) }));
|
||||
}
|
||||
|
||||
return <Dialog open title="New Approval request" onClose={onClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant="primary" disabled={busy || !valid} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
|
||||
<div className="approval-editor">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<div className="approval-editor-grid">
|
||||
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||
<FormField label="Subject module"><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
|
||||
<FormField label="Subject type"><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
|
||||
<FormField label="Subject ID"><input value={draft.subject_id} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_id: event.target.value })} /></FormField>
|
||||
<FormField label="Subject version"><input value={draft.subject_version ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_version: event.target.value })} /></FormField>
|
||||
<FormField label="Subject SHA-256"><input value={draft.subject_digest} disabled={busy} maxLength={64} spellCheck={false} onChange={(event) => setDraft({ ...draft, subject_digest: event.target.value.trim().toLowerCase() })} /></FormField>
|
||||
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
|
||||
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
|
||||
</div>
|
||||
<div className="approval-editor-heading"><h3>Steps</h3><Button disabled={busy} onClick={() => setDraft({ ...draft, steps: [...draft.steps, emptyStep(draft.steps.length + 1)] })}><Plus size={16} aria-hidden="true" />Add step</Button></div>
|
||||
<div className="approval-step-list">
|
||||
{draft.steps.map((step, index) => <div className="approval-step-editor" key={`${index}:${step.key}`}>
|
||||
<FormField label="Key"><input value={step.key} disabled={busy} onChange={(event) => patchStep(index, { key: event.target.value })} /></FormField>
|
||||
<FormField label="Label"><input value={step.label} disabled={busy} onChange={(event) => patchStep(index, { label: event.target.value })} /></FormField>
|
||||
<FormField label="Actor type"><select value={step.selectors[0].kind} disabled={busy} onChange={(event) => patchStep(index, { selectors: [{ ...step.selectors[0], kind: event.target.value as ApprovalStep["selectors"][number]["kind"] }] })}><option value="account">Account</option><option value="group">Group</option><option value="role">Role</option><option value="function_assignment">Function assignment</option><option value="any_account">Any account</option></select></FormField>
|
||||
<FormField label="Actor value"><input value={step.selectors[0].value} disabled={busy} onChange={(event) => patchStep(index, { selectors: [{ ...step.selectors[0], value: event.target.value }] })} /></FormField>
|
||||
<FormField label="Required"><input type="number" min={1} value={step.required_approvals} disabled={busy} onChange={(event) => patchStep(index, { required_approvals: Number(event.target.value) })} /></FormField>
|
||||
<ToggleSwitch label="Signature" checked={step.signature_required} disabled={busy} onChange={(value) => patchStep(index, { signature_required: value })} />
|
||||
<IconButton label={`Remove ${step.label || "step"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.steps.length === 1} onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, itemIndex) => itemIndex !== index) })} />
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
function emptyStep(index: number): ApprovalStep {
|
||||
return { key: `step-${index}`, label: "", selectors: [{ kind: "account", value: "" }], required_approvals: 1, rejection_policy: "fail_fast", signature_required: false, forbidden_evidence_roles: [], metadata: {} };
|
||||
}
|
||||
|
||||
function initialDraft(): ApprovalDraft {
|
||||
return { title: "", description: "", subject_module: "", subject_type: "", subject_id: "", subject_version: "", subject_digest: "", steps: [emptyStep(1)], separation_of_duties: true, unique_actors_across_steps: false, expires_at: null, policy_refs: [], evidence_actors: {}, template_id: null, template_revision: null, metadata: {} };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Check, Plus, RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Dialog, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, type PlatformRouteContext } from "@govoplan/core-webui";
|
||||
import { approvalHistory, decideApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals";
|
||||
import ApprovalRequestDialog from "./ApprovalRequestDialog";
|
||||
|
||||
export default function ApprovalsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const [items, setItems] = useState<ApprovalRequest[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<ApprovalRequest | null>(null);
|
||||
const [history, setHistory] = useState<ApprovalEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [decision, setDecision] = useState<"approved" | "rejected" | null>(null);
|
||||
const canWrite = hasScope(auth, "approvals:workspace:write");
|
||||
const canDecide = hasScope(auth, "approvals:workspace:decide");
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal, preferred?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listApprovals(settings, signal);
|
||||
setItems(response.requests);
|
||||
setSelectedId((current) => preferred ?? current ?? response.requests[0]?.id ?? null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval requests could not be loaded.")); });
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setSelected(null); setHistory([]); return; }
|
||||
const controller = new AbortController();
|
||||
Promise.all([getApproval(settings, selectedId, controller.signal), approvalHistory(settings, selectedId, controller.signal)]).then(([item, events]) => { setSelected(item); setHistory(events); }).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(text(reason, "Approval details could not be loaded.")); });
|
||||
return () => controller.abort();
|
||||
}, [selectedId, settings]);
|
||||
|
||||
async function reload(id: string) {
|
||||
const [item, events] = await Promise.all([getApproval(settings, id), approvalHistory(settings, id)]);
|
||||
setSelected(item);
|
||||
setHistory(events);
|
||||
await load(undefined, id);
|
||||
}
|
||||
|
||||
return <main className="approvals-page"><div className="approvals-shell">
|
||||
<aside className="approvals-list-panel">
|
||||
<div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} onClick={() => void load()} />{canWrite && <Button variant="primary" onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button>}</div>
|
||||
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div></PageScrollViewport>
|
||||
</aside>
|
||||
<section className="approvals-workspace">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail">
|
||||
<header><div><h2>{selected.title}</h2><span>{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}</span></div><div><StatusBadge status={tone(selected.state)} label={humanize(selected.state)} />{canDecide && ["pending", "escalated"].includes(selected.state) && <><Button variant="primary" disabled={busy} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy} onClick={() => setDecision("rejected")}><X size={16} aria-hidden="true" />Reject</Button></>}</div></header>
|
||||
<div className="approval-metrics"><Metric label="Revision" value={selected.revision} /><Metric label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><Metric label="Requested by" value={selected.requested_by || "-"} /><Metric label="Steps" value={selected.steps.length} /></div>
|
||||
{selected.description && <p>{selected.description}</p>}
|
||||
<section><h3>Approval chain</h3><div className="approval-chain">{selected.steps.map((step, index) => <div key={step.key} className={step.key === selected.current_step_key ? "is-current" : ""}><span>{index + 1}</span><strong>{step.label}</strong><small>{step.required_approvals} required / {step.selectors.map((item) => `${humanize(item.kind)}: ${item.label || item.value}`).join(", ")}</small>{step.signature_required && <em>Signature</em>}</div>)}</div></section>
|
||||
<section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString()}</time></div>)}</div></section>
|
||||
</div></PageScrollViewport>}
|
||||
</section>
|
||||
</div>
|
||||
{creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />}
|
||||
{selected && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => setDecision(null)} onConfirm={async (reason, signatureId) => { setBusy(true); setError(""); try { await decideApproval(settings, selected, decision, reason, signatureId ? { owner_module: "signatures", object_id: signatureId } : undefined); setDecision(null); await reload(selected.id); } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); } finally { setBusy(false); } }} />}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }: { outcome: "approved" | "rejected"; busy: boolean; signatureRequired: boolean; onClose: () => void; onConfirm: (reason: string, signatureId: string) => Promise<void> }) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [signatureId, setSignatureId] = useState("");
|
||||
return <Dialog open title={`${humanize(outcome)} request`} onClose={onClose} closeDisabled={busy} portal footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant={outcome === "approved" ? "primary" : "danger"} disabled={busy || !reason.trim() || (signatureRequired && !signatureId.trim())} onClick={() => void onConfirm(reason.trim(), signatureId.trim())}>Confirm</Button></>}><div className="approval-decision-form"><FormField label="Reason"><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>{signatureRequired && <FormField label="Signature reference"><input value={signatureId} disabled={busy} onChange={(event) => setSignatureId(event.target.value)} /></FormField>}</div></Dialog>;
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) { return <div><span>{label}</span><strong>{value}</strong></div>; }
|
||||
function tone(state: ApprovalRequest["state"]): "active" | "inactive" | "warning" { if (state === "approved") return "active"; if (["rejected", "cancelled", "expired"].includes(state)) return "inactive"; return "warning"; }
|
||||
function humanize(value: string): string { return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
|
||||
function text(value: unknown, fallback: string): string { return value instanceof Error ? value.message : fallback; }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, approvalsModule } from "./module";
|
||||
export * from "./api/approvals";
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/approvals.css";
|
||||
|
||||
const ApprovalsPage = lazy(() => import("./features/approvals/ApprovalsPage"));
|
||||
|
||||
export const approvalsModule: PlatformWebModule = {
|
||||
id: "approvals",
|
||||
label: "Approvals",
|
||||
version: "0.1.14",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"],
|
||||
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }],
|
||||
navItems: [{ to: "/approvals", label: "Approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
|
||||
viewSurfaces: [
|
||||
{ id: "approvals.navigation", moduleId: "approvals", kind: "navigation", label: "Approvals navigation", order: 10 },
|
||||
{ id: "approvals.workspace", moduleId: "approvals", kind: "route", label: "Approval request workspace", order: 20 }
|
||||
]
|
||||
};
|
||||
|
||||
export default approvalsModule;
|
||||
@@ -0,0 +1,34 @@
|
||||
.approvals-page { height: 100%; min-height: 0; overflow: hidden; }
|
||||
.approvals-shell { display: grid; grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); height: 100%; min-height: 0; }
|
||||
.approvals-list-panel { display: flex; min-height: 0; flex-direction: column; border-right: 1px solid var(--border-color, #d8dde3); }
|
||||
.approvals-toolbar { display: flex; align-items: center; gap: 8px; min-height: 50px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #d8dde3); }
|
||||
.approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; }
|
||||
.approvals-list { display: flex; flex-direction: column; gap: 2px; padding: 6px; }
|
||||
.approvals-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 52px; padding: 7px 8px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||
.approvals-list button:hover, .approvals-list button.is-selected { background: var(--hover-bg, rgba(54, 99, 135, 0.1)); }
|
||||
.approvals-list button > span { display: flex; min-width: 0; flex-direction: column; }
|
||||
.approvals-list small, .approvals-detail header span { color: var(--text-muted, #65717e); }
|
||||
.approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
|
||||
.approvals-detail { padding: 16px 20px 28px; }
|
||||
.approvals-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color, #d8dde3); }
|
||||
.approvals-detail header > div:last-child { display: flex; align-items: center; gap: 8px; }
|
||||
.approvals-detail h2, .approvals-detail h3, .approval-editor-heading h3 { margin: 0; font-size: 1rem; letter-spacing: 0; }
|
||||
.approval-metrics { display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 10px; margin: 16px 0; }
|
||||
.approval-metrics > div { display: flex; flex-direction: column; padding: 10px 12px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; }
|
||||
.approval-metrics span { color: var(--text-muted, #65717e); font-size: .75rem; text-transform: uppercase; }
|
||||
.approvals-detail section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-color, #d8dde3); }
|
||||
.approval-chain, .approval-history { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||
.approval-chain > div, .approval-history > div { display: grid; grid-template-columns: 32px minmax(120px, .6fr) minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; padding: 7px 9px; background: var(--surface-muted, rgba(127,137,147,.08)); }
|
||||
.approval-chain > div.is-current { border-left: 3px solid var(--accent-color, #36709a); }
|
||||
.approval-chain em { font-size: .8rem; font-style: normal; }
|
||||
.approval-history > div { grid-template-columns: 32px minmax(0, 1fr) auto; }
|
||||
.approval-history time { color: var(--text-muted, #65717e); font-size: .82rem; }
|
||||
.approval-request-dialog { width: min(1080px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); }
|
||||
.approval-editor { display: flex; min-height: 0; flex-direction: column; gap: 12px; overflow: auto; }
|
||||
.approval-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.approval-editor-wide { grid-column: 1 / -1; }
|
||||
.approval-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.approval-step-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.approval-step-editor { display: grid; grid-template-columns: minmax(100px,.7fr) minmax(130px,1fr) 150px minmax(140px,1fr) 80px 120px 34px; align-items: end; gap: 8px; }
|
||||
.approval-decision-form { display: flex; min-width: min(520px, 75vw); flex-direction: column; gap: 10px; }
|
||||
@media (max-width: 850px) { .approvals-shell { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-editor { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } }
|
||||
Reference in New Issue
Block a user