Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ Router: `main/routes/tables.ts`. Full path: `/api/tables`.
| `PUT` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `number`, `name`, `capacity`, `floor`, `section`, `position_x`, `position_y`, `kitchen_station_id` | - |
| `POST` | `/:id/deactivate` | `ROLE_ACCESS.ownerManager` | path: `id` | Soft-deactivates the table rather than deleting it. |
| `POST` | `/:id/reactivate` | `ROLE_ACCESS.ownerManager` | path: `id` | - |
| `POST` | `/:id/move-order` | `ROLE_ACCESS.sales` | path: `id`; body: `target_table_id`, `order_id` | - |
| `POST` | `/:id/move-order` | `ROLE_ACCESS.sales` | path: `id`; body: `target_table_id`, `order_id` | `{ order, sourceTable, targetTable }`. `400` when `target_table_id` is missing, matches `id`, or names an inactive table; `404` when either table or the active order is not found; `409` when the destination table has an active or held order or is `reserved`. |
| `PATCH` | `/:id/status` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `status`, `reservation_customer_id` | Body `status`, plus `reservation_customer_id` for reservation state. |

### Kitchen stations
Expand Down
95 changes: 93 additions & 2 deletions frontend/e2e/table-move-live.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,9 +407,94 @@ test.describe('Table Move UI Live Validation', () => {
// Take screenshot 8: Guard against occupied / inactive tables
await captureScreenshot(page, '08-occupied-table-disabled.png', testInfo);

// Close modal
await guardModal.getByRole('button', { name: 'Cancel' }).first().click();
// Destination changed to reserved after initial modal read must be rejected with 409
const reservedCustomer = { id: 'e2e-customer', name: 'E2E Guest' };

const reserveT1Res = await request.patch(`${BASE}/api/tables/${table1.id}/status`, {
headers: authHeaders,
data: { status: 'reserved', reservation_customer_id: reservedCustomer.id },
});
expect(reserveT1Res.status()).toBe(200);

const directReservedMoveRes = await request.post(
`${BASE}/api/tables/${table2.id}/move-order`,
{
headers: authHeaders,
data: { target_table_id: table1.id },
},
);
expect(directReservedMoveRes.status()).toBe(409);

const candidateT1InGuard = guardModal.getByRole('button', { name: new RegExp(t1Num) });
await expect(candidateT1InGuard).toBeVisible();
await candidateT1InGuard.click();

const confirmReservedMoveBtn = guardModal.getByRole('button', {
name: new RegExp(`Move to ${t1Num}`),
});
const [reservedMoveResponse] = await Promise.all([
page.waitForResponse(
(res) =>
res.url().includes(`/api/tables/${table2.id}/move-order`) &&
res.request().method() === 'POST',
),
confirmReservedMoveBtn.click(),
]);
expect(reservedMoveResponse.status()).toBe(409);
await expect(page.locator('text=Target table is reserved')).toBeVisible({
timeout: 10_000,
});
await expect(
page.locator('text=Destination table already has an active order'),
).not.toBeVisible();
await expect(guardModal).toBeVisible();

const checkTablesReserved = await (
await request.get(`${BASE}/api/tables`, { headers: authHeaders })
).json();
const checkedReservedT1 = checkTablesReserved.tables.find(
(t: {
id: string;
status: string;
reservation_customer_id?: string | null;
reservation_customer_name?: string | null;
}) => t.id === table1.id,
);
const checkedOccupiedT2 = checkTablesReserved.tables.find(
(t: { id: string; status: string; activeOrder?: { id: string } }) => t.id === table2.id,
);
expect(checkedReservedT1.status).toBe('reserved');
expect(checkedReservedT1.reservation_customer_id).toBe(reservedCustomer.id);
expect(checkedReservedT1.reservation_customer_name).toBe(reservedCustomer.name);
expect(checkedOccupiedT2.status).toBe('occupied');
expect(checkedOccupiedT2.activeOrder.id).toBe(order1.id);

// Releasing reservation back to available allows the move to succeed
const releaseT1Res = await request.patch(`${BASE}/api/tables/${table1.id}/status`, {
headers: authHeaders,
data: { status: 'available' },
});
expect(releaseT1Res.status()).toBe(200);

await confirmReservedMoveBtn.click();
await expect(page.locator(`text=Order moved from ${t2Num} to ${t1Num}`)).toBeVisible({
timeout: 10_000,
});
await expect(guardModal).not.toBeVisible();

const checkTablesAfterRelease = await (
await request.get(`${BASE}/api/tables`, { headers: authHeaders })
).json();
expect(
checkTablesAfterRelease.tables.find(
(t: { id: string; status: string }) => t.id === table1.id,
).status,
).toBe('occupied');
expect(
checkTablesAfterRelease.tables.find(
(t: { id: string; status: string }) => t.id === table2.id,
).status,
).toBe('available');
} finally {
// Cleanup created orders and tables
if (order1.id) {
Expand All @@ -424,6 +509,12 @@ test.describe('Table Move UI Live Validation', () => {
}
for (const tbl of [table1, table2, table3, table4]) {
if (tbl.id) {
await request
.patch(`${BASE}/api/tables/${tbl.id}/status`, {
headers: authHeaders,
data: { status: 'available' },
})
.catch(() => {});
await request
.post(`${BASE}/api/tables/${tbl.id}/deactivate`, { headers: authHeaders })
.catch(() => {});
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/components/tables/MoveTableModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,16 @@ export default function MoveTableModal({
onSuccess(data);
onClose();
} catch (error: unknown) {
const status = (error as { response?: { status?: number } })?.response?.status;
const response = (error as { response?: { status?: number; data?: { error?: string } } })
?.response;
const status = response?.status;
const serverError = response?.data?.error;
const errorMsg =
status === 409
? t('targetTableOccupied')
: (error as { response?: { data?: { error?: string } } })?.response?.data?.error ||
t('tableMoveFailed');
? serverError && /reserved|held/i.test(serverError)
? serverError
: t('targetTableOccupied')
: serverError || t('tableMoveFailed');
toast.error(errorMsg);
} finally {
setMoving(false);
Expand Down
18 changes: 17 additions & 1 deletion main/routes/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,11 +577,27 @@ router.post(
const targetHeldOrder = db
.prepare('SELECT id FROM held_orders WHERE table_id = ?')
.get(target_table_id);
if (targetHeldOrder || targetTable.status === 'held') {
const currentTargetTable = db
.prepare('SELECT * FROM tables WHERE id = ?')
.get(target_table_id) as { is_active?: number | boolean; status?: string } | undefined;
if (!currentTargetTable) {
throw Object.assign(new Error('Target table not found'), { status: 404 });
}
if (!currentTargetTable.is_active) {
throw Object.assign(new Error('Target table is not active'), { status: 400 });
}
if (
targetHeldOrder ||
targetTable.status === 'held' ||
currentTargetTable.status === 'held'
) {
const error: any = new Error('Target table has a held order');
error.status = 409;
throw error;
}
if (targetTable.status === 'reserved' || currentTargetTable.status === 'reserved') {
throw Object.assign(new Error('Target table is reserved'), { status: 409 });
Comment thread
khaira777 marked this conversation as resolved.
}

const nowStr = now();
db.prepare('UPDATE orders SET table_id = ?, type = ?, updated_at = ? WHERE id = ?').run(
Expand Down
Loading
Loading