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 withholdsEveryPermission(role, permissions)— a role is shown as super-admin when its saved grants cover every permission row, derived from the/permissions+/permissions/rolespayloads. 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 testsrc/components/admin/PermissionsMatrix.test.tsxproves a role namedDEPUTYis flagged from its grants.UserManagement.tsx—roleLabels/defaultRolesare 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/adminvsagent) 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 onlyonClicks 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.deleteis used for the deactivate control, which is whatPATCH /users/:idrequires. AirportSettingsimport/rename/reset are not gated: the overrides live inlocalStorage(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 behindadmin.view("should tighten") and that Currency/Cities CRUD needsadmin.edit. Code now uses the granularadmin.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.tsALLOWED_DOC_ONLYstill listsadmin.currency.create|edit|delete,admin.cities.create|edit|delete,admin.users.viewetc. 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 …") andservice_citiesdoes not useauth_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.tsrevokeSession/revokeOtherSessions/revokeAllSessionsgained an optional trailingreason?: stringmerged into the edge-function body (those three are used only byActiveSessionsPanel). Thesession-manageredge function currently ignores it. Currency/city writes go straight to Supabase tables with no reason column, so their confirmations aresimple/typedwith 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-rejecthas no handler inapi.ts, so the Approvals "Reject" button 404s.POST /finance/stock-adjustmenthas norequirePermission(; §6.8 says it needsfinance.edit(UI is gated).DELETE /sales/quotations/:id,POST /requests/:id/attachments,POST /requests/:id/convert/lead|bookingandDELETE /requests/attachments/:idhave no handler inapi.ts.- RLS on
currencies,exchange_ratesandservice_citiesis still "staff can manage", so the new admin gates on those screens are UI-only until the policies useauth_user_has_permission(). - Reasons added to DELETE/PATCH request bodies only reach the audit trail once the shared audit context reads
body.reasonfor those routes.