f-ui
Design

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

In progress

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

Request idTitleRequesterAmount
  • 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

In progress

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.

FieldRequesterApproverWatcher
JustificationEditReadHide
Cost centerHideReadHide
Unit pricesReadReadHide
AmountReadReadRead
VendorReadReadRead

View as

PO-2026-0841

In progress

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

HighTriage

Damaged shipment on order ORD-8891 — customer requests replacement.

Summary

Case id:
CS-2026-4412
Customer:
Harbor Retail Co.
Channel:
Email
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)
LayerIndustry goldf-ui compositionLive showcase
L0飞书 待审批 / 已办 / 抄送我 / 已发起PageContainer tabs + filtered QueryList/showcases/approvals
L1ServiceNow Audience page variants; Pega Portal × PersonaUsually one Object Page Hub; split portals only when IA differsDocumented — not a separate route wave
L2SF Work Guide hide-when-empty; dept work cardsConditional DetailCard / tabs/showcases/approvals-detail View as
L3钉钉宜搭 / 飞书 / 芋道:编辑 / 只读 / 隐藏Subset Descriptions fields; hide line price columnsSame · cost center / unit prices
L4飞书操作权限;仅当前办理人Conditional FooterToolbarSame · 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
ExtensionMeaningLive showcase
Line PolicyPer-line status; whole-order Approve only when every line is approvedCompose StatusTag on line rows + gate footer Approve — see Tag Selection
Field ProvenanceWho owns the cell (system / formula / agent / human) — chrome, not free editMark derived cells; keep editable seats explicit
Validation TriadBlock (hard stop) / Confirm→human / PassMessage 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.

SeatCreate / submitInbox / instance reviewReports
Requester / partner✅ PrimaryMy requests
Approver❌ Do not show Create in navOptional
Auditor / CCRead / CC slices onlyOptional

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”

  1. Open /showcases/approvals → switch My pending / Submitted by me / CC to me. Note PO-2026-0900 (Restricted) is absent from CC — L0 denial.
  2. Open /showcases/approvals-detailView as Requester → Finance → Compliance → VP → CC. Same PO-2026-0841; Budget / Risk cards, field sets, and footer actions change — L2–L4.
  3. 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

PatternMeaningUI signal
SequentialStrict orderStepper advances one stage at a time
Parallel / 会签All assignees must approveAssignee table shows per-person votes
Or-sign / 或签Any one assignee completes the nodeLabelTag on inbox + assignee votes
ConditionalPath by amount / risk / deptHost engine; UI shows resulting path
Approval matrixRole / authority, not named peopleDisplay 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:

RegionContentsf-ui stack
SummaryCase id, status, customer, severityPageContainer header + Descriptions + StatusTag
WorkLifecycle + current workVertical Stepper + assignment forms / Related Lists
UtilitiesAttachments, stakeholders, relatedRelated 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

JobStackLive 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 queueQueryList + severity / SLA columns/showcases/complaints
Complaint case hubHub tabs + lifecycle Stepper + dept tasks + activity/showcases/complaints-detail
Line-item approval on an Object PageHub + line StatusTag + FooterToolbar gateCompose from orders detail + Tag Selection

When To Copy Which Showcase

ScenarioCopy
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 (会签) UIApprovals detail — assignee votes + strategy LabelTag
Complaint / CS case with dept investigation/showcases/complaints/showcases/complaints-detail
Line-level approve + provenance + validation triadApprovals detail + Tag Selection + Message Popover
Only need stage chrome on an existing hubCompose Stepper into your Object Page Hub (orders detail)
Multi-step create wizardSteps Form — not the approval Stepper

Composition Checklist

  1. L0 Inbox — tabs or queues that change which rows exist; never only a god-list.
  2. Policy bag first — regions / fields / actions from host ACL, not ad-hoc name checks.
  3. Detail header — identity, status tags, policy-filtered Descriptions, amount / SLA in extraContent.
  4. Stages — presentational Stepper (wait / process / finish / error).
  5. L2 work cards — mount only for the persona/node that owns that work (Budget, Risk, …).
  6. L3 fields — edit / read / hide per 宜搭 vocabulary; omit hidden keys from Descriptions.
  7. L4 actionsPageFooter + FooterToolbar only 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 with required composer.
  8. Async — every region that loads data must host-branch Loading / Empty / Error (Page And Region Status).
  9. Region tables — title + Refresh / Columns / Add on one row.
  10. Line Policy — when lines can pass/fail independently, gate whole-order Approve on line statuses. Field-scoped return uses correction fields — see Comment Thread.
  11. Row Actions — omit the Actions column when the seat has zero line ops (Row Actions Column).
  12. Submit ≠ approve — Create / submit nav only for requester seats (Submit Seat vs Approve Seat).
  13. Provenance chrome — mark system-derived / computed / requester-editable cells; never paint spreadsheet ownership colors as UI.
  14. 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 Form for 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.

On this page