Lightweight Multi-Level Approval

For fixed two or three level approval flows (applicant → department → finance), a state machine + approval record table is enough — you do not need a workflow engine.

Design

Concern Approach
Where the state lives On the business entity itself (ApprovalStatus + CurrentLevel), no instance table
Where the history lives One sys_approval_record table, which doubles as the to-do index and the notification target
Who can approve Decided by flow config: org leader / role / specific user / bill field
Concurrency Conditional update on status + level, so double clicks and simultaneous approvals succeed only once
The state machine A pure function in code (ApprovalStateMachine), never persisted, fully unit-testable

Not supported: countersign, parallel branches, conditional routing, jumping back to an arbitrary node. Use a workflow engine (e.g. Elsa) for those.

There is no limit on the number of levels: the flow runs as many nodes as you configure (1 to N, typically 2–3). Once a flow needs conditional routing instead of plain serial levels, that is workflow-engine territory.

States and transition rules

State machine

Draft ──Submit──▶ Level 1 pending ──Approve──▶ Level 2 pending ──Approve──▶ Approved
                        │                             │
                        ├──Reject─────────────────────┴──▶ Rejected ──edit & submit──▶ Level 1 pending (new round)
                        └──Revoke (before anyone approves)─▶ Revoked ──submit───────▶ Level 1 pending (new round)

Pending ──Transfer──▶ same level, only the approver changes
  • Only 5 states: Draft / Pending / Approved / Rejected / Revoked; the level lives in a separate CurrentLevel field
  • Only 5 actions: Submit / Approve / Reject / Revoke / Transfer; the rules are a pure function (ApprovalStateMachine), never persisted, and fully unit-testable
  • Concurrency: every action uses a conditional update on status + level, so a double click or two simultaneous approvals succeed only once; the loser gets "the bill has been handled by someone else, please refresh"

Can the bill still be edited?

State Save changes Delete Notes
Draft Yes, and it stays a draft (no auto-submit by default) Yes Click "Submit for Approval" in the approval tab to enter the flow; see the switch below to restore auto-submit
Pending No — "the bill is under approval and cannot be modified or deleted" No Exception: the current node has "allowEdit": true and the editor is that node's approver
Approved No by default (locked once approved); enable AllowEditAfterApproved to edit, then the re-approval policy applies Yes Default policy is "locked once approved"
Rejected / Revoked Yes, and it is submitted again from level 1 Yes The normal "fix and resubmit" path

Re-approval policy

.AddEasyAdminBlazorApproval(o =>
{
    o.AllowEditAfterApproved = false;   // global default: locked once approved

    o.Flows.Add(new ApprovalFlowConfig
    {
        BillType = typeof(Article).FullName!,
        AllowEditAfterApproved = true,                                      // this bill type stays editable
        ResubmitOnEdit = true,                                             // ...and re-enters approval when edited
        ResubmitFields = [nameof(Article.Title), nameof(Article.Content)],  // but only these count as key fields
        Levels = [ /* see the flow config above */ ]
    });
});

Three settings, each settable globally and overridable per bill type (flow config wins):

Setting Scope Default Meaning
AllowEditAfterApproved global / flow false Whether an approved bill may still be edited; false = locked
ResubmitOnEdit global / flow true If editing is allowed, whether an edit re-enters approval
ResubmitFields flow only empty Key fields that trigger re-approval; empty means any change does

The four common policies map like this:

Policy Configuration
Locked once approved (finance, contracts, inventory) AllowEditAfterApproved = false (default)
Re-approve only when key fields changed AllowEditAfterApproved = true + ResubmitFields = [key fields]
Always re-approve on any edit AllowEditAfterApproved = true, ResubmitFields empty
Edits take effect immediately AllowEditAfterApproved = true + ResubmitOnEdit = false

How key fields are compared: on submit, the values of the configured fields are sorted by name, concatenated and hashed with SHA256; the hash is stored on that round's start record (sys_approval_record.ResubmitHash). On the next save the hash is recomputed and compared — if nothing changed, approval is skipped. Only the hash is stored, never the values themselves. After changing ResubmitFields, the old hash no longer matches, so the next save is treated as a change (the safe direction).

Re-approval by amount/risk tier (small amounts skip approval, large ones go through the full flow) is flow routing and out of scope for this extension.

Rounds

Resubmitting after a rejection/revoke, or editing an approved bill, starts a new round:

  • The timeline is ordered by round then level; nodes in round 2+ are prefixed with "Round 2 ·"
  • Records of earlier rounds are kept in full (audit requirement)

Separating save from submit

Saving and submitting are separate by default: saving only saves (the bill stays a draft); it enters the flow when the user clicks Submit for Approval in the business page's approval tab. This matches the usual OA workflow (fill in → review → submit).

