diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md
index fed11d0d4..0b4cb9658 100644
--- a/docs/architecture/authentication-and-authorization.md
+++ b/docs/architecture/authentication-and-authorization.md
@@ -35,7 +35,7 @@ list. `requireRole` (in `main/middleware/security.ts`) still exists, but a stati
(`tests/authorization-static-audit.test.ts`) fails the build if a new call site under
`main/routes/` uses it; the few remaining direct role checks are reviewed context policy, covered
in their own sections below (KDS station/category scope, refund approver identity, the
-override-PIN holder for order-status and item cancel/void overrides, and the
+override-PIN holder for order-status, kitchen-delivery, and item cancel/void overrides, and the
last-active-owner/staff-target rule).
**Resolution.** For every permission id, `resolveEffectivePermissions(userId)` evaluates, in
@@ -128,10 +128,11 @@ a router, in [`main/routes/index.ts`](../../main/routes/index.ts). A static sear
**Gates inside transaction bodies.** The most important case. Some endpoints perform the
permission check inside the `withTxn` callback, after the transaction opens, using
`hasPermission(actorId, id)` against state read in the same transaction.
-`PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, and the matching
-`.../restore` endpoint in `main/routes/orders.ts` do this, and their override-PIN paths
-additionally require the presenting user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a
-deliberate context-policy role check, not a permission, because the override PIN belongs to a
+`PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, the matching
+`.../restore` endpoint in `main/routes/orders.ts`, and the bill payment kitchen-delivery override
+in `main/routes/bills.ts` do this, and their override-PIN paths additionally require the presenting
+user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a deliberate context-policy role check,
+not a permission, because the override PIN belongs to a
person acting in an owner/manager capacity regardless of what their individual permission grants
say. To prevent event loop starvation and transaction lock contention during CPU-intensive bcrypt
evaluations, manager PINs are verified asynchronously via `verifyPinAsync` outside the transaction,
diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md
index 1c491a1bf..ba91dd576 100644
--- a/docs/architecture/frontend.md
+++ b/docs/architecture/frontend.md
@@ -638,7 +638,7 @@ frontend/src/
│ ├── tables/ Floorplan editor, table move modal, table turnover badge
│ ├── ui/ shadcn/ui primitives
│ └── updates/ Update download prompt and install guard dialog
-├── hooks/ Printer, KDS, theme, update, cash, and formatting hooks
+├── hooks/ Printer, KDS, theme, update, cash, approver, and formatting hooks
├── lib/
│ ├── i18n/ Language registry, loader, and the 24 message files
│ ├── printer/ Encoders, document builders, warnings, WebUSB bridge,
diff --git a/docs/architecture/order-lifecycle.md b/docs/architecture/order-lifecycle.md
index 46ecb37cb..d72df15c8 100644
--- a/docs/architecture/order-lifecycle.md
+++ b/docs/architecture/order-lifecycle.md
@@ -138,6 +138,8 @@ same fields:
if either already has an associated customer, a different id is a `400`. Wallet payments require a
customer already associated with the bill or order, with enough points.
- `override_pin`: the manager override described below.
+- `approver_id`: when supplying `override_pin`, the selected manager ID (`manager_id` and `user_id`
+ remain compatibility aliases).
Non-cash lines can never exceed the balance (`400`). Cash is applied up to the amount still owed and
the difference is recorded as change, so an over-tendered bill settles and its ledger line carries
@@ -161,6 +163,8 @@ Two gates sit between a valid request and the write:
- **Kitchen-delivery gate.** When `require_kitchen_delivered_before_settlement` is on, `kds_enabled`
is not `false`, and `billing_type` is not `prepaid`, paying while kitchen items are undelivered
is a `409` with code `KITCHEN_ITEMS_UNDELIVERED` unless a manager PIN is supplied as `override_pin`.
+ When an approver is explicitly supplied (`approver_id`, `manager_id`, or `user_id`), PIN
+ verification is restricted to that active manager without falling back to scanning other managers.
When all kitchen items are delivered, `override_pin` verification is bypassed. The override is
recorded in the order audit log.
diff --git a/docs/reference/api.md b/docs/reference/api.md
index f1b829443..15807b4f1 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -396,8 +396,8 @@ Bill generation, settlement, and completion are described end to end in
| `GET` | `/order/:orderId` | `ROLE_ACCESS.ownerManagerCashier` | path: `orderId` | - |
| `POST` | `/generate` | `ROLE_ACCESS.ownerManagerCashier` | body: `order_id` | See [Order lifecycle](../architecture/order-lifecycle.md#bills) for bill creation and re-sync behavior. |
| `POST` | `/:id/split-check` | `ROLE_ACCESS.ownerManagerCashier` | body: `checks` | Body `checks` must hold between 2 and 20 entries and the resulting split group is capped at 20 checks. An unpaid untouched dine-in check is divided by allocating every active order item quantity; a split-group child may be divided again while it stays unpaid with no payment recorded, keeping its bill id, bill number and group, while paid siblings keep their own rows and payments. `400` when a request does not allocate exactly the source's own quantities. `403` when `split_checks_enabled` is off. `409` when the source has a recorded payment, a legacy already-split bill is re-split, the source has no divisible allocation, or the resulting split group would exceed 20 checks. |
-| `POST` | `/:id/payment` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: payment line object, `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. |
-| `POST` | `/:id/payments` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `payments`, `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. |
+| `POST` | `/:id/payment` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: payment line object (accepts optional `override_pin`, `approver_id`, `manager_id`, `user_id`), `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. |
+| `POST` | `/:id/payments` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `payments`, `customer_id`, optional `override_pin`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. |
| `POST` | `/:id/applyDiscount` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `type`, `value`, `reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the bill is paid (`paid`). `409` when the order is completed or cancelled (`order_not_discountable`), the bill is split (`split_check`) or refunded (`refunded`), or the discount would reduce the bill total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`. |
| `PATCH` | `/:id/charges` | `bills.discount.apply` | path: `id`; body: `charge_id`, `waived` or `applied` | `{ bill }` with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. `409` when the order is cancelled. |
| `POST` | `/:id/markPrinted` | `bills.print` (`ROLE_ACCESS.ownerManager`) | path: `id` | Stamps `bills.printed_at`. |
diff --git a/frontend/e2e/approval-manager-selection.spec.ts b/frontend/e2e/approval-manager-selection.spec.ts
new file mode 100644
index 000000000..e9b006823
--- /dev/null
+++ b/frontend/e2e/approval-manager-selection.spec.ts
@@ -0,0 +1,420 @@
+import { test, expect } from '@playwright/test';
+import * as path from 'path';
+import * as fs from 'fs';
+import * as os from 'os';
+import { E2E_BASE_URL as BASE } from './helpers/urls';
+import {
+ setLanguage,
+ readOrdersLayout,
+ setOrdersLayout,
+ type E2EOrdersLayout,
+ getE2eToken,
+} from './helpers/test-auth';
+import { captureSettings, restoreSettings } from './helpers/kitchen-fixture';
+
+const EVIDENCE_DIR =
+ process.env.EVIDENCE_DIR || path.join(os.tmpdir(), 'flocafe-approval-manager-selection-evidence');
+
+const GATE_SETTINGS = [
+ 'billing_type',
+ 'kds_enabled',
+ 'require_kitchen_delivered_before_settlement',
+];
+
+test.describe('Explicit Manager Selection for Approvals and Kitchen Override', () => {
+ let ownerToken: string;
+ let headers: Record;
+ let managerAliceId: string;
+ let managerBobId: string;
+ let settingsBefore: Map;
+ let ordersLayoutBefore: E2EOrdersLayout = 'split';
+
+ test.beforeAll(async ({ request }) => {
+ if (!fs.existsSync(EVIDENCE_DIR)) {
+ try {
+ fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
+ } catch {
+ // Fall back gracefully
+ }
+ }
+ settingsBefore = await captureSettings(request, GATE_SETTINGS);
+ });
+
+ test.afterEach(async ({ page, request }) => {
+ try {
+ await setOrdersLayout(page, ordersLayoutBefore, BASE, ownerToken);
+ } catch {
+ // Ignore layout restoration error if page closed
+ }
+ if (settingsBefore) {
+ await restoreSettings(request, settingsBefore);
+ }
+ });
+
+ test.afterAll(async ({ request }) => {
+ if (settingsBefore) {
+ await restoreSettings(request, settingsBefore);
+ }
+ const staffToRestore = [
+ { id: 'e2e-owner', pin: '1111' },
+ { id: 'e2e-manager', pin: '2222' },
+ ];
+ for (const { id, pin } of staffToRestore) {
+ try {
+ await request.put(`${BASE}/api/staff/${id}`, {
+ headers: {
+ Authorization: `Bearer ${getE2eToken('e2e-owner', 'owner@flo.local', 'owner')}`,
+ 'Content-Type': 'application/json',
+ },
+ data: { pin, is_active: 1 },
+ });
+ } catch {
+ // Ignore if staff not found
+ }
+ }
+ });
+
+ test.beforeEach(async ({ page }) => {
+ ownerToken = getE2eToken('e2e-owner', 'owner@flo.local', 'owner');
+ headers = { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' };
+ ordersLayoutBefore = await readOrdersLayout(page, BASE, ownerToken);
+
+ // Login via UI
+ await page.goto(`${BASE}/auth/login`);
+ await page.locator('#email').fill('owner@flo.local');
+ await page.locator('#password').fill('E2ePass123!');
+ await page.locator('button[type="submit"]').click();
+ await setLanguage(page, 'en');
+ await setOrdersLayout(page, 'cards', BASE, ownerToken);
+ });
+
+ test('Scenarios 1 & 2: Kitchen Delivery Override binds manager selection and rejects mismatch', async ({
+ page,
+ }) => {
+ // 1. Configure settings
+ await page.request.put(`${BASE}/api/settings/billing_type`, {
+ headers,
+ data: { value: 'postpaid' },
+ });
+ await page.request.put(`${BASE}/api/settings/kds_enabled`, {
+ headers,
+ data: { value: 'true' },
+ });
+ await page.request.put(`${BASE}/api/settings/require_kitchen_delivered_before_settlement`, {
+ headers,
+ data: { value: 'true' },
+ });
+
+ // 2. Ensure two active managers with distinct PINs exist
+ const staffListRes = await page.request.get(`${BASE}/api/staff?active=true`, { headers });
+ expect(staffListRes.ok()).toBeTruthy();
+ const { staff } = await staffListRes.json();
+
+ const existingAlice = staff.find(
+ (s: { email?: string; id?: string }) => s.email === 'alice.mgr@flo.local',
+ );
+ if (!existingAlice) {
+ const res = await page.request.post(`${BASE}/api/staff`, {
+ headers,
+ data: {
+ name: 'Manager Alice',
+ email: 'alice.mgr@flo.local',
+ password: 'Password123!',
+ role: 'manager',
+ pin: '1111',
+ },
+ });
+ expect(res.ok()).toBeTruthy();
+ const body = await res.json();
+ managerAliceId = body.staff.id;
+ } else {
+ managerAliceId = existingAlice.id;
+ await page.request.put(`${BASE}/api/staff/${managerAliceId}`, {
+ headers,
+ data: { pin: '1111', is_active: 1 },
+ });
+ }
+
+ const existingBob = staff.find(
+ (s: { email?: string; id?: string }) => s.email === 'bob.mgr@flo.local',
+ );
+ if (!existingBob) {
+ const res = await page.request.post(`${BASE}/api/staff`, {
+ headers,
+ data: {
+ name: 'Manager Bob',
+ email: 'bob.mgr@flo.local',
+ password: 'Password123!',
+ role: 'manager',
+ pin: '2222',
+ },
+ });
+ expect(res.ok()).toBeTruthy();
+ const body = await res.json();
+ managerBobId = body.staff.id;
+ } else {
+ managerBobId = existingBob.id;
+ await page.request.put(`${BASE}/api/staff/${managerBobId}`, {
+ headers,
+ data: { pin: '2222', is_active: 1 },
+ });
+ }
+
+ // 3. Create an order with an undelivered item
+ const orderRes = await page.request.post(`${BASE}/api/orders`, {
+ headers,
+ data: {
+ type: 'takeaway',
+ items: [{ product_id: 'e2e-product', quantity: 1 }],
+ },
+ });
+ expect(orderRes.status()).toBe(201);
+ const { order } = await orderRes.json();
+
+ // 4. Open Orders page and launch Checkout for this order
+ await page.goto(`${BASE}/orders`);
+ await page
+ .getByPlaceholder(/search/i)
+ .first()
+ .fill(order.order_number);
+ await expect(page.getByText(`#${order.order_number}`)).toBeVisible();
+ await page.getByRole('button', { name: 'Checkout', exact: true }).click();
+ await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();
+
+ // Select Cash tender
+ await page.getByRole('button', { name: 'Cash', exact: true }).click();
+ const payButton = page.getByRole('button', { name: /^Pay / });
+
+ // Initial click fails with 409 because items are undelivered
+ await payButton.click();
+
+ // ApproverSelect dropdown and Manager PIN input should appear
+ const approverSelect = page.locator('#kitchenApproverSelect');
+ await expect(approverSelect).toBeVisible();
+ const pinInput = page.getByLabel('Manager PIN');
+ await expect(pinInput).toBeVisible();
+
+ // Verify both managers are in the dropdown options
+ const options = await approverSelect.locator('option').allTextContents();
+ expect(options.some((o) => o.includes('Manager Alice'))).toBeTruthy();
+ expect(options.some((o) => o.includes('Manager Bob'))).toBeTruthy();
+
+ // --- Scenario 2 (Adversarial): Mismatched manager selection and PIN ---
+ await approverSelect.selectOption(managerAliceId);
+ // Enter Bob's PIN (2222) while Alice is selected
+ await pinInput.fill('2222');
+
+ const paymentResponsePromise = page.waitForResponse(
+ (res) => res.url().includes('/payments') && res.request().method() === 'POST',
+ );
+ await payButton.click();
+ const rejectedResponse = await paymentResponsePromise;
+ expect(rejectedResponse.status()).toBe(403);
+ const rejectedBody = await rejectedResponse.json();
+ expect(rejectedBody.error).toBe('Invalid manager PIN');
+
+ // Error alert should be visible in the modal
+ await expect(page.getByText('Invalid manager PIN')).toBeVisible();
+ await page.screenshot({
+ path: path.join(EVIDENCE_DIR, 'scenario-2-mismatched-manager-pin-rejected.png'),
+ });
+
+ // --- Scenario 1: Correct manager selection and matching PIN ---
+ await approverSelect.selectOption(managerBobId);
+ // Selecting another approver resets the PIN input
+ await expect(pinInput).toHaveValue('');
+ await pinInput.fill('2222');
+
+ await page.screenshot({
+ path: path.join(EVIDENCE_DIR, 'scenario-1-kitchen-override-select-manager.png'),
+ });
+
+ const successResponsePromise = page.waitForResponse(
+ (res) => res.url().includes('/payments') && res.request().method() === 'POST',
+ );
+ await payButton.click();
+ const successResponse = await successResponsePromise;
+ expect(successResponse.status()).toBe(200);
+
+ // Modal should show Done / successful payment
+ await expect(page.getByRole('button', { name: 'Done' })).toBeVisible();
+ await page.screenshot({
+ path: path.join(EVIDENCE_DIR, 'scenario-1-payment-settled-success.png'),
+ });
+
+ // Verify audit log has kitchen_delivery_override with Bob's manager ID
+ const auditRes = await page.request.get(`${BASE}/api/orders/${order.id}/audit-log`, {
+ headers,
+ });
+ if (auditRes.ok()) {
+ const audit = await auditRes.json();
+ const overrideLog = audit.logs?.find(
+ (l: { action?: string; details?: unknown }) => l.action === 'kitchen_delivery_override',
+ );
+ if (overrideLog) {
+ const details =
+ typeof overrideLog.details === 'string'
+ ? JSON.parse(overrideLog.details)
+ : overrideLog.details;
+ expect(details.manager_user_id).toBe(managerBobId);
+ }
+ }
+ });
+
+ test('Scenario 3: Orders page Void Item & Cancel Order modals enforce manager selection', async ({
+ page,
+ }) => {
+ // 1. Ensure managers exist with PINs
+ const staffListRes = await page.request.get(`${BASE}/api/staff?active=true`, { headers });
+ const { staff } = await staffListRes.json();
+ const activeManagers = staff.filter(
+ (s: { role?: string; has_pin?: boolean }) =>
+ ['owner', 'manager'].includes(s.role || '') && s.has_pin,
+ );
+ if (activeManagers.length < 2) {
+ await page.request.post(`${BASE}/api/staff`, {
+ headers,
+ data: {
+ name: 'Manager Alice',
+ email: 'alice.mgr@flo.local',
+ password: 'Password123!',
+ role: 'manager',
+ pin: '1111',
+ },
+ });
+ await page.request.post(`${BASE}/api/staff`, {
+ headers,
+ data: {
+ name: 'Manager Bob',
+ email: 'bob.mgr@flo.local',
+ password: 'Password123!',
+ role: 'manager',
+ pin: '2222',
+ },
+ });
+ }
+
+ // 2. Create a takeaway order with item in preparing status
+ const orderRes = await page.request.post(`${BASE}/api/orders`, {
+ headers,
+ data: {
+ type: 'takeaway',
+ items: [{ product_id: 'e2e-product', quantity: 2 }],
+ },
+ });
+ expect(orderRes.status()).toBe(201);
+ const { order } = await orderRes.json();
+
+ // Mark item as preparing so Void Item button is displayed
+ const detailRes = await page.request.get(`${BASE}/api/orders/${order.id}`, { headers });
+ const detail = await detailRes.json();
+ const itemId = detail.order.items[0].id;
+ const patchRes = await page.request.patch(`${BASE}/api/order-items/${itemId}/status`, {
+ headers,
+ data: { status: 'preparing' },
+ });
+ expect(patchRes.ok()).toBeTruthy();
+
+ await page.goto(`${BASE}/orders`);
+ await page
+ .getByPlaceholder(/search/i)
+ .first()
+ .fill(order.order_number);
+ const card = page
+ .locator('div.bg-card')
+ .filter({ hasText: `#${order.order_number}` })
+ .first();
+ await expect(card).toBeVisible();
+
+ // 3. Open Void Item modal
+ const voidBtn = card.locator('button[title="Void item"]').first();
+ await expect(voidBtn).toBeVisible();
+ await voidBtn.click();
+
+ const voidApprover = page.locator('#voidApproverSelect');
+ await expect(voidApprover).toBeVisible();
+ const confirmVoidBtn = page.getByRole('button', { name: 'Confirm Void', exact: true });
+ // Button disabled when no approver chosen
+ await expect(confirmVoidBtn).toBeDisabled();
+
+ await page.screenshot({
+ path: path.join(EVIDENCE_DIR, 'scenario-3-orders-void-item-approver-select.png'),
+ });
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+
+ // 4. Open Cancel Order modal from Card action dropdown
+ const moreBtn = card
+ .locator('button:has(svg.lucide-ellipsis, svg.lucide-more-horizontal)')
+ .first();
+ await moreBtn.click();
+
+ const cancelMenuItem = page.getByRole('menuitem', { name: /cancel/i });
+ await expect(cancelMenuItem).toBeVisible();
+ await cancelMenuItem.click();
+
+ // Cancel modal for orders with in-progress items requires PIN override
+ const cancelApprover = page.locator('#cancelApproverSelect');
+ await expect(cancelApprover).toBeVisible();
+ const confirmCancelBtn = page.getByRole('button', { name: 'Confirm Cancel', exact: true });
+ await expect(confirmCancelBtn).toBeDisabled();
+
+ await page.screenshot({
+ path: path.join(EVIDENCE_DIR, 'scenario-3-orders-cancel-order-approver-select.png'),
+ });
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+ });
+
+ test('Scenario 4: ApproverSelect displays graceful warning when no managers have PINs', async ({
+ page,
+ }) => {
+ // Mock staff route to return empty staff list so no manager approvers exist with PINs
+ await page.route('**/api/staff?active=true', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ staff: [] }),
+ });
+ });
+
+ // Create an order and mark item as preparing
+ const orderRes = await page.request.post(`${BASE}/api/orders`, {
+ headers,
+ data: {
+ type: 'takeaway',
+ items: [{ product_id: 'e2e-product', quantity: 1 }],
+ },
+ });
+ const { order } = await orderRes.json();
+
+ const detailRes = await page.request.get(`${BASE}/api/orders/${order.id}`, { headers });
+ const detail = await detailRes.json();
+ const itemId = detail.order.items[0].id;
+ await page.request.patch(`${BASE}/api/order-items/${itemId}/status`, {
+ headers,
+ data: { status: 'preparing' },
+ });
+
+ await page.goto(`${BASE}/orders`);
+ await page
+ .getByPlaceholder(/search/i)
+ .first()
+ .fill(order.order_number);
+ const card = page
+ .locator('div.bg-card')
+ .filter({ hasText: `#${order.order_number}` })
+ .first();
+ await expect(card).toBeVisible();
+
+ const voidBtn = card.locator('button[title="Void item"]').first();
+ await expect(voidBtn).toBeVisible();
+ await voidBtn.click();
+
+ const emptyAlert = page.getByText('Approval is not configured');
+ await expect(emptyAlert).toBeVisible();
+
+ await page.screenshot({
+ path: path.join(EVIDENCE_DIR, 'scenario-4-approver-select-empty-state.png'),
+ });
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click();
+ });
+});
diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx
index 96a5d37f4..bceb8e4c7 100644
--- a/frontend/src/app/(dashboard)/orders/page.tsx
+++ b/frontend/src/app/(dashboard)/orders/page.tsx
@@ -61,6 +61,8 @@ import { OrdersMasterList } from '@/components/orders/OrdersMasterList';
import { OrderDetailPanel } from '@/components/orders/OrderDetailPanel';
import EditOrderItemModal from '@/components/orders/EditOrderItemModal';
import { useOrdersLayoutPreference } from '@/hooks/useOrdersLayout';
+import { useManagerApprovers } from '@/hooks/useManagerApprovers';
+import { ApproverSelect } from '@/components/ApproverSelect';
type OrdersKey = keyof AppConfig['Messages']['orders'];
@@ -88,6 +90,7 @@ interface CancelModal {
freeTable: boolean;
overridePin: string;
requiresPin?: boolean;
+ approverId?: string | null;
}
function orderHasCollectedPayment(order: Order): boolean {
@@ -124,6 +127,7 @@ interface VoidItemModal {
itemId: number;
productName: string;
overridePin: string;
+ approverId?: string | null;
}
interface DiscountModal {
@@ -222,6 +226,10 @@ export default function OrdersPage() {
const [discountMode, setDiscountMode] = useState('percentage');
const [discountRequiresApproval, setDiscountRequiresApproval] = useState(false);
const [discountPin, setDiscountPin] = useState('');
+ const [discountApproverId, setDiscountApproverId] = useState(null);
+
+ // Manager approver list for privileged write approvals
+ const managerApprovers = useManagerApprovers();
// Print states
const [generatingBill, setGeneratingBill] = useState(null);
@@ -321,6 +329,7 @@ export default function OrdersPage() {
if (discountMode === 'none') {
setDiscountModal(null);
setDiscountPin('');
+ setDiscountApproverId(null);
}
}
@@ -1146,11 +1155,13 @@ export default function OrdersPage() {
const handleVoidItem = async () => {
if (!voidItemModal) return;
+ const effectiveApproverId = voidItemModal.approverId ?? managerApprovers.selectedApproverId;
setVoidingItem(true);
try {
await api.patch(`/orders/${voidItemModal.orderId}/items/${voidItemModal.itemId}/cancel`, {
reason: tOrders('removedByManager'),
override_pin: voidItemModal.overridePin || undefined,
+ approver_id: effectiveApproverId || undefined,
});
toast.success(tOrders('itemVoided'));
setVoidItemModal(null);
@@ -1224,6 +1235,8 @@ export default function OrdersPage() {
return;
}
+ const effectiveApproverId = discountApproverId ?? managerApprovers.selectedApproverId;
+
const operationId = createOperationReference();
try {
await api.patch(
@@ -1234,6 +1247,10 @@ export default function OrdersPage() {
discount_reason: discountModal.reason || undefined,
override_pin:
discountRequiresApproval && normalizedDiscountValue > 0 ? discountPin : undefined,
+ approver_id:
+ discountRequiresApproval && normalizedDiscountValue > 0
+ ? effectiveApproverId || undefined
+ : undefined,
},
{ headers: operationHeaders(operationId) },
);
@@ -1267,6 +1284,7 @@ export default function OrdersPage() {
} finally {
setDiscountModal(null);
setDiscountPin('');
+ setDiscountApproverId(null);
}
};
@@ -1469,6 +1487,9 @@ export default function OrdersPage() {
const handleCancelOrder = async () => {
if (!cancelModal) return;
+ const isPinRequired = cancelModal.requiresPin || orderRequiresCancelPin(cancelModal.order);
+ const effectiveApproverId = cancelModal.approverId ?? managerApprovers.selectedApproverId;
+
setCancellingOrderId(cancelModal.order.id);
try {
await api.patch(`/orders/${cancelModal.order.id}/status`, {
@@ -1476,6 +1497,10 @@ export default function OrdersPage() {
reason: cancelModal.reason || undefined,
free_table: cancelModal.freeTable,
override_pin: cancelModal.overridePin || undefined,
+ approver_id:
+ isPinRequired && cancelModal.overridePin && effectiveApproverId
+ ? effectiveApproverId
+ : undefined,
});
toast.success(tOrders('orderCancelled'));
setCancelModal(null);
@@ -1779,7 +1804,13 @@ export default function OrdersPage() {
onRefund={(ord, bills) => setRefundModal({ order: ord, bills })}
onConvertToTakeaway={handleConvertToTakeaway}
onCancelOrder={(ord) =>
- setCancelModal({ order: ord, reason: '', freeTable: true, overridePin: '' })
+ setCancelModal({
+ order: ord,
+ reason: '',
+ freeTable: true,
+ overridePin: '',
+ approverId: null,
+ })
}
onPrint={(billId) => setConfirmPrintBillId(billId)}
onPrintOrder={
@@ -1810,7 +1841,13 @@ export default function OrdersPage() {
onDownloadPrintPreview={handleDownloadPrintPreview}
onDeleteItem={deleteItem}
onVoidItem={(orderId, itemId, productName) =>
- setVoidItemModal({ orderId, itemId, productName, overridePin: '' })
+ setVoidItemModal({
+ orderId,
+ itemId,
+ productName,
+ overridePin: '',
+ approverId: null,
+ })
}
onRestoreItem={restoreItem}
onItemEditResolved={handleItemEditResolved}
@@ -1847,7 +1884,13 @@ export default function OrdersPage() {
onRefund={(ord, bills) => setRefundModal({ order: ord, bills })}
onConvertToTakeaway={handleConvertToTakeaway}
onCancelOrder={(ord) =>
- setCancelModal({ order: ord, reason: '', freeTable: true, overridePin: '' })
+ setCancelModal({
+ order: ord,
+ reason: '',
+ freeTable: true,
+ overridePin: '',
+ approverId: null,
+ })
}
onPrint={(billId) => setConfirmPrintBillId(billId)}
onPrintOrder={
@@ -1877,7 +1920,13 @@ export default function OrdersPage() {
onDownloadPrintPreview={handleDownloadPrintPreview}
onDeleteItem={deleteItem}
onVoidItem={(orderId, itemId, productName) =>
- setVoidItemModal({ orderId, itemId, productName, overridePin: '' })
+ setVoidItemModal({
+ orderId,
+ itemId,
+ productName,
+ overridePin: '',
+ approverId: null,
+ })
}
onRestoreItem={restoreItem}
onItemEditResolved={handleItemEditResolved}
@@ -2018,21 +2067,36 @@ export default function OrdersPage() {
)}
{(cancelModal.requiresPin || orderRequiresCancelPin(cancelModal.order)) && (
-
-
-
updateCancelModal({ overridePin: e.target.value })}
- placeholder={tOrders('managerPin')}
- className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
+
)}
@@ -2044,7 +2108,13 @@ export default function OrdersPage() {
-
-
-
- setVoidItemModal({ ...voidItemModal, overridePin: e.target.value })
+
@@ -2102,7 +2185,12 @@ export default function OrdersPage() {
{discountRequiresApproval && discountModal.value > 0 && (
-
-
-
setDiscountPin(e.target.value)}
- placeholder={tOrders('managerPin')}
- maxLength={6}
- className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
+
)}
-