Approval And Case Patterns
Compose multi-party approvals and complaint case workbenches from f-ui page recipes — including the Role Surface Formula for different viewers on the same instance.
Use this playbook when integrators ask for 审批 (multi-department approvals) or 投诉 / 反馈 (complaint case management). Industry splits a workflow engine from a workbench UI. f-ui owns the workbench composition; the host owns routing rules (会签 / 或签 / timers / audit persistence).
Engine ≠ UI Kit
Do not look for an installable Approval registry component that runs BPMN. Salesforce Flow Approval, 钉钉 / 飞书审批, and Flowable all keep routing in an engine (or SaaS API) and ship inbox + instance pages on top. Copy the showcases below; wire Approve / Reject to your API.
When To Use
- Multi-party or multi-department approval of a business request (purchase, expense, access).
- Different roles see different UI (or no entry) on the same instance.
- Complaint / feedback cases with triage, investigation tasks, and resolution.
- You already have (or will add) a host workflow engine / ticket service.
Use CRUD Page Patterns for ordinary entity list → detail → form. Use Stepper for named stage progress; use Steps Form only when the user is filling a multi-step intake wizard. Use Domain Status Vs Derived View when lifecycle enums, derived badges, and FE “status” language start to diverge — keep outcome ⊥ provenance; do not invent a client-owned lifecycle.
Examples
Composition sketches only — not a BPM engine. Copy the live /showcases/approvals and /showcases/complaints routes for full QueryList chrome. Domain is generic purchase approval and complaint case work.
Role Surface Formula
Same purchase request instance; switch View as Requester / Approver / Watcher. Regions (Budget card), field sets, line prices, and footer actions come from a policy bag — L2–L4 on one Object Page Hub.
View as
PO-2026-0841
Lab analyzers — Q3 capacity upgrade
My pending · Budget card · Approve / Reject as current assignee
Request
- Request id:
- PO-2026-0841
- Requester:
- Mina Chen
- Department:
- R&D Ops
- Cost center:
- CC-4802
- Vendor:
- Northwind Scientific
- Amount:
- USD 186,400.00
- Needed by:
- 2026-09-15
- Justification:
- Replace two end-of-life analyzers. Finance + Compliance countersign, then VP over $100k.
- Status:
- In progress
Budget check
Cost center CC-4802 has USD 210,000 remaining this quarter.
L2 · Work Guide — mount only for the Finance assignee node
Line items
- Analyzer A-200 · qty 1USD 92,000
- Analyzer A-200 · qty 1USD 94,400
"use client";
import { useState } from "react";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
import {
PURCHASE_REQUEST,
ROLE_POLICIES,
headerFieldsFor,
type RoleSurface,
} from "./demo-data";
import { SectionLabel, SketchCard, SketchFrame } from "./demo-shell";
const ROLES: RoleSurface[] = ["requester", "approver", "watcher"];
/**
* L2–L4 Role Surface Formula on one purchase instance.
* Switch View as — regions, fields, and footer actions change from a policy bag.
*/
export function ApprovalRoleSurfaceDemo() {
const [role, setRole] = useState<RoleSurface>("approver");
const policy = ROLE_POLICIES[role];
const visibleKeys = [...policy.readFields, ...policy.editFields];
const fields = headerFieldsFor(visibleKeys);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-muted-foreground">View as</p>
{ROLES.map((id) => (
<Button
key={id}
type="button"
variant={role === id ? "default" : "outline"}
onClick={() => setRole(id)}
>
{ROLE_POLICIES[id].label}
</Button>
))}
</div>
<SketchFrame heightClassName="h-[460px]">
<PageContainer
fixedHeader
surface="muted"
title={PURCHASE_REQUEST.requestId}
subTitle={PURCHASE_REQUEST.title}
tags={<StatusTag tone="info">In progress</StatusTag>}
content={
<p className="text-sm text-muted-foreground">{policy.banner}</p>
}
>
<div className="space-y-4">
<SketchCard>
<SectionLabel>Request</SectionLabel>
<Descriptions
record={PURCHASE_REQUEST}
fields={fields}
column={2}
size="small"
layout="horizontal"
/>
{policy.editFields.includes("justification") ? (
<p className="text-xs text-muted-foreground">
Justification is editable for this seat (L3 · E) — other
viewers see it read-only or hidden.
</p>
) : null}
</SketchCard>
{policy.regions.budget ? (
<SketchCard>
<SectionLabel>Budget check</SectionLabel>
<p className="text-sm">
Cost center CC-4802 has USD 210,000 remaining this quarter.
</p>
<p className="text-xs text-muted-foreground">
L2 · Work Guide — mount only for the Finance assignee node
</p>
</SketchCard>
) : null}
{policy.regions.lines ? (
<SketchCard>
<SectionLabel>Line items</SectionLabel>
<ul className="space-y-1 text-sm">
<li className="flex justify-between gap-4">
<span>Analyzer A-200 · qty 1</span>
{role === "watcher" ? (
<span className="text-muted-foreground">—</span>
) : (
<span className="tabular-nums">USD 92,000</span>
)}
</li>
<li className="flex justify-between gap-4">
<span>Analyzer A-200 · qty 1</span>
{role === "watcher" ? (
<span className="text-muted-foreground">—</span>
) : (
<span className="tabular-nums">USD 94,400</span>
)}
</li>
</ul>
{role === "watcher" ? (
<p className="text-xs text-muted-foreground">
L3 · unit prices hidden for CC / watcher
</p>
) : null}
</SketchCard>
) : null}
<div className="flex flex-wrap items-center gap-2 border-t bg-background pt-3">
{policy.actions.length === 0 ? (
<p className="text-sm text-muted-foreground">
L4 · no actions for this viewer
</p>
) : (
policy.actions.map((action) => (
<Button
key={action}
type="button"
variant={
action === "approve"
? "default"
: action === "reject"
? "destructive"
: "outline"
}
>
{action === "approve"
? "Approve"
: action === "reject"
? "Reject"
: action === "urge"
? "Urge"
: "Withdraw"}
</Button>
))
)}
</div>
</div>
</PageContainer>
</SketchFrame>
</div>
);
}Submit Seat Vs Approve Seat
Create / submit belongs on the requester seat. Approver nav is inbox → instance review only — do not park a permanent Create item on the approver sidebar “for demo convenience.”
Wrong
Approver sidebar
Permanent Create on the approver seat trains the wrong job model
Right
Approver sidebar
No Create in nav — review from Inbox → instance only
Put Create / Apply on the requester (or partner) portal instead
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
function NavSketch({
seat,
items,
active,
}: {
seat: string;
items: string[];
active: string;
}) {
return (
<div className="space-y-3">
<p className="text-sm font-medium text-muted-foreground">{seat}</p>
<nav className="space-y-1 rounded-lg border bg-muted/30 p-2">
{items.map((label) => (
<div
key={label}
className={
label === active
? "rounded-md bg-background px-2 py-1.5 text-sm font-medium shadow-sm"
: "rounded-md px-2 py-1.5 text-sm text-muted-foreground"
}
>
{label}
</div>
))}
</nav>
</div>
);
}
/**
* Submit seat ≠ approve seat — Create lives on the requester nav only.
*/
export function ApprovalSubmitVsApproveSeatDemo() {
return (
<DesignCompare
wrong={
<div className="space-y-3">
<NavSketch
seat="Approver sidebar"
items={["Inbox", "Reports", "Create request"]}
active="Inbox"
/>
<Button type="button" className="w-full">
<PlusIcon className="size-4" />
Create request
</Button>
<p className="text-xs text-muted-foreground">
Permanent Create on the approver seat trains the wrong job model
</p>
</div>
}
right={
<div className="space-y-3">
<NavSketch
seat="Approver sidebar"
items={["Inbox", "CC to me", "Reports"]}
active="Inbox"
/>
<p className="rounded-lg border border-dashed px-2 py-3 text-center text-xs text-muted-foreground">
No Create in nav — review from Inbox → instance only
</p>
<p className="text-xs text-muted-foreground">
Put Create / Apply on the requester (or partner) portal instead
</p>
</div>
}
/>
);
}Inbox Tabs (L0)
PageContainer tabs change which rows exist for the viewer. PO-2026-0900 (Restricted) appears under My pending only — absent from CC to me is L0 denial (no row ⇒ no detail).
PO-2026-0900 appears under My pending only.
Approvals
Inbox slices for one viewer
- PO-2026-0841Lab analyzers — Q3 capacityMina ChenUSD 186,400
- PO-2026-0820Office chairs — floor 4Alex KimUSD 4,200
- PO-2026-0900RestrictedRestricted tooling (pending only)Sam RiveraUSD 62,000
Composition sketch — live list chrome lives on /showcases/approvals with QueryList.
"use client";
import { useMemo, useState } from "react";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { StatusTag } from "@/components/f-ui/status-tag";
import {
INBOX_TABS,
sliceInbox,
type InboxTab,
} from "./demo-data";
import { SketchFrame } from "./demo-shell";
/**
* L0 inbox slices — tabs change which rows exist. No row ⇒ no detail.
*/
export function ApprovalInboxTabsDemo() {
const [tab, setTab] = useState<InboxTab>("pending");
const rows = useMemo(() => sliceInbox(tab), [tab]);
const restrictedVisible = rows.some((r) => r.requestId === "PO-2026-0900");
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
{restrictedVisible
? "PO-2026-0900 appears under My pending only."
: "PO-2026-0900 is absent from this slice — L0 denial (no detail)."}
</p>
<SketchFrame heightClassName="h-[380px]">
<PageContainer
fixedHeader
surface="muted"
title="Approvals"
subTitle="Inbox slices for one viewer"
tabs={{
activeKey: tab,
onChange: (key) => setTab(key as InboxTab),
items: INBOX_TABS.map((item) => ({
key: item.id,
label: item.label,
})),
}}
>
<div className="overflow-hidden rounded-xl border bg-card">
<div className="grid grid-cols-[7rem_1fr_6rem_7rem] gap-2 border-b px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Request id</span>
<span>Title</span>
<span>Requester</span>
<span className="text-end">Amount</span>
</div>
{rows.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No requests in this slice
</p>
) : (
<ul className="divide-y text-sm">
{rows.map((row) => (
<li
key={row.id}
className="grid grid-cols-[7rem_1fr_6rem_7rem] items-center gap-2 px-4 py-2.5"
>
<span className="flex flex-wrap items-center gap-1.5 font-medium text-primary">
{row.requestId}
{row.requestId === "PO-2026-0900" ? (
<StatusTag tone="warning">Restricted</StatusTag>
) : null}
</span>
<span className="truncate">{row.title}</span>
<span>{row.requester}</span>
<span className="text-end tabular-nums">{row.amount}</span>
</li>
))}
</ul>
)}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Composition sketch — live list chrome lives on{" "}
<span className="font-medium">/showcases/approvals</span> with
QueryList.
</p>
</PageContainer>
</SketchFrame>
</div>
);
}Work Guide Regions (L2)
Wrong: god-view that always mounts Budget / Risk / VP cards. Right: mount Work Guide cards only for the persona/node that owns that work; empty regions stay unmounted (Salesforce hide-when-empty).
Wrong
God-view detail
Budget check
Always visible
Risk checklist
Always visible
VP override notes
Always visible
Every viewer sees every work card — buyers reject multi-role UX
Right
Policy-driven regions
Budget check
Finance assignee only
Risk / VP cards omitted — empty regions stay unmounted
Host policy bag drives L2 · UI does not hard-code names in JSX
Try
PO-2026-0841
Approver
Request
- Request id:
- PO-2026-0841
- Requester:
- Mina Chen
- Vendor:
- Northwind Scientific
- Amount:
- USD 186,400.00
- Status:
- In progress
Budget check
Remaining budget covers this request. Confirm cost center before Approve.
"use client";
import { useState } from "react";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
import {
PURCHASE_REQUEST,
ROLE_POLICIES,
headerFieldsFor,
type RoleSurface,
} from "./demo-data";
import { SectionLabel, SketchCard, SketchFrame } from "./demo-shell";
function WorkGuideSurface({ role }: { role: RoleSurface }) {
const policy = ROLE_POLICIES[role];
const fields = headerFieldsFor([
"requestId",
"requester",
"vendor",
"amount",
"status",
]);
return (
<SketchFrame heightClassName="h-[360px]">
<PageContainer
fixedHeader
surface="muted"
title={PURCHASE_REQUEST.requestId}
tags={<StatusTag tone="info">In progress</StatusTag>}
subTitle={policy.label}
>
<div className="space-y-3">
<SketchCard>
<SectionLabel>Request</SectionLabel>
<Descriptions
record={PURCHASE_REQUEST}
fields={fields}
column={2}
size="small"
layout="horizontal"
/>
</SketchCard>
{policy.regions.budget ? (
<SketchCard className="border-info/30">
<SectionLabel>Budget check</SectionLabel>
<p className="text-sm">
Remaining budget covers this request. Confirm cost center before
Approve.
</p>
</SketchCard>
) : null}
{policy.regions.risk ? (
<SketchCard>
<SectionLabel>Risk checklist</SectionLabel>
<p className="text-sm">Vendor on approved list · dual use: clear</p>
</SketchCard>
) : null}
{!policy.regions.budget && !policy.regions.risk ? (
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
No Work Guide card for this persona · hide when empty (SF pattern)
</p>
) : null}
</div>
</PageContainer>
</SketchFrame>
);
}
/**
* L2 Work Guide — mount dept work cards only for the owning persona/node.
* Wrong: always show every work card. Right: hide-when-empty from policy.
*/
export function ApprovalWorkGuideDemo() {
const [role, setRole] = useState<RoleSurface>("approver");
return (
<div className="flex flex-col gap-4">
<DesignCompare
wrong={
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">
God-view detail
</p>
<SketchCard>
<SectionLabel>Budget check</SectionLabel>
<p className="text-sm">Always visible</p>
</SketchCard>
<SketchCard>
<SectionLabel>Risk checklist</SectionLabel>
<p className="text-sm">Always visible</p>
</SketchCard>
<SketchCard>
<SectionLabel>VP override notes</SectionLabel>
<p className="text-sm">Always visible</p>
</SketchCard>
<p className="text-xs text-muted-foreground">
Every viewer sees every work card — buyers reject multi-role UX
</p>
</div>
}
right={
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Policy-driven regions
</p>
<SketchCard className="border-success/30">
<SectionLabel>Budget check</SectionLabel>
<p className="text-sm">Finance assignee only</p>
</SketchCard>
<p className="rounded-lg border border-dashed px-3 py-3 text-center text-xs text-muted-foreground">
Risk / VP cards omitted — empty regions stay unmounted
</p>
<p className="text-xs text-muted-foreground">
Host policy bag drives L2 · UI does not hard-code names in JSX
</p>
</div>
}
/>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-muted-foreground">Try</p>
{(["requester", "approver", "watcher"] as RoleSurface[]).map((id) => (
<Button
key={id}
type="button"
variant={role === id ? "default" : "outline"}
onClick={() => setRole(id)}
>
{ROLE_POLICIES[id].label}
</Button>
))}
</div>
<WorkGuideSurface role={role} />
</div>
);
}Field Matrix E / R / H (L3)
宜搭 vocabulary on the same instance: Edit / Read / Hide. Omit hidden keys from Descriptions; keep editable seats explicit. Matrix first, then View as to see the rendered subset.
View as
PO-2026-0841
L3 matrix · Requester
Visible fields
- Request id:
- PO-2026-0841
- Requester:
- Mina Chen
- Department:
- R&D Ops
- Vendor:
- Northwind Scientific
- Amount:
- USD 186,400.00
- Needed by:
- 2026-09-15
- Status:
- In progress
Hidden for this seat (omit from Descriptions): costCenter
"use client";
import { useMemo, useState } from "react";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { Form } from "@/components/f-ui/formily/form";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { LabelTag } from "@/components/f-ui/label-tag";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { StatusTag } from "@/components/f-ui/status-tag";
import { Button } from "@/components/ui/button";
import {
PURCHASE_REQUEST,
ROLE_POLICIES,
headerFieldsFor,
type RoleSurface,
} from "./demo-data";
import { SectionLabel, SketchCard, SketchFrame } from "./demo-shell";
type MatrixMode = "E" | "R" | "H";
const MATRIX: {
field: string;
requester: MatrixMode;
approver: MatrixMode;
watcher: MatrixMode;
}[] = [
{ field: "Justification", requester: "E", approver: "R", watcher: "H" },
{ field: "Cost center", requester: "H", approver: "R", watcher: "H" },
{ field: "Unit prices", requester: "R", approver: "R", watcher: "H" },
{ field: "Amount", requester: "R", approver: "R", watcher: "R" },
{ field: "Vendor", requester: "R", approver: "R", watcher: "R" },
];
function ModeChip({ mode }: { mode: MatrixMode }) {
if (mode === "E") {
return <LabelTag accent="accent-2">Edit</LabelTag>;
}
if (mode === "R") {
return <LabelTag accent="accent-1">Read</LabelTag>;
}
return <LabelTag>Hide</LabelTag>;
}
type JustificationValues = { justification: string };
/**
* L3 field matrix — Edit / Read / Hide per (viewer, node).
* Edit seat uses Formily (no hand-rolled Input).
*/
export function ApprovalFieldMatrixDemo() {
const [role, setRole] = useState<RoleSurface>("requester");
const policy = ROLE_POLICIES[role];
const readFields = headerFieldsFor(policy.readFields);
const form = useMemo(
() =>
createForm<JustificationValues>({
initialValues: { justification: PURCHASE_REQUEST.justification },
}),
[],
);
return (
<div className="flex flex-col gap-4">
<div className="overflow-hidden rounded-xl border bg-card">
<div className="grid grid-cols-4 gap-2 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Field</span>
<span>Requester</span>
<span>Approver</span>
<span>Watcher</span>
</div>
{MATRIX.map((row) => (
<div
key={row.field}
className="grid grid-cols-4 items-center gap-2 border-b px-3 py-2 text-sm last:border-0"
>
<span>{row.field}</span>
<ModeChip mode={row.requester} />
<ModeChip mode={row.approver} />
<ModeChip mode={row.watcher} />
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-muted-foreground">View as</p>
{(["requester", "approver", "watcher"] as RoleSurface[]).map((id) => (
<Button
key={id}
type="button"
variant={role === id ? "default" : "outline"}
onClick={() => setRole(id)}
>
{ROLE_POLICIES[id].label}
</Button>
))}
</div>
<SketchFrame heightClassName="h-[400px]">
<PageContainer
fixedHeader
surface="muted"
title={PURCHASE_REQUEST.requestId}
tags={<StatusTag tone="info">In progress</StatusTag>}
subTitle={`L3 matrix · ${policy.label}`}
>
<SketchCard>
<SectionLabel>Visible fields</SectionLabel>
<Descriptions
record={PURCHASE_REQUEST}
fields={readFields}
column={2}
size="small"
layout="horizontal"
/>
{policy.editFields.includes("justification") ? (
<Form
form={form}
density="compact"
onSubmit={() => undefined}
>
<FormField
kind="textarea"
name="justification"
label="Justification"
required
/>
</Form>
) : null}
{policy.hideFields.length > 0 ? (
<p className="text-xs text-muted-foreground">
Hidden for this seat (omit from Descriptions):{" "}
{policy.hideFields.join(", ")}
</p>
) : (
<p className="text-xs text-muted-foreground">
No hidden header fields for Approver on this node
</p>
)}
</SketchCard>
</PageContainer>
</SketchFrame>
</div>
);
}Case Triage Compose
Complaint case hub: Summary Descriptions + vertical lifecycle Stepper + Tasks / Activity tabs. Host owns ticket routing; UI composes PageContainer regions.
CS-2026-4412
Damaged shipment on order ORD-8891 — customer requests replacement.
Summary
- Case id:
- CS-2026-4412
- Customer:
- Harbor Retail Co.
- Channel:
- Severity:
- High
- Status:
- Triage
- Summary:
- Damaged shipment on order ORD-8891 — customer requests replacement.
Lifecycle
"use client";
import { useState } from "react";
import { Descriptions } from "@/components/f-ui/descriptions/descriptions";
import { PageContainer } from "@/components/f-ui/page/page-container";
import { StatusTag } from "@/components/f-ui/status-tag";
import {
STEPPER_SEPARATOR_ABSOLUTE_VERTICAL,
Stepper,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/f-ui/stepper/stepper";
import { Button } from "@/components/ui/button";
import {
COMPLAINT_STAGES,
SAMPLE_COMPLAINT,
complaintHeaderFields,
} from "./demo-data";
import { SectionLabel, SketchCard, SketchFrame } from "./demo-shell";
const PAGE_TABS = [
{ key: "overview", label: "Overview" },
{ key: "tasks", label: "Tasks" },
{ key: "activity", label: "Activity" },
] as const;
type PageTab = (typeof PAGE_TABS)[number]["key"];
/**
* Complaint case hub — Summary + lifecycle Stepper + triage work.
* Composition only; host owns routing / ticket engine.
*/
export function CaseTriageComposeDemo() {
const [tab, setTab] = useState<PageTab>("overview");
return (
<SketchFrame heightClassName="h-[480px]">
<PageContainer
fixedHeader
surface="muted"
title={SAMPLE_COMPLAINT.caseId}
subTitle={SAMPLE_COMPLAINT.summary}
tags={
<>
<StatusTag tone="warning">High</StatusTag>
<StatusTag tone="info">Triage</StatusTag>
</>
}
tabs={{
activeKey: tab,
onChange: (key) => setTab(key as PageTab),
items: PAGE_TABS.map((item) => ({
key: item.key,
label: item.label,
})),
}}
>
{tab === "overview" ? (
<div className="flex flex-col gap-4 lg:flex-row">
<SketchCard className="min-w-0 flex-1">
<SectionLabel>Summary</SectionLabel>
<Descriptions
record={SAMPLE_COMPLAINT}
fields={complaintHeaderFields}
column={1}
size="small"
layout="horizontal"
/>
</SketchCard>
<SketchCard className="w-full lg:max-w-xs">
<SectionLabel>Lifecycle</SectionLabel>
<Stepper value={1} orientation="vertical" className="w-full">
<StepperNav>
{COMPLAINT_STAGES.map((stage, index) => (
<StepperItem
key={stage.id}
step={index}
className="relative items-start not-last:flex-1"
>
<StepperTrigger className="items-start gap-2.5 pb-10 last:pb-0">
<StepperIndicator />
<div className="mt-0.5 flex flex-col gap-0.5 text-left">
<StepperTitle>{stage.title}</StepperTitle>
{stage.status === "process" ? (
<StepperDescription>
Assign owner · set severity
</StepperDescription>
) : null}
</div>
</StepperTrigger>
{index < COMPLAINT_STAGES.length - 1 ? (
<StepperSeparator
className={STEPPER_SEPARATOR_ABSOLUTE_VERTICAL}
/>
) : null}
</StepperItem>
))}
</StepperNav>
</Stepper>
</SketchCard>
</div>
) : null}
{tab === "tasks" ? (
<SketchCard>
<div className="flex items-center justify-between gap-2">
<SectionLabel>Investigation tasks</SectionLabel>
<Button type="button" variant="outline">
Add task
</Button>
</div>
<ul className="divide-y rounded-lg border text-sm">
<li className="flex items-center justify-between gap-3 px-3 py-2.5">
<span>Logistics — confirm carrier scan</span>
<StatusTag tone="warning">Open</StatusTag>
</li>
<li className="flex items-center justify-between gap-3 px-3 py-2.5">
<span>Warehouse — photo of outer carton</span>
<StatusTag tone="info">In progress</StatusTag>
</li>
</ul>
<p className="text-xs text-muted-foreground">
Region table chrome on the live showcase uses QueryList + toolbar
on one row
</p>
</SketchCard>
) : null}
{tab === "activity" ? (
<SketchCard>
<SectionLabel>Activity</SectionLabel>
<ul className="space-y-3 text-sm">
<li>
<p className="font-medium">Case opened</p>
<p className="text-muted-foreground">
Intake from email · 2026-08-09 09:14
</p>
</li>
<li>
<p className="font-medium">Severity set to High</p>
<p className="text-muted-foreground">
Triage · SLA clock started
</p>
</li>
</ul>
<p className="text-xs text-muted-foreground">
Dialogue Activity uses Comment Thread; audit tab uses Timeline
</p>
</SketchCard>
) : null}
</PageContainer>
</SketchFrame>
);
}Role Surface Formula (L0–L4)
This is the 万能公式. Every serious OA / BPM / case product implements the same five layers. If your demo only shows a god-view detail, buyers will correctly say you cannot do multi-role UX.
UI(viewer, node, instance) =
L0 Inbox slice(viewer) → no row = no detail
∩ L1 Page / portal variant(viewer) → optional heavy shell split
∩ L2 Region visibility(viewer, node) → cards / tabs / Work Guide
∩ L3 Field matrix(viewer, node) → E|R|H → edit | read | hide
∩ L4 Action matrix(viewer, node, workItem)| Layer | Industry gold | f-ui composition | Live showcase |
|---|---|---|---|
| L0 | 飞书 待审批 / 已办 / 抄送我 / 已发起 | PageContainer tabs + filtered QueryList | /showcases/approvals |
| L1 | ServiceNow Audience page variants; Pega Portal × Persona | Usually one Object Page Hub; split portals only when IA differs | Documented — not a separate route wave |
| L2 | SF Work Guide hide-when-empty; dept work cards | Conditional DetailCard / tabs | /showcases/approvals-detail View as |
| L3 | 钉钉宜搭 / 飞书 / 芋道:编辑 / 只读 / 隐藏 | Subset Descriptions fields; hide line price columns | Same · cost center / unit prices |
| L4 | 飞书操作权限;仅当前办理人 | Conditional FooterToolbar | Same · Approve vs Urge/Withdraw vs none |
Host responsibility: return a policy bag for (viewer, instance) — inbox membership, visible regions, field E|R|H, allowed actions.
UI responsibility: render from that policy. Do not scatter if (userName === "Priya") across JSX; keep a matrix (showcase: APPROVAL_PERSONAS in approvals-mock.ts).
Formula Extensions (Line / Provenance / Triad)
Keep L0–L4. Packs with line items (purchase lines, invoice match, allocation rows) also need three row- and field-level extensions — without them buyers will say “you only demo header approvals.”
UI = L0 ∩ L1 ∩ L2 ∩ L3 ∩ L4
∩ LinePolicy(line, viewer, node)
∩ Provenance(field) → chrome
∩ ValidationTriad(check) → block | confirm | pass| Extension | Meaning | Live showcase |
|---|---|---|
| Line Policy | Per-line status; whole-order Approve only when every line is approved | Compose StatusTag on line rows + gate footer Approve — see Tag Selection |
| Field Provenance | Who owns the cell (system / formula / agent / human) — chrome, not free edit | Mark derived cells; keep editable seats explicit |
| Validation Triad | Block (hard stop) / Confirm→human / Pass | Message Popover + Confirm before submit |
Host returns line statuses + check outcomes; UI renders StatusTag + gates Submit / Approve. Do not invent a BPM or OCR component — compose Table + ModalForm + FooterToolbar.
Submit Seat vs Approve Seat (Hard Rule)
Many products split submit from approve. Industry (purchase portals, partner claim portals, expense apps) puts creation / submit on the requester (or partner) surface; approver seats work an inbox → instance review path and never need a permanent Create nav item.
| Seat | Create / submit | Inbox / instance review | Reports |
|---|---|---|---|
| Requester / partner | ✅ Primary | My requests | ❌ |
| Approver | ❌ Do not show Create in nav | ✅ | Optional |
| Auditor / CC | ❌ | Read / CC slices only | Optional |
Do not put Create / Apply in the approver sidebar “for demo convenience.” That reads as redundant chrome and trains the wrong job model. Optional: an explicit Act as requester control for demos — not a permanent Create nav item on approver seats.
Deep links to a create route may still exist for demos; nav IA must not advertise Create to approvers.
How To Answer “Different Roles See Different Screens”
- Open /showcases/approvals → switch My pending / Submitted by me / CC to me. Note
PO-2026-0900 (Restricted)is absent from CC — L0 denial. - Open /showcases/approvals-detail → View as Requester → Finance → Compliance → VP → CC. Same
PO-2026-0841; Budget / Risk cards, field sets, and footer actions change — L2–L4. - For line-level packs: keep the same Object Page Hub; render line StatusTag + gate whole-order Approve on line policy (compose from Table + Tag Selection).
Approval Patterns
| Pattern | Meaning | UI signal |
|---|---|---|
| Sequential | Strict order | Stepper advances one stage at a time |
| Parallel / 会签 | All assignees must approve | Assignee table shows per-person votes |
| Or-sign / 或签 | Any one assignee completes the node | LabelTag on inbox + assignee votes |
| Conditional | Path by amount / risk / dept | Host engine; UI shows resulting path |
| Approval matrix | Role / authority, not named people | Display role column on assignees |
Chinese OA runtime ops (转办 / 委派 / 加签 / 抄送 / 驳回) are host actions. Finance View as exposes Approve / Reject + More stubs; Requester gets Urge / Withdraw.
Case Patterns
Borrow Pega / ServiceNow case anatomy:
| Region | Contents | f-ui stack |
|---|---|---|
| Summary | Case id, status, customer, severity | PageContainer header + Descriptions + StatusTag |
| Work | Lifecycle + current work | Vertical Stepper + assignment forms / Related Lists |
| Utilities | Attachments, stakeholders, related | Related List cards (same as orders hub) |
Typical lifecycle: Intake → Triage → Investigate → Resolve → Close. Apply the same L0–L4 formula when agents vs investigators vs managers need different case chrome (complaint View as is a follow-up).
Page Recipes
| Job | Stack | Live showcase |
|---|---|---|
| Approval inbox (L0 slices) | PageContainer tabs + QueryList + KPIs | /showcases/approvals |
| Approval instance (L2–L4 personas) | Hub + Stepper + policy-driven cards / fields / footer | /showcases/approvals-detail |
| Complaint / feedback queue | QueryList + severity / SLA columns | /showcases/complaints |
| Complaint case hub | Hub tabs + lifecycle Stepper + dept tasks + activity | /showcases/complaints-detail |
| Line-item approval on an Object Page | Hub + line StatusTag + FooterToolbar gate | Compose from orders detail + Tag Selection |
When To Copy Which Showcase
| Scenario | Copy |
|---|---|
| Multi-department purchase / expense approval | /showcases/approvals → /showcases/approvals-detail |
| “Different roles see different UI / some can’t open the page” | Approvals inbox tabs (L0) + detail View as (L2–L4) |
| Need countersign (会签) UI | Approvals detail — assignee votes + strategy LabelTag |
| Complaint / CS case with dept investigation | /showcases/complaints → /showcases/complaints-detail |
| Line-level approve + provenance + validation triad | Approvals detail + Tag Selection + Message Popover |
| Only need stage chrome on an existing hub | Compose Stepper into your Object Page Hub (orders detail) |
| Multi-step create wizard | Steps Form — not the approval Stepper |
Composition Checklist
- L0 Inbox — tabs or queues that change which rows exist; never only a god-list.
- Policy bag first — regions / fields / actions from host ACL, not ad-hoc name checks.
- Detail header — identity, status tags, policy-filtered
Descriptions, amount / SLA inextraContent. - Stages — presentational
Stepper(wait/process/finish/error). - L2 work cards — mount only for the persona/node that owns that work (Budget, Risk, …).
- L3 fields — edit / read / hide per 宜搭 vocabulary; omit hidden keys from Descriptions.
- L4 actions —
PageFooter+FooterToolbaronly when policy allows; dialogue Activity on Review / Returned uses Comment Thread (Shared / Internal), not a one-off ModalForm textarea. Read-only audit on an Activity tab uses Timeline (newest first, no composer). Copy the Shared And Internal Visibility demo: filter Internal on the host before the kit; return / decline reasons stay Shared withrequiredcomposer. - Async — every region that loads data must host-branch Loading / Empty / Error (Page And Region Status).
- Region tables — title + Refresh / Columns / Add on one row.
- Line Policy — when lines can pass/fail independently, gate whole-order Approve on line statuses. Field-scoped return uses correction fields — see Comment Thread.
- Row Actions — omit the Actions column when the seat has zero line ops (Row Actions Column).
- Submit ≠ approve — Create / submit nav only for requester seats (Submit Seat vs Approve Seat).
- Provenance chrome — mark system-derived / computed / requester-editable cells; never paint spreadsheet ownership colors as UI.
- Validation Triad — Block disables submit; Confirm routes to human review; Pass is silent success — never collapse Block into Empty.
Anti-Patterns
- Treating f-ui as the workflow engine or as the ACL.
- One god-view approval detail for all roles (buyers will reject capability).
- Using
Steps Formfor approval progress (that is wizard navigation + validate). - Blank panels while inbox / tasks / log load.
- Stacking Refresh / Columns vertically above a Related List.
- ALL CAPS section titles on the hub.
- Header-only Approve when the product requires per-line status.
- Shipping OCR / document-verify / PDF generation as kit components — host services; UI shows triad + document card.
- CSS-hiding Internal Activity rows instead of filtering them out of the message list.
Related
- Comment Thread — Detail dialogue Activity (Shared / Internal)
- Timeline — read-only audit tab (vertical rail)
- CRUD Page Patterns — Object Page Hub baseline
- Stepper — stage chrome
- Steps Form — intake wizards
- Page And Region Status — Loading / Empty / Error
- Access — field-level AccessProvider when wiring real policies