Add approval detail review and fix the declined-row icon
This commit is contained in:
1 parent
a9300682ee
commit
da14be3443
1 file changed
+75
-6
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Avatar, Button, Icon, Pill } from "../shared/ui";
|
import { Avatar, Button, Icon, IconButton, Pill } from "../shared/ui";
|
||||||
import { TopbarActions } from "../shared/TopbarActions";
|
import { TopbarActions } from "../shared/TopbarActions";
|
||||||
import type {
|
import type {
|
||||||
AgentRecord,
|
AgentRecord,
|
||||||
@@ -30,6 +30,7 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
|||||||
const orgId = activeOrgId(snapshot);
|
const orgId = activeOrgId(snapshot);
|
||||||
const employee = requireActiveEmployee(snapshot);
|
const employee = requireActiveEmployee(snapshot);
|
||||||
const [filter, setFilter] = useState<RiskFilter>("all");
|
const [filter, setFilter] = useState<RiskFilter>("all");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const approvals = useMemo(() => snapshot.approvals.filter((item) => item.orgId === orgId), [snapshot.approvals, orgId]);
|
const approvals = useMemo(() => snapshot.approvals.filter((item) => item.orgId === orgId), [snapshot.approvals, orgId]);
|
||||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||||
@@ -43,13 +44,17 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
|||||||
const declined7d = weeklyDecided.filter((approval) => approval.state === APPROVAL_STATE.DECLINED).length;
|
const declined7d = weeklyDecided.filter((approval) => approval.state === APPROVAL_STATE.DECLINED).length;
|
||||||
const medianTime = computeMedianMinutes(weeklyDecided);
|
const medianTime = computeMedianMinutes(weeklyDecided);
|
||||||
|
|
||||||
const decide = (approval: ApprovalRecord, state: "approved" | "declined") =>
|
const editing = editingId ? approvals.find((approval) => approval.id === editingId) ?? null : null;
|
||||||
|
|
||||||
|
const decide = (approval: ApprovalRecord, state: "approved" | "declined") => {
|
||||||
|
setEditingId(null);
|
||||||
void actions.run(() => window.agentCompany.decideApproval({
|
void actions.run(() => window.agentCompany.decideApproval({
|
||||||
orgId,
|
orgId,
|
||||||
approvalId: approval.id,
|
approvalId: approval.id,
|
||||||
state,
|
state,
|
||||||
decidedByEmployeeId: employee.id,
|
decidedByEmployeeId: employee.id,
|
||||||
}));
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const approveAllLowRisk = () => {
|
const approveAllLowRisk = () => {
|
||||||
const lows = filteredPending.filter((approval) => approval.risk === RISK_LEVEL.LOW);
|
const lows = filteredPending.filter((approval) => approval.risk === RISK_LEVEL.LOW);
|
||||||
@@ -83,6 +88,7 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
|||||||
isBot={approval.requestedByType === "agent"}
|
isBot={approval.requestedByType === "agent"}
|
||||||
onApprove={() => decide(approval, "approved")}
|
onApprove={() => decide(approval, "approved")}
|
||||||
onDecline={() => decide(approval, "declined")}
|
onDecline={() => decide(approval, "declined")}
|
||||||
|
onEdit={() => setEditingId(approval.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -102,10 +108,70 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ApprovalDetailDialog
|
||||||
|
approval={editing}
|
||||||
|
requester={editing ? resolveRequester(editing, employees, agents) : ""}
|
||||||
|
onClose={() => setEditingId(null)}
|
||||||
|
onApprove={() => editing && decide(editing, "approved")}
|
||||||
|
onDecline={() => editing && decide(editing, "declined")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ApprovalDetailDialog({
|
||||||
|
approval,
|
||||||
|
requester,
|
||||||
|
onClose,
|
||||||
|
onApprove,
|
||||||
|
onDecline,
|
||||||
|
}: {
|
||||||
|
approval: ApprovalRecord | null;
|
||||||
|
requester: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onApprove: () => void;
|
||||||
|
onDecline: () => void;
|
||||||
|
}) {
|
||||||
|
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="Close" onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 11, 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: 13, lineHeight: 1.55, whiteSpace: "pre-wrap" }}>
|
||||||
|
{approval.details}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "10px 14px", fontSize: 12.5 }}>
|
||||||
|
<span style={{ color: "var(--text-3)" }}>Requested by</span>
|
||||||
|
<span>{requester}</span>
|
||||||
|
{approval.sourceEntityType && (<><span style={{ color: "var(--text-3)" }}>Source</span><span>{humanizeKind(approval.sourceEntityType)}</span></>)}
|
||||||
|
<span style={{ color: "var(--text-3)" }}>Requested</span>
|
||||||
|
<span>{formatRelative(approval.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button onClick={onDecline}>Decline</Button>
|
||||||
|
<Button tone="primary" onClick={onApprove}>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 }) {
|
function RiskMenu({ filter, onChange }: { filter: RiskFilter; onChange: (next: RiskFilter) => void }) {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
@@ -140,12 +206,14 @@ function ApprovalRow({
|
|||||||
isBot,
|
isBot,
|
||||||
onApprove,
|
onApprove,
|
||||||
onDecline,
|
onDecline,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
approval: ApprovalRecord;
|
approval: ApprovalRecord;
|
||||||
requester: string;
|
requester: string;
|
||||||
isBot: boolean;
|
isBot: boolean;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onDecline: () => void;
|
onDecline: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const tone = RISK_TONES[approval.risk];
|
const tone = RISK_TONES[approval.risk];
|
||||||
if (!tone) throw new Error("Approval risk tone is missing.");
|
if (!tone) throw new Error("Approval risk tone is missing.");
|
||||||
@@ -181,7 +249,8 @@ function ApprovalRow({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, alignItems: "flex-end" }}>
|
||||||
<Button tone="primary" onClick={onApprove}>Approve</Button>
|
<Button tone="primary" onClick={onApprove}>Approve</Button>
|
||||||
<Button onClick={onDecline}>Decline</Button>
|
<Button onClick={onEdit}>Edit</Button>
|
||||||
|
<Button tone="ghost" onClick={onDecline}>Decline</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -198,8 +267,8 @@ function DecidedRow({
|
|||||||
decider: string;
|
decider: string;
|
||||||
last: boolean;
|
last: boolean;
|
||||||
}) {
|
}) {
|
||||||
const decidedTone = approval.state === APPROVAL_STATE.APPROVED ? "var(--green)" : "var(--text-3)";
|
const approved = approval.state === APPROVAL_STATE.APPROVED;
|
||||||
const decidedLabel = approval.state === APPROVAL_STATE.APPROVED ? "Approved" : "Declined";
|
const decidedLabel = approved ? "Approved" : "Declined";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="row"
|
className="row"
|
||||||
@@ -210,7 +279,7 @@ function DecidedRow({
|
|||||||
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
borderBottom: last ? "none" : "1px solid var(--border-soft)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="check" size={14} color={decidedTone} />
|
<Icon name={approved ? "check" : "x"} size={14} color={approved ? "var(--green)" : "var(--red)"} />
|
||||||
<div style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
<div style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
||||||
<Avatar name={requester} size="sm" bot />
|
<Avatar name={requester} size="sm" bot />
|
||||||
<div style={{ fontSize: 12, color: "var(--text-3)", width: 180, textAlign: "right", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<div style={{ fontSize: 12, color: "var(--text-3)", width: 180, textAlign: "right", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
|||||||
Reference in new issue
Block a user