To make saving submit automatically:

.AddEasyAdminBlazorApproval(o =>
{
    o.AutoSubmitOnSave = true;                 // global default: submit right after saving

    o.Flows.Add(new ApprovalFlowConfig
    {
        BillType = typeof(Article).FullName!,
        AutoSubmitOnSave = true,               // or enable it for one bill type only (flow config wins)
        Levels = [ /* ... */ ]
    });
});

With auto-submit off, the user triggers the submission; IApprovalService.CanSubmitAsync(billType, status) tells the UI whether that state can be submitted (used to show/hide the button), and SubmitAsync re-validates on the server.

1. Install

dotnet add package EasyAdminBlazor.Approval
builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
       .AddEasyAdminBlazorApproval(o =>
       {
           o.Flows.Add(new ApprovalFlowConfig
           {
               BillType = typeof(Article).FullName!,
               Levels =
               [
                   new ApprovalLevelConfig { Level = 1, Name = "Department manager", Kind = ApproverKind.OrgLeader, Offset = 1 },
                   new ApprovalLevelConfig { Level = 2, Name = "Finance review", Kind = ApproverKind.Role, Value = "Finance" }
               ]
           });
       });

The sys_approval_record table is created automatically.

2. Put a business entity under approval

public partial class Article : ApprovalEntityFull   // with soft delete
{
    // existing members unchanged
}

public partial class Order : ApprovalEntity { }     // without soft delete

If the entity already inherits another base class, implement IApprovalBill and declare the two fields yourself:

public class MyBill : EntityCreated, IApprovalBill
{
    [Column(Position = -8)] public ApprovalStatus ApprovalStatus { get; set; }
    [Column(Position = -7)] public int CurrentLevel { get; set; }
}

The interface is the contract used by the components and the engine; the fields still have to be declared by the entity or base class, because interface members do not become database columns.

Submitter and submit time reuse EntityCreated.CreatedUserId / CreatedTime.

3. Wire up a page

Option 1: let AdminTable take over (recommended). Any entity implementing IApprovalBill will:

  • submit for approval right after a successful save (bill types without flow config are unaffected)
  • be blocked from edit/delete while pending
  • get an approval status column in the grid (ApprovalStatusText)

Option 2: use the panel component.

@using EasyAdminBlazor.Approval.Components

<ApprovalActions TItem="Article" Bill="Model" OnChanged="Reload" />

It shows the approve / reject / revoke buttons based on the current state and the signed-in user, plus the full approval timeline.

Option 3: call the service. Inject IApprovalService and use SubmitAsync / ApproveAsync / RejectAsync / RevokeAsync / TransferAsync. All return ApprovalSubmitResult; when Submitted is false, Message tells you why.

4. Approver sources

Kind Description Value / Field
OrgLeader Org leader, walking up from the submitter's org via SysOrg.ResponsibleUserId Offset: 1 = own org, 2 = parent org
Role All enabled users of a role (any one of them can approve) Value = role name
User A specific user Value = user id
FormField Value of a bill property Field = property name

Resolved users must exist and be enabled, otherwise submission fails with "no approver found for ..." instead of creating an orphan to-do.

Approvers are resolved once at submit time, so use transfer instead of editing the config when someone is unavailable.

5. Approval Center

The framework ships a top-level menu Approval Center (/Admin/ApprovalTodo, page lives in the core project at EasyAdminBlazor/Pages/ApprovalTodo.razor). Install the extension and grant the menu — no page to write yourself.

One menu, four views (no extra sub-menus):

View Content
My To-do IsCurrent, Pending, approver = me; disappears as soon as you approve or reject
Handled Nodes I actually acted on (approved / rejected / transferred) — what did I sign, and what did I write
My Requests Every round I submitted — "where is my bill stuck". One row per round: the node column shows the round's current pending node (in approval) or the last handled node (finished, e.g. "Revoke"), the status column shows the round result (pending / approved / rejected / revoked), and the approver + time belong to that node
All Every approval record — administrators only

Every view supports keyword search on the bill title, filtering by node result (pending / approved / rejected / transferred / skipped …) and paging.

