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 separateCurrentLevelfield - 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:
ApprovalServicere-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, notAPPROVAL_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.