Skip to content

UX-001 — confirmation coverage

Every state-changing action in the staff screens asks the user to confirm before it runs (rule UX-001, DECIDED 2026-09-17), through useConfirm() from src/components/common/ConfirmProvider.tsx:

Level Used for What the user sees
simple reversible changes (master data, assignments, sync, messages) title + summary, Yes / No
reason money, access and status changes summary of what changes (amounts, Dr/Cr lines, record) + a required reason
typed deletes, voids, reversals, role/permission grants, bulk approvals summary + reason + type the record number / name / count

The reason is passed to the API call when the route accepts reason / note / notes, and otherwise added to the request body as reason so it reaches the audit context (AUD-001). Reason column: param = passed as an argument to the service/API call, body = added to the request body, — = level simple, no reason collected.

Buttons are wrapped in <PermissionGate permission="…"> with the permission the API route enforces (docs/PERMISSIONS.md §6) — never a role name (ACC-001/012).

Out of scope on this branch: the customer and partner portals (Wave 3 — their actions already confirm through the portal dialogs), src/pages/Dashboard.tsx / Home.tsx / Index.tsx and the Customer 360 profile page (other Wave 2 workstreams).

Generated from the Wave 2 sweep; keep it up to date when you add an action.

Finance workspace (/finance)

Screen Action Level Permission gate Reason sent Notes
Finance › Settings Save finance system controls (prefixes, default ledgers) reason finance.edit (added) body Converted (had no confirm). API allowlists fields, so reason is ignored by the update but is in the request for audit
Finance › Transactions Verify payment reason finance.payments.verify (existing) body Already compliant; Dr/Cr in details
Finance › Transactions Reject payment simple finance.payments.reject (existing) body Already compliant: the reject dialog requires a reason (sent as reason) and confirms once when submitted
Finance › Transactions Request refund reason finance.payments.refund (existing) body Already compliant
Finance › Approvals Approve / reject refund request reason finance.refunds.approve (existing) body Already compliant
Finance › Settings Manual ledger entry reason finance.ledger.rebuild (existing) body (notes) Already compliant
Finance › Settings Rebuild all ledger entries reason finance.ledger.rebuild (existing) body Reason now sent (it was collected but not sent before)
Finance › Settings / Transactions Rebuild airline block ledger (two buttons) reason finance.ledger.rebuild (added on Transactions toolbar) body Reason now sent; Transactions-tab button had no gate
Finance › Transactions Record payment reason finance.create (existing on opener; added on "Pay" quick buttons) body (notes) Already compliant
Finance › Transactions Create installment schedule reason finance.create (added) body Converted (had no confirm)
Finance › Vouchers Post receipt voucher (booking/invoice/settlement receipts, customer/agent on-account, supplier refund receipt) reason finance.create (added) body (notes for /finance/payments, reason otherwise) Moved from the local AlertDialog to useConfirm; Dr/Cr lines, amount and FX detail in details
Finance › Vouchers Post supplier payment voucher reason finance.create (added) — Moved from the local AlertDialog; reason collected but not sent: createSupplierTransaction is shared (Suppliers, QuickVoucherDialog) and its payload literal has a baseline type error
Finance › Vouchers Post contra voucher reason finance.create (added) body Moved from the local AlertDialog
Finance › Vouchers Post debit / credit note reason finance.create (added) body Moved from the local AlertDialog
Finance › Vouchers Post any-to-any settlement reason finance.create (added) body Moved from the local AlertDialog
Finance › Vouchers Open journal composer (voucher mode "journal") — finance.create (added) — No longer asks for confirmation just to open the composer; the confirmation happens when the journal is posted
Finance › Vouchers Allocate supplier advance reason suppliers.edit (added) body (allocate route) / — (full-amount PATCH via shared updateSupplierTransaction) Moved from the local AlertDialog
Finance › Vouchers Allocate customer on-account receipt reason finance.edit (added) body (notes) Moved from the local AlertDialog
Finance › Settings Create accounting period simple finance.edit (added) — Converted (had no confirm)
Finance › Settings Post stock adjustment (opening/closing) reason finance.edit (added) body Converted (had no confirm). The API handler has no requirePermission (see Not converted)
Finance › Settings Run FX revaluation reason finance.periods.close (existing) body Converted (had no confirm); spot rates in details
Finance › Settings Lock period reason finance.periods.lock (added) body Already confirmed; gate added
Finance › Settings Unlock period reason finance.periods.unlock (added) body Already confirmed; gate added
Finance › Settings Close period reason finance.periods.close (added) body Already confirmed; gate added
Finance › Journal Post manual journal voucher reason finance.create (added on "New Journal Entry") body Converted (had no confirm); Dr/Cr lines in details
Finance › Journal Reverse journal typed (voucher no) finance.journals.reverse (added) body Moved from the local AlertDialog, and the level raised to typed; reversal Dr/Cr in details
Finance › Approvals Finance approve booking reason approvals.approve (added) body Moved from the local AlertDialog; the API reads reason as notes
Finance › Approvals Finance reject booking reason approvals.approve (added) body Moved from the local AlertDialog
Finance › Approvals Send booking back to sales simple approvals.approve (added) body Converted (had no confirm); the required correction note in the form is the reason
Finance › Approvals Approve / reject voucher (journal) reason finance.journals.approve / finance.journals.reject (existing) body Already compliant
Finance › Approvals Approve all pending vouchers typed (count) finance.journals.bulk_approve (existing) body Level raised from reason to typed ("Type N to confirm")
Finance › Approvals / Cancellations Approve B2B / airline cancellation simple hasPermission(finance.cancellations.approve_b2b / approve_airline) (existing) body (approvedRemarks) Already compliant: the form requires remarks and they are sent
Finance › Approvals / Cancellations Reject B2B / airline cancellation simple same as above (existing) body (rejectionReason) Already compliant: the form requires a reason
Finance › Settings Create financial year simple finance.edit (added) — Converted (had no confirm)
Finance › Settings Close financial year typed (year name) finance.years.close (existing) body Already compliant
Finance › Settings Refresh FX rates from the live feed simple finance.edit (added) — Converted (had no confirm)
Finance › Settings Save manual FX rate simple finance.edit (added) — Converted (had no confirm); writes directly to exchange_rates with Supabase, so there is no request body for a reason
Finance › TDS Update challan / status reason finance.tds.deduct (existing) body Converted (had no confirm)
Finance › TDS Upload Form 16A certificate simple finance.tds.deduct (existing) — Converted (had no confirm)
Finance › Journal composer Save journal template simple finance.create (existing) — Converted (had no confirm)
Finance › Journal composer Delete journal template typed (template name) finance.edit (existing) body Replaced window.confirm
Finance › Cancellations tab Cancellation status pill — — — Hard-coded amber/emerald/red badges replaced with <StatusBadge> (pending / approved / rejected)
Not converted / notes:
- The local single-level confirmDialog AlertDialog (state, requestConfirm and markup) and the unused AlertDialog import were removed. All 5 places that used it now use askConfirm, and a module-level VoucherLinesPreview shows the same Dr/Cr, amount and FX table.
- Supplier payment voucher: the reason is collected but not sent. createSupplierTransaction in src/services/supplierService.ts is shared with Suppliers and QuickVoucherDialog, so its signature was not changed. Adding reason to the payload literal would also change the text of an existing baseline TS2345 error on that call.
- Supplier advance allocation for the full amount goes through the shared updateSupplierTransaction (PATCH), so the reason is not sent on that path. The partial-allocation route does send it.
- POST /finance/stock-adjustment (handleFinanceStockAdjustment in src/lib/api.ts) has no requirePermission, and §6.8 says it needs finance.edit. The UI is now gated, but the handler needs a fix in the shared file.
- POST /finance/vouchers/customer-on-account-receipts/:id/allocate requires finance.edit, but §6.8 lists "Allocate agent receipt" under finance.payments.record. The gate follows the API. The matrix row may need to be made clearer.
- Accounts CRUD (ChartOfAccounts), bank reconciliation and the voucher-detail dialog live in separate components, not in Finance.tsx, so they are out of scope for this group.
- The accounting-period status badge (open/locked/closed) is left as a variant-only Badge: it has no ad-hoc colours, and locked has no token in statusTones.ts.
- The Settlement "settled/partial/unsettled" badge was left alone: those are not lifecycle statuses and have no tokens.