Clicking a row shows the bill content (rendered automatically from the entity's fields and DisplayName, long text truncated), the current approval status and the full timeline (grouped by round) on the right. In My To-do you can type a comment and approve/reject directly, and the list refreshes afterwards; the other views are read-only.

In My Requests, selecting a bill you submitted that nobody has approved yet shows a Revoke button (revoke, fix it, and submit again as a new round).

The menu is provisioned automatically, including in existing projects. A brand-new database gets it from the framework seed; for existing projects (whose menu table is not empty, so the seed does nothing) the extension creates it at startup — no manual menu setup. Disable with ApprovalOptions.AutoCreateMenu = false. Earlier versions used "Approval Center (group) + My To-do (child)". They are now merged into a single top-level menu; the upgrade runs automatically at startup and keeps the original menu id, so role grants are preserved. An auto-created menu is visible to administrators only until you grant it to other roles in role management.

The to-do data itself is a single-table query and can be used from your own pages:

var todos = await approvalService.GetTodoListAsync();   // my to-do (entities)
var count = await approvalService.GetTodoCountAsync();

await approvalService.ApproveByBillTypeAsync(record.BillType, record.BillId, "ok");
await approvalService.RejectByBillTypeAsync(record.BillType, record.BillId, "missing documents");

Button permissions

Approve / Reject / Revoke in the Approval Center use the framework's button-level permissions (SysMenu button paths: approve / reject / revoke). The menu seed already contains these three buttons — tick them when granting the Approval Center menu to a role; administrators always pass.

  • UI: the button is hidden when the user lacks the permission
  • Service: ApprovalService re-checks the same permission, so calling the API directly is rejected too ("no permission to perform this operation")
  • The <ApprovalActions> panel inside business pages obeys the same rules

The approval tab inside the business edit dialog serves a different case: when the current node sets allowEdit, the approver has to edit the bill first and then approve it.

6. Options

Option Default Meaning
EnableNotification true Send an internal message (plus SignalR push) on submit and level change
AllowEditAfterApproved false Global default for whether an approved bill may still be edited; the flow config can override it (see "Re-approval policy")
AutoSubmitOnSave false Global default for auto-submitting after save; false keeps saving and submitting separate
ResubmitOnEdit true Global default for whether an edit re-enters approval (only relevant when editing is allowed)
RequireCommentOnReject true A comment is mandatory when rejecting
RequireCommentOnApprove false A comment is mandatory when approving
AllowRevoke true The submitter can revoke while nobody has approved yet

7. Configuring the flow from the admin UI

No code required — add one config item under System Management → Configuration:

Field Value
Name Anything, e.g. "Article approval flow" (for identification only)
Unique Code APPROVAL_FLOW_Article
Value the JSON below
{
  "enabled": true,
  "allowEditAfterApproved": true,
  "resubmitOnEdit": true,
  "resubmitFields": [ "Title", "Content" ],
  "levels": [
    { "level": 1, "name": "Department manager", "kind": "OrgLeader", "offset": 1 },
    { "level": 2, "name": "Finance review", "kind": "Role", "value": "Finance" },
    { "level": 3, "name": "General manager", "kind": "User", "value": "838392596680773" }
  ]
}
Field Meaning
enabled Whether approval is enabled for this bill type
allowEditAfterApproved Whether an approved bill may still be edited; omitted = global default (false = locked)
autoSubmitOnSave Whether saving submits for approval automatically; omitted = global default (false = save and submit are separate)
resubmitOnEdit If editing is allowed, whether an edit re-enters approval; omitted = global default (true)
resubmitFields Key fields that trigger re-approval; omitted or empty = any change triggers it
level Level number starting at 1; omit it and the array order is used
name Node name, shown in the to-do list and the approval timeline
kind OrgLeader / Role / User / FormField
value role name for Role, user id for User
offset OrgLeader only: 1 = own org leader, 2 = parent org leader
field FormField only: which property supplies the approver
allowEdit whether this node's approver may also edit the bill (default false)

Use the short type name in the code (APPROVAL_FLOW_Article, not APPROVAL_FLOW_EasyAdminBlazor.Test.Blog.Article). The unique-code column is only 30 characters, and a full type name overflows it (MySQL truncates, and the config is then never found). Lookup order is: full name first, then short name.

Changes take effect immediately, no restart needed. Config in the database takes precedence over the code default.

Localization

Everything the approval extension renders — the to-do page, the approval panel, status texts and all service messages — goes through the localization resource. Chinese and English are built in, under the EasyAdminBlazor.Common block of EasyAdminBlazor/Locales/zh.json and en.json. To add another culture, translate that block (see Localization).

Property names of your own entities (for example the "approval status" column) still live in your project's Locales/{culture}.json, keyed by entity full name → property name, as usual.

8. Troubleshooting

"No approver found for «level»" — the level resolved to nobody: check that the org has a responsible user, the role has enabled users, or the configured user still exists.

The bill cannot be edited anymore — bills under approval are read-only by default. Revoke/reject it first, or set AllowEdit = true on the current node.

A module should not use approval — simply do not configure a flow for that bill type, or set ApprovalFlowConfig.Enabled = false.

The approver left the company — the current approver or an administrator can transfer the to-do to somebody else.

I can see the timeline but not the buttons — the signed-in user is neither the approver of the current node nor an administrator.