Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
409 lines
17 KiB
TypeScript
409 lines
17 KiB
TypeScript
import { decidedWindow } from "./approvals/decided-window";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Avatar, Button, Icon, IconButton, Pill } from "../shared/ui";
|
|
import { TopbarActions } from "../shared/TopbarActions";
|
|
import type {
|
|
AgentRecord,
|
|
ApprovalRecord,
|
|
EmployeeRecord,
|
|
RiskLevel,
|
|
} from "../../../shared/domain";
|
|
import type { ApprovalDrift } from "../../../shared/approval-pin";
|
|
import { APPROVAL_STATE, RISK_LEVEL } from "../../../shared/domain";
|
|
import type { PageProps } from "./types";
|
|
import { activeOrgId, formatRelative, requireActiveEmployee } from "./types";
|
|
import { t } from "../i18n/i18n";
|
|
|
|
type RiskFilter = "all" | RiskLevel;
|
|
|
|
const RISK_FILTERS: ReadonlyArray<{ id: RiskFilter; label: string }> = [
|
|
{ id: "all", label: t("All risk") },
|
|
{ id: RISK_LEVEL.HIGH, label: t("High") },
|
|
{ id: RISK_LEVEL.MEDIUM, label: t("Medium") },
|
|
{ id: RISK_LEVEL.LOW, label: t("Low") },
|
|
];
|
|
|
|
const RISK_TONES: Record<RiskLevel, { label: string; color: string }> = {
|
|
[RISK_LEVEL.HIGH]: { label: t("High risk"), color: "var(--red)" },
|
|
[RISK_LEVEL.MEDIUM]: { label: t("Medium risk"), color: "var(--amber)" },
|
|
[RISK_LEVEL.LOW]: { label: t("Low risk"), color: "var(--text-3)" },
|
|
};
|
|
|
|
export function ApprovalsPage({ snapshot, actions, navigate, focus }: PageProps) {
|
|
const orgId = activeOrgId(snapshot);
|
|
const employee = requireActiveEmployee(snapshot);
|
|
const [filter, setFilter] = useState<RiskFilter>("all");
|
|
const focusedApproval = focus?.kind === "approval" ? focus.id : null;
|
|
const [editingId, setEditingId] = useState<string | null>(
|
|
focusedApproval && snapshot.approvals.some((item) => item.id === focusedApproval && item.state === "pending") ? focusedApproval : null,
|
|
);
|
|
const openTask = (taskId: string) => navigate("work", { kind: "task", id: taskId });
|
|
|
|
const approvals = useMemo(() => snapshot.approvals.filter((item) => item.orgId === orgId), [snapshot.approvals, orgId]);
|
|
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
|
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
|
|
|
const pending = approvals.filter((approval) => approval.state === APPROVAL_STATE.PENDING);
|
|
const decided = approvals.filter((approval) => approval.state !== APPROVAL_STATE.PENDING);
|
|
const shownDecided = decidedWindow(decided, focusedApproval);
|
|
const filteredPending = filter === "all" ? pending : pending.filter((approval) => approval.risk === filter);
|
|
const weeklyDecided = decided.filter((approval) => withinDays(approval.decidedAt, 7));
|
|
const approved7d = weeklyDecided.filter((approval) => approval.state === APPROVAL_STATE.APPROVED).length;
|
|
const declined7d = weeklyDecided.filter((approval) => approval.state === APPROVAL_STATE.DECLINED).length;
|
|
const medianTime = computeMedianMinutes(weeklyDecided);
|
|
|
|
const editing = editingId ? approvals.find((approval) => approval.id === editingId) ?? null : null;
|
|
|
|
// Drift is read from disk in the main process, again whenever the approvals change.
|
|
const [drifts, setDrifts] = useState<Record<string, ApprovalDrift>>({});
|
|
const [driftError, setDriftError] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void window.agentCompany.approvalDrifts({ orgId }).then((result) => {
|
|
if (cancelled) return;
|
|
if (result.ok) {
|
|
setDrifts(result.data);
|
|
setDriftError(null);
|
|
} else {
|
|
setDriftError(result.error.message);
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [orgId, approvals]);
|
|
|
|
const decide = (approval: ApprovalRecord, state: "approved" | "declined", notes?: string) => {
|
|
setEditingId(null);
|
|
void actions.run(() => window.agentCompany.decideApproval({
|
|
orgId,
|
|
approvalId: approval.id,
|
|
state,
|
|
decidedByEmployeeId: employee.id,
|
|
notes,
|
|
}));
|
|
};
|
|
|
|
const approveAllLowRisk = () => {
|
|
const lows = filteredPending.filter((approval) => approval.risk === RISK_LEVEL.LOW);
|
|
for (const approval of lows) decide(approval, "approved");
|
|
};
|
|
|
|
return (
|
|
<div className="page">
|
|
<TopbarActions>
|
|
<RiskMenu filter={filter} onChange={setFilter} />
|
|
<Button tone="primary" onClick={approveAllLowRisk}>{t("Approve all low risk")}</Button>
|
|
</TopbarActions>
|
|
<h1 className="page-h1">{t("Approvals")}</h1>
|
|
<p className="page-sub">
|
|
{t("{pending} pending · {approved} approved this week", { pending: pending.length, approved: approved7d })}
|
|
</p>
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12, marginBottom: 24 }}>
|
|
<StatTile label={t("Pending")} value={String(pending.length)} tone="amber" />
|
|
<StatTile label={t("Median time")} value={formatMinutes(medianTime)} />
|
|
<StatTile label={t("Approved (7d)")} value={String(approved7d)} />
|
|
<StatTile label={t("Declined (7d)")} value={String(declined7d)} />
|
|
</div>
|
|
|
|
{filteredPending.length > 0 && <div className="section-label" style={{ marginBottom: 10 }}>{t("Pending")}</div>}
|
|
{filteredPending.map((approval) => (
|
|
<ApprovalRow
|
|
key={approval.id}
|
|
approval={approval}
|
|
requester={resolveRequester(approval, employees, agents)}
|
|
isBot={approval.requestedByType === "agent"}
|
|
onApprove={() => decide(approval, "approved")}
|
|
onDecline={() => decide(approval, "declined")}
|
|
onReview={() => setEditingId(approval.id)}
|
|
/>
|
|
))}
|
|
|
|
{decided.length > 0 && (
|
|
<>
|
|
<div className="section-label" style={{ margin: "32px 0 10px" }}>{t("Recently decided")}</div>
|
|
{driftError && <div style={{ color: "var(--red)", fontSize: 13, marginBottom: 8 }}>{t("Could not check approved content: {message}", { message: driftError })}</div>}
|
|
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
|
{shownDecided.map((approval, index) => (
|
|
<DecidedRow
|
|
key={approval.id}
|
|
approval={approval}
|
|
drift={drifts[approval.id] ?? null}
|
|
requester={resolveRequester(approval, employees, agents)}
|
|
decider={resolveDecider(approval, employees)}
|
|
last={index === shownDecided.length - 1}
|
|
focused={approval.id === focusedApproval}
|
|
onOpenTask={openTask}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Keyed by request, so notes typed for one never carry to another or survive closing. */}
|
|
<ApprovalDetailDialog
|
|
key={editing?.id ?? "none"}
|
|
approval={editing}
|
|
requester={editing ? resolveRequester(editing, employees, agents) : ""}
|
|
onClose={() => setEditingId(null)}
|
|
onDecide={(state, notes) => editing && decide(editing, state, notes)}
|
|
onOpenTask={openTask}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ApprovalDetailDialog({
|
|
approval,
|
|
requester,
|
|
onClose,
|
|
onDecide,
|
|
onOpenTask,
|
|
}: {
|
|
approval: ApprovalRecord | null;
|
|
requester: string;
|
|
onClose: () => void;
|
|
onDecide: (state: "approved" | "declined", notes?: string) => void;
|
|
onOpenTask: (taskId: string) => void;
|
|
}) {
|
|
const [notes, setNotes] = useState("");
|
|
if (!approval) return null;
|
|
const tone = RISK_TONES[approval.risk];
|
|
if (!tone) throw new Error("Approval risk tone is missing.");
|
|
return (
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
|
<div className="modal" role="dialog" aria-label={approval.title} onMouseDown={(event) => event.stopPropagation()}>
|
|
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
|
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
|
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
|
</div>
|
|
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 14 }}>
|
|
<span style={{ fontSize: 12, color: tone.color, fontWeight: 540, letterSpacing: "0.02em" }}>{tone.label}</span>
|
|
<span style={{ color: "var(--text-4)" }}>·</span>
|
|
<Pill>{approval.kind}</Pill>
|
|
</div>
|
|
<p style={{ margin: "0 0 16px", color: "var(--text-2)", fontSize: 14, lineHeight: 1.55, whiteSpace: "pre-wrap" }}>
|
|
{approval.details}
|
|
</p>
|
|
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "10px 14px", fontSize: 12 }}>
|
|
<span style={{ color: "var(--text-3)" }}>{t("Requested by")}</span>
|
|
<span>{requester}</span>
|
|
{approval.sourceEntityType && (
|
|
<>
|
|
<span style={{ color: "var(--text-3)" }}>{t("Source")}</span>
|
|
{approval.sourceEntityType === "task" && approval.sourceEntityId
|
|
? (
|
|
<span>
|
|
<button type="button" className="link-button" onClick={() => onOpenTask(approval.sourceEntityId!)}>{t("Edit the task")}</button>
|
|
<span style={{ color: "var(--text-3)" }}> · {t("Changing the task replaces this request with a fresh one.")}</span>
|
|
</span>
|
|
)
|
|
: <span>{humanizeKind(approval.sourceEntityType)}</span>}
|
|
</>
|
|
)}
|
|
<span style={{ color: "var(--text-3)" }}>{t("Requested")}</span>
|
|
<span>{formatRelative(approval.createdAt)}</span>
|
|
</div>
|
|
<textarea
|
|
className="input"
|
|
aria-label={t("Decision notes")}
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={2}
|
|
style={{ marginTop: 14 }}
|
|
/>
|
|
<div className="modal-actions">
|
|
<Button onClick={() => onDecide("declined", notes || undefined)}>{t("Decline")}</Button>
|
|
<Button tone="primary" onClick={() => onDecide("approved", notes || undefined)}>{t("Approve")}</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function humanizeKind(value: string): string {
|
|
const spaced = value.replace(/_/g, " ");
|
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
}
|
|
|
|
function RiskMenu({ filter, onChange }: { filter: RiskFilter; onChange: (next: RiskFilter) => void }) {
|
|
return (
|
|
<select
|
|
className="button"
|
|
value={filter}
|
|
onChange={(event) => onChange(event.target.value as RiskFilter)}
|
|
style={{ padding: "0 10px" }}
|
|
aria-label={t("Filter risk")}
|
|
>
|
|
{RISK_FILTERS.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
function StatTile({ label, value, tone }: { label: string; value: string; tone?: "amber" }) {
|
|
return (
|
|
<div className="card" style={{ padding: 14 }}>
|
|
<div className="card-label" style={{ marginBottom: 6 }}>{label}</div>
|
|
<div
|
|
className="num"
|
|
style={{ fontSize: 18, fontWeight: 600, color: tone === "amber" ? "var(--amber)" : "var(--text-1)", letterSpacing: "-0.02em" }}
|
|
>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ApprovalRow({
|
|
approval,
|
|
requester,
|
|
isBot,
|
|
onApprove,
|
|
onDecline,
|
|
onReview,
|
|
}: {
|
|
approval: ApprovalRecord;
|
|
requester: string;
|
|
isBot: boolean;
|
|
onApprove: () => void;
|
|
onDecline: () => void;
|
|
/** Opens the request to read it in full and decide with notes; what is requested is changed at its source. */
|
|
onReview: () => void;
|
|
}) {
|
|
const tone = RISK_TONES[approval.risk];
|
|
if (!tone) throw new Error("Approval risk tone is missing.");
|
|
return (
|
|
<div
|
|
style={{
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 12,
|
|
background: "var(--panel)",
|
|
padding: 18,
|
|
marginBottom: 10,
|
|
display: "grid",
|
|
gridTemplateColumns: "6px minmax(0, 1fr) auto",
|
|
gap: 16,
|
|
alignItems: "flex-start",
|
|
}}
|
|
>
|
|
<div style={{ width: 3, alignSelf: "stretch", background: tone.color, borderRadius: 2 }} />
|
|
<div style={{ minWidth: 0 }}>
|
|
<div className="row" style={{ marginBottom: 4 }}>
|
|
<span style={{ fontSize: 12, color: tone.color, fontWeight: 540, letterSpacing: "0.02em" }}>{tone.label}</span>
|
|
<span style={{ color: "var(--text-4)" }}>·</span>
|
|
<Pill>{approval.kind}</Pill>
|
|
</div>
|
|
<div style={{ fontSize: 15, fontWeight: 540, letterSpacing: "-0.01em", marginBottom: 4 }}>{approval.title}</div>
|
|
<div style={{ fontSize: 14, color: "var(--text-2)", lineHeight: 1.5, marginBottom: 12, whiteSpace: "pre-wrap" }}>
|
|
{approval.details}
|
|
</div>
|
|
<div className="row" style={{ gap: 8 }}>
|
|
<Avatar name={requester} size="sm" bot={isBot} />
|
|
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{t("Requested by {requester}", { requester })} · {formatRelative(approval.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
|
<Button tone="primary" onClick={onApprove}>{t("Approve")}</Button>
|
|
<Button onClick={onReview}>{t("Review")}</Button>
|
|
<Button tone="ghost" onClick={onDecline}>{t("Decline")}</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DecidedRow({
|
|
approval,
|
|
drift,
|
|
requester,
|
|
decider,
|
|
last,
|
|
focused,
|
|
onOpenTask,
|
|
}: {
|
|
approval: ApprovalRecord;
|
|
drift: ApprovalDrift | null;
|
|
requester: string;
|
|
decider: string;
|
|
last: boolean;
|
|
focused: boolean;
|
|
onOpenTask: (taskId: string) => void;
|
|
}) {
|
|
const rowRef = useRef<HTMLDivElement | null>(null);
|
|
useEffect(() => {
|
|
if (focused) rowRef.current?.scrollIntoView({ block: "center" });
|
|
}, [focused]);
|
|
const approved = approval.state === APPROVAL_STATE.APPROVED;
|
|
const superseded = approval.state === APPROVAL_STATE.SUPERSEDED;
|
|
const decidedLabel = approved ? t("Approved") : superseded ? t("Replaced by an edit") : t("Declined");
|
|
return (
|
|
<div
|
|
ref={rowRef}
|
|
className="row"
|
|
aria-current={focused ? "true" : undefined}
|
|
style={{
|
|
gap: 12,
|
|
padding: "12px 18px",
|
|
fontSize: 14,
|
|
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
|
background: focused ? "var(--accent-soft)" : undefined,
|
|
}}
|
|
>
|
|
<Icon name={approved ? "check" : "x"} size={14} color={approved ? "var(--green)" : superseded ? "var(--text-3)" : "var(--red)"} />
|
|
<div style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
|
{approval.sourceEntityType === "task" && approval.sourceEntityId && (
|
|
<button type="button" className="link-button" style={{ fontSize: 12 }} onClick={() => onOpenTask(approval.sourceEntityId!)}>{t("Open task")}</button>
|
|
)}
|
|
{drift && <Pill tone="amber">{drift === "changed" ? t("Changed since approval") : t("Removed since approval")}</Pill>}
|
|
<Avatar name={requester} size="sm" bot />
|
|
<div style={{ fontSize: 12, color: "var(--text-3)", width: 180, textAlign: "right", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
{decidedLabel} · {formatRelative(approval.decidedAt ?? approval.createdAt)}{superseded ? "" : ` · ${decider.split(" ")[0]}`}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[], agents: AgentRecord[]): string {
|
|
if (approval.requestedByType === "employee" && approval.requestedById) {
|
|
return employees.find((item) => item.id === approval.requestedById)?.displayName ?? t("Unknown");
|
|
}
|
|
if (approval.requestedByType === "agent" && approval.requestedById) {
|
|
return agents.find((item) => item.id === approval.requestedById)?.name ?? t("Agent");
|
|
}
|
|
return t("System");
|
|
}
|
|
|
|
function resolveDecider(approval: ApprovalRecord, employees: EmployeeRecord[]): string {
|
|
if (!approval.decidedByEmployeeId) return "—";
|
|
return employees.find((item) => item.id === approval.decidedByEmployeeId)?.displayName ?? t("Unknown");
|
|
}
|
|
|
|
function withinDays(iso: string | null, days: number): boolean {
|
|
if (!iso) return false;
|
|
const target = new Date(iso).getTime();
|
|
return Date.now() - target < days * 24 * 60 * 60 * 1000;
|
|
}
|
|
|
|
function computeMedianMinutes(approvals: ApprovalRecord[]): number {
|
|
const intervals = approvals
|
|
.map((approval) => approval.decidedAt ? new Date(approval.decidedAt).getTime() - new Date(approval.createdAt).getTime() : null)
|
|
.filter((interval): interval is number => interval !== null && interval > 0)
|
|
.map((interval) => Math.round(interval / 60_000))
|
|
.sort((a, b) => a - b);
|
|
if (intervals.length === 0) return 0;
|
|
const mid = Math.floor(intervals.length / 2);
|
|
const lower = intervals[mid - 1];
|
|
const higher = intervals[mid];
|
|
if (intervals.length % 2 === 1) return higher ?? 0;
|
|
if (lower === undefined || higher === undefined) return 0;
|
|
return Math.round((lower + higher) / 2);
|
|
}
|
|
|
|
function formatMinutes(minutes: number): string {
|
|
if (minutes <= 0) return "—";
|
|
if (minutes < 60) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const remaining = minutes % 60;
|
|
return remaining === 0 ? `${hours}h` : `${hours}h ${remaining}m`;
|
|
}
|