Finance components — accounts, vouchers, reconciliation, ledgers

Screen Action Level Permission gate Reason sent Notes
BankReconciliationPanel Import parsed bank statement reason finance.edit body Existing gate; shows rows + opening→closing in details
BankReconciliationPanel Auto-match statement lines reason finance.edit body Existing gate
BankReconciliationPanel Manual match line → journal reason finance.edit body Confirm names line #, date, amount, voucher
BankReconciliationPanel Ignore statement line reason finance.edit body
BankReconciliationPanel Undo match / restore line reason finance.edit body Single handler, wording switches on status
BankReconciliationPanel Delete statement import typed finance.edit body Replaced window.confirm; typedValue = filename (fallback id)
VoucherDetailDialog Upload voucher attachment simple finance.edit — Existing gate; confirm after file pick
VoucherDetailDialog Remove voucher attachment typed finance.edit body typedValue = file name
VoucherDetailDialog Reverse voucher typed finance.journals.reverse param (reason) Replaced local AlertDialog + free-text reason; Dr/Cr lines in details, typedValue = voucher no
VoucherDetailDialog Allocate on-account receipt reason finance.payments.record (gate added) body Confirm lives in AllocateReceiptDialog (no double-confirm)
AllocateReceiptDialog Apply receipt allocation reason finance.payments.record (gate added) body Per-booking amounts in details
RecordPaymentDialog Record booking payment reason finance.payments.record, fallback finance.create (gate added) param (notes) Already compliant at reason; gate mirrors the route's either-of check
QuickVoucherDialog Post contra transfer reason finance.create body Dr/Cr summary in details
QuickVoucherDialog Record expense reason finance.create body
QuickVoucherDialog Post supplier payment reason suppliers.edit — createSupplierTransaction is shared with Suppliers/Finance — signature left alone
QuickVoucherDialog Post customer on-account receipt reason finance.edit body
QuickVoucherDialog Post supplier refund receipt reason finance.create body
QuickVoucherDialog Post agent on-account receipt reason finance.create body
QuickVoucherDialog Post any-to-any settlement reason finance.create body
ChartOfAccounts Create GL group / subgroup simple finance.edit —
ChartOfAccounts Create GL leaf account simple, reason when an opening balance is entered finance.edit body
ChartOfAccounts Edit GL account (Save) simple, reason when opening balance or active flag changes finance.edit body Reason also sent on the opening-balance POST
ChartOfAccounts Activate / deactivate GL account reason finance.edit body
ChartOfAccounts Delete GL account / group typed accounts.delete body Replaced window.confirm; typedValue = account code
ChartOfAccounts Create payment account simple, reason with opening balance finance.edit body (service reason)
ChartOfAccounts Edit payment account simple, reason when opening balance changes finance.edit body (service reason)
ChartOfAccounts Toggle payment account active (Switch) reason finance.edit body (service reason)
ChartOfAccounts Hard-delete payment account typed finance.edit body Replaced window.confirm; typedValue = account name
ChartOfAccounts Record / update payment-account transaction reason finance.create (new) / finance.edit (update) body (service reason)
ChartOfAccounts Delete payment-account transaction typed finance.edit body typedValue = amount
ChartOfAccounts Allocate agent receipt from ledger row reason finance.payments.record (gate added) body Confirmed once in AllocateReceiptDialog
ChartOfAccounts Voucher status pill — — — (see VoucherDetailDialog)
Accounts (Bank & Cash) Create account simple, reason with opening balance finance.edit body (service reason)
Accounts (Bank & Cash) Edit account simple, reason when opening balance changes finance.edit body (service reason)
Accounts (Bank & Cash) Activate / deactivate account reason finance.edit body (service reason)
Accounts (Bank & Cash) Hard-delete account typed finance.edit body Had no confirmation at all before
Accounts (Bank & Cash) Add / update manual transaction reason finance.create (add) / finance.edit (update) body (service reason)
Accounts (Bank & Cash) Delete manual transaction typed finance.edit body typedValue = amount
VoucherDetailDialog Voucher status display — — — Ad-hoc amber/emerald/destructive text replaced with <StatusBadge domain="journal">
Not converted / notes:
- FinancePinGate (/finance/pin/set, /verify, /request-reset, /reset) — PIN entry is authentication, not a state change (per brief).
- FinanceIntelligencePanel, FinanceCopilotChat, AirlineCancellationSeatsTable, LedgerViewer — read-only; the copilot only asks a question, no writes.
- PartyLedgerDialog — only Export to Excel / Print (no state change). §6.9 lists reports.export for statement printing; gating exports was out of scope for this sweep — flagging it rather than adding a gate.
- PartyLedgerQuickActions — buttons only open QuickVoucherDialog; confirmation happens once on its submit. Existing finance.create gates left in place (the payment mode's own submit is gated on suppliers.edit).
- Supplier payment reason not forwarded — createSupplierTransaction in src/services/supplierService.ts is also used by src/pages/suppliers/Suppliers.tsx and src/pages/finance/Finance.tsx (another worker's file), so its signature was left untouched; the reason is collected but not sent. Needs a follow-up in the shared service.
- Bank reconciliation status pills (import imported/partial/reconciled, line matched/ignored/unmatched) — no matching domain in src/lib/statusTones.ts; left as-is rather than editing the shared tones file.
- src/pages/finance/Finance.tsx and src/components/invoices/** — owned by other workers.

Groups, group pricing and invoices, capacity

Screen Action Level Permission gate Reason sent Notes
Groups (list) Create group simple groups.create — Confirm on the create dialog's submit
Groups (list) Create from template (instantiate) simple groups.create — Gate on "From Template" trigger
Groups (list) Delete group (card) typed groups.delete body typedValue = group code; gate added inside GroupCard
Groups (list) Group status pill — — — <StatusBadge domain="group"> replaces getGroupStatusClass / STATUS_STYLES
Group detail Delete group (footer) typed groups.delete body Double window.confirm removed; navigates only when the delete succeeded
Group detail Edit group — dates / capacity / itinerary simple groups.edit — Confirm on dialog Save
Group detail Edit group — status override set/clear reason groups.edit body Level switches to reason when statusOverride changes
Group detail Edit group — service charge per traveller reason groups.edit body Level switches to reason when the charge or its label changes; old → new amount in the description (FIN-039)
Group detail Clone group simple groups.create — Confirm on the clone dialog's submit
Group detail Save as template simple groups.create —
Group detail Delete template typed groups.create body typedValue = template name; deleteGroupTemplate(id, reason) (used only by this page)
Group detail Save payment/GST/cancellation defaults reason groups.edit body Details list policy, deposit %, balance-due days, GST
Group detail Save custom fields (metadata) simple groups.edit —
Group detail Choose / remove the group leader (Overview picker — this group's travellers only, PAX-035) simple groups.edit — set_group_leader via POST /groups/:id/leader
Group detail Passengers → a traveller → Make / remove group leader simple groups.edit — Same route; the booking dialog's booking-level "Mark as Leader" is gone
Group detail Passengers → a traveller → Transfer / Remove from group reason (in the booking page's transfer or cancellation dialog) booking.transfer / bookings.cancel body Remove is a choice dialog first; nothing is written until the transfer or cancellation dialog confirms
Group detail Passengers → bulk Assign Hotel / Meal / Ground, Link Flight, Request Visa (selected travellers) simple bookings.edit / visa.create — Counts are travellers, not bookings
Group detail Assign group payer (handleAssignGroupPayer) reason — body Handler currently has no call site in the UI
Group detail Assign existing booking to group reason groups.edit param Already compliant (moveBookingToGroup(..., reason)); gate added
Group detail Move passenger to another group reason groups.edit param Already compliant
Group detail Drop passenger from group reason groups.edit param Already compliant
Group detail Add passenger (new customer + booking) simple / reason — body reason when money was received (payment POST); no call site for the dialog today
Group detail Import passenger roster (commit) simple bookings.create — GroupPassengerImportDialog; gate pre-existing on the trigger
Flights tab Link flight (block / FIT) simple groups.edit —
Flights tab Unlink flight simple groups.edit —
Flights tab Save flight PNR simple groups.edit —
Passenger dialog Save passenger flight + PNR override simple bookings.edit —
Hotels tab Assign hotels from inventory simple hotels.edit — API route requires hotels.edit
Hotels tab Update hotel allocation simple / reason hotels.edit — reason when pricePerNight changes (money)
Hotels tab Remove hotel allocation simple hotels.edit — Rooms return to inventory
Passenger dialog Assign / remove passenger hotel stay simple — — Checkbox doubles as state indicator — not gated (see Not converted)
Ground tab Link transfer simple inventory.edit — API route requires inventory.edit
Ground tab Remove transfer simple inventory.edit —
Meals tab Link meal plan simple food.edit — API route requires food.edit
Meals tab Remove meal plan simple food.edit —
Passengers tab Add misc expense reason groups.edit body Amount + category in the description
Passengers tab Delete misc expense typed groups.edit body typedValue = expense description
Passengers tab Bulk link flight to selected bookings simple bookings.edit — Count in the title
Passengers tab Bulk assign hotel simple bookings.edit —
Passengers tab Bulk assign meal plan simple bookings.edit —
Passengers tab Bulk assign ground service simple bookings.edit —
Passengers tab Bulk request visa cases simple visa.create — Count in the title
Passengers tab Request visa (row / compact icon) simple — — Icon also shows visa state — not gated (see Not converted)
Passengers tab Booking status pill — — — <StatusBadge domain="booking">
Pricing tab Save rate sheet reason group_pricing.edit body Sent as { pricing, reason } so the sheet is not polluted
Pricing tab Compare with inventory cost — group_pricing.edit — Reads cost only; changes nothing, so nothing to confirm (PRC-006)
Pricing tab Use cost as price simple group_pricing.edit — Destructive style; per-adult now vs at cost in details; Save still asks for a reason
Overview tab Stop selling / Reopen sales / Mark departed / Mark completed / Back to automatic reason groups.edit body Status now → after in details; reason stored as statusOverrideReason (INV-006)
Invoices tab Create draft invoice for payer simple group_invoices.create — Draft only; issuing confirms again
Invoices tab Create internal invoice simple group_invoices.create —
Invoices tab Create supplementary invoice simple group_invoices.create body createSupplementaryDraft(id, paxDelta, reason)
Invoices tab Invoice status pill — — — <StatusBadge domain="invoice"> replaces badgeVariant
Invoice dialog Save draft (line items, taxes, due date) reason group_invoices.edit body Details show subtotal / tax / total
Invoice dialog Issue invoice reason group_invoices.issue body Payer + total in details
Invoice dialog Post journal entries to finance reason group_invoices.issue body Dr/Cr summary in details
Invoice dialog Cancel invoice (credit note) reason group_invoices.cancel body Says whether a reversal is posted
Invoice dialog Invoice status pill — — — <StatusBadge domain="invoice">
Group detail Upload group document simple groups.edit — GroupCustomizationPanel; gate pre-existing
Group detail Remove group document typed groups.edit body typedValue = document name
Group detail Assign / clear tour leader simple groups.edit —
Group detail Save required passenger fields simple groups.edit — Lists the fields that become mandatory
Not converted / notes:
- Operations → Capacity (src/pages/operations/Capacity.tsx) — read-only dashboard; no state-changing action, and its utilisation colours are a load meter, not a status pill.
- Exports / print (rooming list, flight manifest, meal count, print group report, copy itinerary) — GET-only, no state change; already gated with groups.view.
- Per-passenger hotel checkbox and the per-row / compact "Request visa" icons — confirmations added, but no <PermissionGate>: these controls double as state indicators inside dense table rows, so hiding them would hide the passenger's hotel/visa status. They need a disabled-state variant of the gate to be done properly.
- GroupReadinessPanel, MovementChart, GroupCard (view actions) — presentation only; no writes.
- Reason not forwarded for hotel / food / ground assignment service calls (assignHotelRooms, updateHotelAssignment, deleteHotelAssignment, assignFood, deleteFoodAssignment, assignGroundTransfer, deleteGroundTransferAssignment) — those service functions are shared with pages/hotels/Hotels.tsx, pages/food/Food.tsx and pages/inventory/GroundTransfers.tsx, so the signature was left alone per the brief. The confirmations are in place; only the audit reason is missing.
- Permission-matrix drift to flag (no doc edit made — docs/PERMISSIONS.md is off-limits for this sweep): §6.5 lists "Assign hotel" and "Assign transfer" under groups.edit, but the API routes (/hotels/assignments, /ground-transfers/assignments, /food/assignments) enforce hotels.edit, inventory.edit and food.edit. The gates follow the API. Also missing from §6.5: group defaults, custom fields, documents, tour leader, misc expenses, bulk passenger assignment and per-passenger PNR rows.

Airline blocks, FIT, B2B flights, ground services

Screen Action Level Permission gate Reason sent Notes
AirlineBlocks Create airline block (no initial payment) simple inventory.create — Replaces local pendingConfirm dialog
AirlineBlocks Create airline block + initial payment reason inventory.create body createAirlineBlock(payload, reason) → POST body
AirlineBlocks Modify airline block (no payment delta) simple inventory.edit body Button already hidden by canModifyAirlineBlocks
AirlineBlocks Modify airline block + initial-payment adjustment/reversal reason inventory.edit body Reason on the PATCH and on the finance event
AirlineBlocks Delete airline block (grid/list row) typed inventory.edit body Types the block code (API checks inventory.edit)
AirlineBlocks Delete airline block (detail view) typed inventory.edit body Now navigates away only after a successful delete
AirlineBlocks Save block configuration (infants, lost seats, expiry) simple inventory.edit body
AirlineBlocks Cancel full block reason inventory.edit body Posts full_cancellation finance event (server: finance.create)
AirlineBlocks Post supplier payment reason finance.create body Dr/Cr lines shown in details
AirlineBlocks Sell seats to third party reason inventory.edit body Loss sales flagged destructive; amount + buyer in description
AirlineBlocks File cancellation of third-party sale reason finance.create body Reason also fills filedNotes when notes are blank
AirlineBlocks Link group to block simple groups.edit — API route is POST /groups/:id/flights
AirlineBlocks Unlink group from block simple groups.edit — API route is DELETE /groups/:id/flights/:flightId
AirlineBlocks Save B2B passenger details simple inventory.edit —
AirlineBlocks Add airline simple inventory.create —
AirlineBlocks Delete airline typed inventory.edit body Types the airline code
AirlineBlocks Save airline flight numbers simple inventory.edit — Dialog itself is gated inventory.edit
AirlineBlocks Show full PNR vs masked PNR n/a tickets.view — Scope item 6: PNR_VISIBLE_ROLES role-name set + UserRole lookup removed; tickets.view is the closest §4 permission — it is exactly the ticketing-desk + admin-tier bundle the old list encoded (CEO/GM/IT_ADMIN/ADMIN_HR/TICKET_MANAGER), and it is what the API already uses to expose ticket/PNR data. inventory.edit was rejected: OPS_EXEC/OPS_MANAGER hold it and never saw full PNRs. canModifyAirlineBlocks now checks inventory.edit alone (what the PATCH route requires) instead of canViewPnr ‖ inventory.edit
FITInventory Create FIT (no initial payment) simple inventory.create —
FITInventory Create FIT + initial payment reason inventory.create body
FITInventory Edit FIT simple inventory.edit —
FITInventory Delete FIT typed inventory.edit body Replaces window.confirm; types the FIT code
FITInventory Post FIT supplier payment reason finance.create body Dr/Cr lines in details; all three entry points gated
B2BFlights Quick add flight (block + offer) simple inventory.create —
B2BFlights Create B2B offer simple inventory.create —
B2BFlights Close B2B offer reason inventory.edit body updateB2BFlightOffer(id, patch, reason)
GroundTransfers Create transfer simple inventory.create —
GroundTransfers Edit transfer simple inventory.edit —
GroundTransfers Delete transfer typed inventory.edit body Replaces window.confirm; types "Origin to Destination"
GroundTransfers Assign transfer to group reason inventory.edit — Posts the expense journal; reason not sent (see below)
GroundTransfers Remove transfer assignment reason inventory.edit — Reverses the expense journal; reason not sent (see below)
ReleasedSeatsPanel File airline cancellation (block + FIT) reason finance.create body Already gated; confirmation + reason added, totals in details
ReleasedSeatsPanel Mark released seat re-used simple inventory.edit — Already gated; confirmation added on the note dialog's submit
Not converted / notes:
- Print / export actions (Print Inventory, block manifest, B2B sale tax invoice), flight-schedule lookup ("Fetch flight details"), released-seat refresh, and all dialog open/close buttons — read-only, no state change.
- Purely client-side form edits (add/remove leg, add/remove a flight-number chip before Save, add/remove a B2B passenger row, seat/price fields) — nothing is written until the confirmed Save.
- assignGroundTransfer / deleteGroundTransferAssignment collect a reason but do not send it: both service functions are also used by src/pages/groups/Groups.tsx, which is another worker's file, so their signatures were left alone (brief §2 "Reason → API"). Fix later by adding an optional reason to those two service functions once both callers can be touched in one PR.
- docs/PERMISSIONS.md §6.12 drift (shared file — not edited): it lists inventory.delete for block/FIT/ground-transfer deletes and inventory.allocate for link/unlink + assign/remove, but neither permission exists in §4 and the API routes actually call requirePermission('inventory.edit') (deletes, assignments) and requirePermission('groups.edit') (block ↔ group links). Gates follow the API; §6.12 should be corrected to inventory.edit / groups.edit.
- No test files exist for these six screens and none were broken (only src/lib/api.cancellationChain.test.ts touches these routes, at API level, and it still passes unchanged); no new test added in this sweep.

Hotels, food, suppliers, business partners

Screen Action Level Permission gate Reason sent Notes
Hotels Create hotel (dialog Save) simple hotels.create (existing) — Confirms after form validation
Hotels Edit hotel (dialog Save) simple hotels.edit (existing) —
Hotels Delete hotel (card) typed (hotel name as shown on card) hotels.delete (existing) body window.confirm replaced; deleteHotel(id, reason?) sends { reason }
Hotels Unassign hotel from group (card) simple hotels.edit (existing) — window.confirm replaced
Hotels Link hotel(s) to group (assign dialog) simple hotels.edit (existing) —
Hotels Remove assignment (Assignments tab "Undo") simple hotels.edit (was hotels.delete) — window.confirm replaced; gate aligned to API DELETE /hotels/assignments/:id → hotels.edit
Hotels Apply low-room thresholds simple hotels.edit (existing) —
Food Create food item simple food.create (existing) —
Food Edit food item simple food.edit (existing) —
Food Delete food item typed (item name) food.delete (existing) body window.confirm replaced; deleteFoodItem(id, reason?) sends { reason }
Food Import CSV / file simple food.create (existing) — Empty-input check moved before the confirm
Food Apply food threshold simple food.edit (existing) —
Food Assign meals to group simple food.edit (existing) —
Food Remove food assignment ("Undo") simple food.edit (was food.delete) — window.confirm replaced; gate aligned to API DELETE /food/assignments/:id → food.edit
Suppliers Create supplier simple suppliers.create (existing) —
Suppliers Link airline as ticketing supplier simple suppliers.create (existing) —
Suppliers Edit supplier (Save Changes) simple suppliers.edit (existing) —
Suppliers Deactivate / reactivate supplier reason suppliers.edit (existing) body updateSupplier patch type accepts reason; API allowlist ignores it for the row
Suppliers Delete supplier (hard) typed (supplier name) suppliers.delete (existing) body window.confirm replaced; hardDeleteSupplier(id, reason?)
Suppliers Record / update ledger transaction reason suppliers.edit (existing) — Amount, type, date and account shown; reason collected but NOT sent (see below)
Suppliers Delete ledger transaction typed (supplier name) suppliers.delete (existing) body window.confirm replaced; deleteSupplierTransaction(id, reason?); description shows type, amount, date
SupplierDetailPage "+ Add Hotel" (navigates to Hotels create) — (navigation) hotels.create (was hotels.edit) — Gate matches the create dialog it opens; no state change on this page
Partners Create business partner simple; reason when "Create portal login" is on agents.create (added on dialog Create; header already gated) body Description shows commission, credit limit, login e-mail
Partners Edit partner (Save Changes) simple; reason when commission or credit limit changes partners.edit (added on row Edit + Save) body Description shows old → new commission / credit limit
Partners Create login / reset access key reason partners.edit (added on row button + dialog Save) body Reason sent on both PATCH /agents/:id and POST /users/:id/password
Partners Deactivate / reactivate partner reason partners.edit (added) body
Partners Delete partner typed (company name or name) agents.delete (existing) body window.confirm replaced
Partners Allocate on-account receipt to bookings reason finance.payments.record (added on ledger row Allocate + Apply Allocation) body Total in title; per-booking amounts in details
Not converted / notes:
- Supplier transaction record/update does not send the reason: createSupplierTransaction / updateSupplierTransaction are also used by src/pages/finance/Finance.tsx and src/components/finance/QuickVoucherDialog.tsx, so I didn't change their signatures. The reason is collected but dropped. Follow-up: add optional reason to those service payloads.
- Partner permission drift: §6.9 lists agents.edit for edit / access key / deactivate, but agents.edit isn't in the §4 catalog (§8 item 6), so the drift test would fail. The API (handleAgentsById PATCH) requires partners.edit, so the gates use partners.edit. Follow-up: fix §6.9 in docs/PERMISSIONS.md.
- Access-key reset for a partner who already has a login calls POST /users/:id/password, which requires admin.users.reset_password. The button is gated on partners.edit, so a user without the reset permission gets an API error. It needs a split gate or a partner-scoped reset route.
- Header "Add Business Partner" is gated on agents.create, but POST /agents requires partners.create. Left as is; §6.9 names agents.create.
- Hotel create posts Dr Expense / Cr Supplier Payable, which is arguably money. It's kept at simple as the lead instructed for master-data create/edit.
- Ledger quick actions (PartyLedgerQuickActions: payment / receipt / settlement on the Suppliers, SupplierDetailPage and Partners ledgers) are a shared component outside this group and weren't touched.
- Partner "Print statement" (PartyLedgerDialog onPrint) and supplier ledger Excel export are read-only. Neither was confirmed. The reports.export gate wasn't applied because print is a prop on the shared dialog.
- TDS on supplier payment (finance.tds.deduct): no TDS control exists in these files. It lives in the Finance / QuickVoucher flows.
- Status colours: HotelAllotmentCalendar cell colours are an occupancy heatmap, not status pills. Partners' green/orange outstanding amounts and the stat-card icon backgrounds aren't statuses. Status pills already use StatusBadge (Hotels card, Partners row, SupplierDetailPage). No replacements needed.
- HotelInventoryDashboard: read-only, no actions.
- DELETE request bodies: the reason goes in the JSON body. The current api.ts handlers ignore it, so it reaches the audit trail only once the shared audit-context wiring reads body.reason.

Leads, quotations, requests, customers

Screen Action Level Permission gate Reason sent Notes
Leads Add lead (dialog submit) simple leads.edit (existing, opener) — Converted
Leads Update lead status reason leads.edit (existing) body Converted; updateLeadStatus(id, status, reason?) in leadService (used only by Leads)
Leads Convert to quotation reason quotations.create (existing) body Converted; convertLeadToQuotation(..., reason?)
Leads Convert to booking reason bookings.create (existing) body Converted; convertLeadToBooking(..., reason?); group check moved before the confirm
Quotations Create / update quotation (dialog submit) simple quotations.create (existing on header; row Edit gated) — Converted
Quotations Send quotation (row + view dialog) reason quotations.create (added) body Converted; sendQuotation(id, payload, reason?); alternate-email window.prompt kept
Quotations Convert to booking (conversion dialog submit) reason bookings.create (added, row + view dialog) body Converted; createBookingFromQuotation(data, reason?) + follow-up updateQuotationStatus(id, 'accepted', reason)
Quotations Edit (view dialog) — quotations.create (added) — Opens the form; confirmation happens on save
Quotations Delete quotation typed (quotation no) quotations.create (added; no quotations.delete in §4) body Replaced window.confirm
Quotations Status pill — — — StatusBadge now passes domain="quotation"
CustomerFormDialog Create customer (submit) simple — (callers gate their opener) — Converted; used by Quotations, Bookings, Dashboard (all under the app-level ConfirmProvider)
Requests Save changes, status changed reason requests.edit (existing) body (note) Already compliant
Requests Save changes, no status change simple requests.edit (existing) — Already compliant
Requests Add note simple requests.edit (existing) — Already compliant
Requests Upload attachments simple requests.edit (added) — Converted
Requests Create lead from request reason leads.edit (existing) body Converted; convertRequestToLead(id, reason?)
Requests Create booking from request (dialog submit) reason bookings.create (existing on opener, added on submit) body Converted; convertRequestToBooking(id, payload, reason?)
Customers Sync customers simple customers.edit (added) — Converted
Customers Import CSV commit simple customers.create (existing, opener) — Converted
Customers Create / edit customer (dialog submit) simple customers.create / customers.edit (existing) — Converted
Customers Delete customer (view card) typed (customer name) customers.delete (existing) body Replaced window.confirm; delete mutation now takes { customerId, reason }
Customers Assign to group (creates draft booking + patches customer) reason bookings.create (added) body Converted; reason added to both request bodies
Customers Create account / reset password (view card) reason customers.edit (existing) body Converted
Customers Upload document to Drive (profile) simple customers.edit (added) — Converted
Customers Delete Drive document (profile) typed (file name) customers.edit (added) body Replaced window.confirm
Customers Remove visa document (profile) typed (file name) — param Upgraded from reason; deleteVisaDocument(..., reason) already took the reason
Customers Delete request attachment (profile) typed (file name) — body Replaced window.confirm
Not converted / notes:
- Quotations handleCreateCustomer: never called (the page uses CustomerFormDialog), so nothing to confirm. Left in place; it can be deleted in a cleanup.
- Quotations "Download PDF": has no handler and changes nothing.
- Customers "Extract from Passport" (OCR) and Export CSV / template download: read-only, nothing changes.
- API gaps, noted but not fixed (src/lib/api.ts is shared): DELETE /sales/quotations/:id, POST /requests/:id/attachments, POST /requests/:id/convert/lead|booking and DELETE /requests/attachments/:id have no handler in api.ts, so these calls fail today. /sales/customers/:id/password has no requirePermission(, so only the UI gate (customers.edit) protects it.
- No gates added on the visa-document and request-attachment delete buttons in the Customers profile: the routes have no requirePermission( to copy, and the profile is being rebuilt by the Customer 360 workstream.
- Lead status and request status pills stay as Badge variants: there's no lead or request domain in statusTones, and they don't use ad-hoc colour classes. The import preview's green/amber/red count chips and row tints show import-row state, not record status, so they're unchanged.
- Existing bug left alone (not UX-001): the Leads convert-to-booking "Payment Policy" Select has no SelectTrigger/SelectContent.

Bookings, passengers, approvals and corrections

Screen Action Level Permission gate Reason sent Notes
Bookings (list) Create booking (inline dialog handler) simple bookings.create — Handler kept for the legacy inline dialog; New Booking routes to the wizard
Bookings (list) Import bookings (CSV/XLSX) simple bookings.create — Confirm added in BookingImportDialog on the import commit
Bookings (list) Submit draft for approval reason bookings.edit param (submitBooking(id, reason)) Already compliant (Wave 1)
Bookings (list) Delete draft booking typed (booking no) bookings.delete param Already compliant (Wave 1)
Bookings (list) Send back for correction reason approvals.approve body reason Local note dialog removed; reason now collected in the confirm
Bookings (list) Resubmit after correction reason bookings.edit body note Local note dialog removed
Bookings (list) Record payment reason finance.create body reason Level was right; reason was discarded, now sent
Bookings (list) Repair legacy payment (correcting receipt) reason finance.create body reason Level was right; reason now sent
Bookings (list) Save inline booking edit (prices/group/payer) reason bookings.edit body reason Deprecated inline dialog; upgraded from no confirm
Bookings (list) Create business partner (inline) simple partners.create — Matches requirePermission('partners.create') on POST /agents
Bookings (list) Request visa case simple visa.create — Gate added
Bookings (list) Send WhatsApp update simple communications.send — Already compliant (Wave 1)
Bookings (list) Bulk cancel selected typed (count) bookings.cancel body cancellationReason Via CancellationDialog bulk mode
Bookings (list) Bulk transfer selected typed (count) booking.transfer body reason Via TransferPassengerDialog bulk mode
Bookings (list) Status column pill — — — Ad-hoc colour map → <StatusBadge domain="booking">
BookingDetail Submit draft for approval reason bookings.edit param Already compliant (Wave 1)
BookingDetail Add passengers reason bookings.edit body reason Changes booking total → reason (see AddPassengersDialog)
BookingDetail Add payment reason finance.create body Delegated to RecordPaymentDialog (finance worker's file)
BookingDetail Request booking cancellation reason bookings.cancel body cancellationReason Reason moved from the form into the confirm
BookingDetail Review/approve cancellation reason bookings.cancel.approve body reason (+ overrideRefundAmount) Confirm shows charge/refund incl. override
BookingDetail Reject cancellation request reason bookings.cancel.approve body rejectionReason Reason moved into the confirm
BookingDetail Pay refund reason bookings.cancel.approve body reason Already compliant (Wave 1)
BookingDetail Transfer passenger reason booking.transfer body reason Upgraded from no confirm
BookingDetail Cancel passenger / review passenger cancellation reason bookings.cancel / bookings.cancel.approve body Same CancellationDialog paths
BookingDetail Header / passenger / payment / journal status pills — — — StatusTone + amber pills → <StatusBadge> (booking, payment, journal)
BookingWizard / BookingEdit (WizardShell) Create booking & submit reason bookings.create body submitNote Level already right; gate added
BookingWizard / BookingEdit (WizardShell) Save booking changes reason bookings.edit body reason Was simple when the group did not change — upgraded (rates/GST/total change)
BookingWizard / BookingEdit (WizardShell) Move booking to another group reason bookings.edit (API groups.edit) body reason Same confirm as save; move-group POST carries the reason
AddPassengersDialog Add passengers (changes total) reason bookings.edit body reason Reason now sent with the PATCH
PassengerFlightsPanel Assign flight leg simple bookings.edit — Confirm added (seat taken from inventory)
PassengerFlightsPanel Remove flight assignment simple bookings.edit — Replaced window.confirm
PassengerFlightsPanel Edit leg (PNR/ticket/seat/status) simple bookings.edit — Confirm added on Save
PassengerFlightsPanel Leg status pill — — — Ad-hoc Badge variants → <StatusBadge domain="ticket">
PassengerHotelsPanel Assign all group hotels simple bookings.edit — Confirm added, lists the hotels
PassengerHotelsPanel Remove hotel assignment simple bookings.edit — Replaced window.confirm
PassengerHotelsPanel Edit stay (room share/room/meals/status) simple bookings.edit — Confirm added on Save
PassengerHotelsPanel Stay status pill — — — Ad-hoc Badge variants → <StatusBadge>
SendMessageDialog Send email / WhatsApp message simple communications.send — Level made explicit; gate added
Approvals Approve booking (ops) reason approvals.approve body notes + reason Local AlertDialog replaced by useConfirm
Approvals Reject booking reason approvals.approve body reason Local AlertDialog + textarea replaced
Approvals Send back for correction reason approvals.approve body reason Local AlertDialog + textarea replaced
Approvals Log communication simple communications.send — Gate matches requirePermission('communications.send') on POST /operations/communications
CorrectionsQueue Assign correction owner simple approvals.approve — Confirm added on the owner <select>
CorrectionsQueue Resubmit corrected booking reason bookings.edit body note Note dialog removed; gate corrected from approvals.approve (§6.6 says bookings.edit)
CorrectionsQueue Status pills — — — <StatusBadge domain="booking">
Not converted / notes:
- Bookings.tsx row "peek" dialog is dead UI (<Dialog open={false}>): its Send Back / Resubmit / Request Visa / Add Payment / Add Passengers buttons were converted anyway, but they are currently unreachable — users enter via navigate() to BookingDetail. Same for the inline Edit-booking dialog (openEditDialog has no caller) and the Create-partner dialog (agentDialogOpen is never set). Worth deleting in a follow-up.
- Bookings.tsx pagination, filters, print invoice, ledger open, voucher open — read-only, no confirm.
- Bookings.tsx "Approvals" column icons and the Assigned/Unassigned group pill — not status pills for a StatusBadge domain (icon-only approval state / boolean membership), left as-is.
- WizardShell "Sync rates from group" — rewrites the on-screen rate fields only; nothing is written until the wizard's own confirmed submit.
- BookingDetail "Add Payment" delegates to src/components/finance/RecordPaymentDialog.tsx (another worker's file) — confirm level/reason verified there, not changed here.
- POST /operations/bookings/:id/ops-reject has no handler in src/lib/api.ts (the route regex only matches ops-send-back|resubmit|ops-approve|corrections/assign). The Reject button in Approvals is converted and gated, but the call will 404 until a handler exists — shared-file fix, not made here.
- §6.6 of docs/PERMISSIONS.md lists "Log communication → approvals.view", while the API requires communications.send; the gate follows the API. Matrix row worth correcting in the shared doc.

Visa, tickets, communications, chat

Screen Action Level Permission gate Reason sent Notes
VisaPipeline Per-row visa status change reason visa.edit (existing) body Already compliant (VISA-003 transitions only)
VisaPipeline Bulk status change reason visa.edit (existing) body Already compliant; lists eligible and skipped cases
VisaPipeline Sync visa cases simple visa.edit (existing) — Converted; double-click guard added
VisaPipeline Create visa case (dialog submit) simple visa.create (existing) — Converted; shows booking no, customer, city
VisaPipeline Upload document (dialog submit) simple visa.edit (existing) — Converted; shows final file name and case
VisaPipeline Inline upload in Docs dialog simple visa.edit (existing) — Converted; file input is reset on cancel
VisaPipeline Remove visa document typed (file name) visa.edit (existing) body Upgraded from reason; soft delete (VISA-030)
VisaPipeline Visa group card status pill — — — Swapped for StatusBadge domain visaGroup
VisaPipeline Case status badge (row fallback + detail dialog) — — — Swapped for StatusBadge domain visa; unused badgeVariant removed
VisaCaseDetail Status change reason visa.edit (existing) body Already compliant
VisaCaseDetail Remove visa document typed (file name) visa.edit (existing) body Upgraded from reason
VisaCaseDetail Status badge (2 places) — — — Swapped for StatusBadge domain visa
VisaGroupDetail Save visa group code simple visa.groups.edit (existing) — Converted; shows old and new code
VisaGroupDetail Set / clear status override (dialog Apply) reason visa.groups.edit (existing) body (statusOverrideReason) Converted; the dialog's own reason input is removed so the reason is asked once
VisaGroupDetail Close visa group reason visa.groups.edit (existing) body Replaced window.confirm; closeVisaGroup(id, reason?) now sends { reason } (service used only by visa pages)
VisaGroupDetail Group status pill + case status badges — — — Local statusBadge colour helper swapped for StatusBadge (visaGroup / visa)
VisaIntakeQueue Change intake status / reject reason visa.intake.convert (existing) body Already compliant
VisaIntakeQueue Convert to visa case simple visa.intake.convert (existing) — Already compliant (creates a case; the RPC takes no reason)
VisaIntakeQueue Status pill — — — Local statusColor map swapped for StatusBadge domain visaIntake
Tickets Request exception reason tickets.edit (existing) body Already compliant
Tickets Approve / reject exception reason tickets.approve (existing) body Already compliant (maker-checker)
Tickets Issue ticket (dialog submit) reason tickets.approve (existing) body Already compliant
Tickets Update passenger name (dialog submit) reason tickets.edit (existing) body Already compliant
Tickets Bulk update names reason tickets.edit (existing) body Already compliant; lists each ticket
Tickets Sync tickets simple tickets.edit (added) — Converted; moved into a handler with a busy guard
Tickets Exception requested / approved pills — — — Swapped for StatusBadge domain ticketException; ticket status badges now pass domain="ticket"
TicketDetail Upload ticket document (dialog submit) simple tickets.edit (existing) — Converted
TicketDetail Ticket status badge (2 places) — — — Now passes domain="ticket"
Communications Send / queue reminder (bulk) reason communications.send (existing) body Already compliant; shows selected/allowed recipient count, opted out, no consent record
WhatsAppInbox Send free-text message simple communications.send (existing) — Converted; shows 1 recipient, phone and text
WhatsAppInbox Send approved template simple communications.send (existing) — Already confirmed; now says level explicitly, shows 1 recipient, busy guard added
WhatsAppInbox Start new conversation (find/create contact) simple communications.send (added on New conversation button) — Converted
Chat Delete message simple — (own messages only) — Replaced window.confirm; no stable visible identifier to type, so it is a simple confirm showing author, time and preview
Chat Delete channel typed (channel name) chat.view (added in ChannelList) — Replaced window.confirm; reason is collected but not stored (see below)
Chat Create channel (dialog submit) simple chat.view (added) — Converted; busy guard added
Chat Start direct message simple chat.view (added) — Converted
Not converted / notes:
- Chat: sending, editing and reacting to internal chat messages. These are chat actions the user can undo (edit or delete), and a confirm on every message would break the chat.
- Chat: the reason from a typed channel delete is not stored. chatService.deleteChannel writes straight to Supabase (ChatChannel.delete()), there is no API route or audit context to take it, and adding one is out of scope.
- Chat: joinChannel (onJoin). ChannelList never renders a Join control, so no user can reach it.
- FileUploader (chat attachment upload on file pick): it only stores a draft attachment. The real commit is sending the message, which is not confirmed (see above).
- WhatsAppInbox: markContactRead and Chat upsertReadState run on their own when a conversation opens. The user does not trigger them.
- Communications: bulk send stays at reason even for one recipient, because the API records body.reason via recordActionReason for both queue and send.
- Permission gates in Chat are chat.view (the only chat permission in §4). They do nothing extra over the route guard, and chat writes are enforced by RLS only.
- /communications and /whatsapp routes still use customers.view (existing §6.17 drift). Not changed here.

Admin, permissions, people, security settings, reports

Screen Action Level Permission gate Reason sent Notes
UserManagement Create user (Add employee → Create) reason admin.users.create (+ roles.request to choose a role) body the login starts with no role; a chosen role becomes a role request carrying the same reason (ACC-082)
UserManagement → RoleRequestDialog Request role change (ACC-077) reason roles.request body the dialog's reason is required; the change itself waits for a CEO and an admin
RoleRequests Approve / reject (CEO), accept and apply / reject (admin), withdraw simple / reason roles.approve / roles.apply / roles.request body a rejection and a withdrawal need a note
UserManagement Deactivate / reactivate user reason admin.users.delete body PATCH /users/:id requires admin.users.delete in api.ts
UserManagement Reset password reason admin.users.reset_password body confirm on the dialog's Reset, not on opening
UserManagement Reset MFA reason admin.mfa.reset (already present) body replaced window.confirm
PermissionsMatrix Save role permissions typed admin.permissions.edit body typedValue = role name; details list granted/revoked permission names
PermissionsMatrix Save user overrides typed admin.permissions.edit body typedValue = user e-mail; details list changed overrides. Roles are read only here since 2026-09-30 (ACC-077)
PermissionsMatrix Discard unsaved role / user changes simple — — replaced window.confirm; local state only
CurrencySettings Activate / deactivate currency (switch) simple admin.currency.edit — direct supabase.from('currencies').update
CurrencySettings Create / edit currency simple admin.currency.create / admin.currency.edit — confirm on dialog Save
CurrencySettings Add / update manual exchange rate simple admin.currency.create / admin.currency.edit — details show the rate + effective/expiry dates
CurrencySettings Delete exchange rate typed admin.currency.delete — replaced confirm(); typedValue = FROM-TO (arrow is unusable to type)
CurrencySettings Clear ALL manual rates for today typed (count) admin.currency.delete — replaced confirm(); typedValue = number of loaded manual rates for today
CurrencySettings Fetch auto rates simple admin.currency.edit — sync/refresh from the exchange-rate API
CitySettings Create / edit city simple admin.cities.create / admin.cities.edit — confirm on dialog Save
CitySettings Delete city typed admin.cities.delete — replaced window.confirm; typedValue = city name
AirportSettings Rename airport simple — — localStorage-only override (no server route/permission)
AirportSettings Reset airport to default name simple — — clears one local override
AirportSettings Import airport names (JSON) simple — — confirm after the file parses, before overrides are replaced
IntegrationSettings Save Email / WhatsApp / SMS section reason admin.integrations.edit body (reason) already compliant before this sweep
IntegrationSettings Clear a secret reason admin.integrations.edit body (reason) already compliant before this sweep
IntegrationSettings Send test email / WhatsApp simple admin.integrations.test — added confirm — a real message is sent
CancellationPolicies Create / edit policy (+ slabs) simple admin.edit — details list every slab (x–y days → z%)
CancellationPolicies Delete policy typed admin.edit body replaced the local AlertDialog; typedValue = policy name
SecuritySettings Enable 2FA simple — (own account) body
SecuritySettings Disable 2FA reason — (own account) body
SecuritySettings Switch verification method (email/WhatsApp) simple — (own account) body no-op click on the already-active method now returns early
SecuritySettings Switch away from authenticator (drops the TOTP factor) reason — (own account) body (disenroll + settings)
SecuritySettings Regenerate backup codes reason — (own account) body replaced confirm()
SecuritySettings Enable / disable push notifications (this device) simple — (own device) —
ActiveSessionsPanel Sign out this (current) session simple — (own sessions) — plain logout of this device
ActiveSessionsPanel Sign out another session reason — (own sessions) body (reason via revokeSession)
ActiveSessionsPanel Sign out other sessions reason — (own sessions) body (revokeOtherSessions)
ActiveSessionsPanel Sign out all sessions reason — (own sessions) body (revokeAllSessions)

Status badges replaced with <StatusBadge>: UserManagement (row status column + inline "Inactive" chip, domain="user"), CurrencySettings (currency Active/Inactive, rate Active/Inactive), CitySettings (Active/Inactive), CancellationPolicies (Active/Inactive), IntegrationSettings (Configured / Not configured), SecuritySettings (2FA and push Enabled / Disabled). Left alone: the Manual/Auto rate chip and the "Default" policy chip (type labels, not statuses), the verification-method "Active" selection markers in SecuritySettings, role chips in UserManagement/OnlineUsers, Admin.tsx "Enabled" reference chips (static informational copy).

Role checks (scope item 6):

  • PermissionsMatrix.tsx — SUPER_ADMIN_ROLES = new Set(['CEO','GM','IT_ADMIN']) is gone. It was display-only (an amber "super-admin" badge on the role tab, a ★/amber highlight on the role chips in the user tab) and never blocked editing, but it hard-coded role names. Replaced with holdsEveryPermission(role, permissions) — a role is shown as super-admin when its saved grants cover every permission row, derived from the /permissions + /permissions/roles payloads. Copy updated accordingly ("Holds every permission (super-admin)", "Roles marked ★ currently hold every permission"). ★ now shows on every such role chip, not only the selected ones. New test src/components/admin/PermissionsMatrix.test.tsx proves a role named DEPUTY is flagged from its grants.
  • UserManagement.tsx — roleLabels / defaultRoles are display + form-option maps only; left as is. The Role Hierarchy card is gone (the page has an organisation chart instead).
  • OnlineUsers.tsx — filters presence rows by user kind (staff / admin vs agent) for the All/Staff/Partners tabs; display-only, no state change, left as is.
  • No role-name access check was introduced anywhere. Not converted / notes:
  • src/pages/admin/Admin.tsx — no state-changing action on the page (dashboard stats, audit-log table, booking-number decoder, read-only "Approval policies"/"System settings" reference cards). Tabs only mount the child components.
  • src/components/admin/LocationSettings.tsx — read-only browser of the India state/district hierarchy; the only onClicks set local selection.
  • src/pages/people/People.tsx — landing page with counts and a read-only activity feed; no writes.
  • src/pages/people/Employees.tsx — 10-line wrapper that renders <UserManagement>; all actions covered above.
  • src/pages/reports/Reports.tsx — report viewing and CSV/PDF downloads only (exports, not state changes); export buttons already sit behind <PermissionGate permission="reports.export">.
  • src/components/admin/OnlineUsers.tsx — presence display only.
  • UserManagement "delete user" — the UI has no hard-delete control (only deactivate/reactivate), so admin.users.delete is used for the deactivate control, which is what PATCH /users/:id requires.
  • AirportSettings import/rename/reset are not gated: the overrides live in localStorage (airport_name_overrides), there is no API route or permission for them. If airports become server data, gate them then.

Notes for the integrator (shared files I did not touch):

  • docs/PERMISSIONS.md §6.15/§6.16 still says user-management actions sit behind admin.view ("should tighten") and that Currency/Cities CRUD needs admin.edit. Code now uses the granular admin.users.*, admin.currency.*, admin.cities.*, admin.permissions.edit — the §6 rows need updating, and §8 item 7/10 can be narrowed.
  • src/test/permissions.matrix.test.ts ALLOWED_DOC_ONLY still lists admin.currency.create|edit|delete, admin.cities.create|edit|delete, admin.users.view etc. as "seeded but unwired"; the currency/cities entries are now wired and can be dropped from that list (test harness — not mine to edit; it passes either way).
  • RLS for public.currencies / public.exchange_rates (20260401190000_cleanup_legacy_objects.sql, "Staff can manage …") and service_cities does not use auth_user_has_permission('admin.currency.*' / 'admin.cities.*'), so the new gates are UI-only until a migration tightens those policies.
  • Reason plumbing: src/services/sessionService.ts revokeSession/revokeOtherSessions/revokeAllSessions gained an optional trailing reason?: string merged into the edge-function body (those three are used only by ActiveSessionsPanel). The session-manager edge function currently ignores it. Currency/city writes go straight to Supabase tables with no reason column, so their confirmations are simple/typed with no reason channel (marked — above).

Matrix drift found during the sweep

The gates above follow what src/lib/api.ts actually enforces. Where docs/PERMISSIONS.md §6 names a different permission, the row below records it; correcting the matrix (and the seeds) is follow-up work, tracked with §8.

Where §6 says Code / API uses Why
§6.5 Groups — assign booking to group, move / drop passenger groups.edit groups.edit (UI) but the RPC also accepts booking.transfer / bookings.edit RPC accepts three permissions; the UI gate uses the narrowest
§6.6 Approvals — log communication approvals.view no write permission exists for it Note-only action, gated on the route permission
§6.9 Partners — edit / access key / deactivate agents.edit partners.edit (API), and agents.edit is not in §4 Using agents.edit would fail the drift test
§6.9 Partners — add partner agents.create partners.create (API) Same family split as above
§6.12 Inventory — delete, allocate inventory.delete, inventory.allocate neither is in §4; API uses inventory.edit Gates use inventory.edit
§6.15/§6.16 Admin coarse admin.view / admin.edit granular admin.users.*, admin.currency.*, admin.cities.*, admin.integrations.* where the API enforces them The granular permissions are seeded (migration 20260416050000)
§6.8 Finance — allocate agent receipt finance.payments.record finance.edit on the customer on-account allocate route Gate follows the API

Known gaps (flagged, not fixed on this branch — they need src/lib/api.ts or migrations):

  • POST /operations/bookings/:id/ops-reject has no handler in api.ts, so the Approvals "Reject" button 404s.
  • POST /finance/stock-adjustment has no requirePermission(; §6.8 says it needs finance.edit (UI is gated).
  • DELETE /sales/quotations/:id, POST /requests/:id/attachments, POST /requests/:id/convert/lead|booking and DELETE /requests/attachments/:id have no handler in api.ts.
  • RLS on currencies, exchange_rates and service_cities is still "staff can manage", so the new admin gates on those screens are UI-only until the policies use auth_user_has_permission().
  • Reasons added to DELETE/PATCH request bodies only reach the audit trail once the shared audit context reads body.reason for those routes.