feat(waiter-floor-web): deepen floor workflow experience

This commit is contained in:
José René White Enciso 2026-03-31 16:50:12 -06:00
parent e019a069dd
commit a0dc0a29ed
8 changed files with 411 additions and 72 deletions

View File

@ -5,6 +5,7 @@
- The UI does not access DAL or internal services directly.
- Route shell uses Ant Design layout/menu and keeps business views behind session checks.
- Unauthenticated users are redirected to the central auth host OIDC start endpoint.
- Session-expired responses are treated as an auth boundary concern and trigger revalidation before the UI prompts for login again.
## Runtime Base URLs
@ -16,10 +17,14 @@
## Protected Workflow Endpoints
- `GET /api/waiter/floor/assignments?contextId=...`
- `GET /api/waiter/floor/activity?contextId=...`
- `POST /api/waiter/floor/orders`
- `PUT /api/waiter/floor/orders/{orderId}`
## UI Workflow Coverage
- Waiter assignment lookup
- Floor order submission
- Protected route shell for assignments, order submission, and session inspection
- Waiter assignment snapshot with location metadata and active-order counts
- Recent waiter activity history feed
- Floor order submission and order update workflows
- Session-expired handling with reauthentication guidance
- Protected route shell for assignments, order actions, and session inspection

View File

@ -21,6 +21,13 @@ npm run dev
- Login is executed via central Thalos OIDC start endpoint.
- Business calls are gated behind session checks.
- Session cookies are sent with `credentials: include`.
- Workflow calls that return `401` trigger session revalidation and then guide the user back to central login.
## Available Screens
- `/assignments`: waiter assignment snapshot and recent activity feed
- `/orders`: floor order submit and update actions
- `/session`: current Thalos session profile payload
## Build

View File

@ -15,10 +15,11 @@ npm run test:ci
## Coverage Scope
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
- `src/api/dashboardApi.test.ts`: endpoint path/query composition, activity loading, and order update payload mapping.
- `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
- `src/App.test.tsx`: central login screen, protected assignment flow, and order submission workflow.
- `src/App.test.tsx`: central login screen, assignment and activity loading, order submit/update workflows, and session-expired reauthentication guidance.
## Notes
- Use containerized Node execution when host `npm` is unavailable.
- Prefer container-first validation before opening or updating runtime stack images.

View File

@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from './api/client';
vi.mock('./api/sessionApi', () => ({
getSessionMe: vi.fn(),
@ -10,17 +11,21 @@ vi.mock('./api/sessionApi', () => ({
vi.mock('./api/dashboardApi', () => ({
loadDashboard: vi.fn(),
submitFloorOrder: vi.fn()
loadRecentActivity: vi.fn(),
submitFloorOrder: vi.fn(),
updateFloorOrder: vi.fn()
}));
import { loadDashboard, submitFloorOrder } from './api/dashboardApi';
import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './api/dashboardApi';
import { getSessionMe } from './api/sessionApi';
import App from './App';
describe('Waiter Floor App', () => {
beforeEach(() => {
vi.mocked(loadDashboard).mockReset();
vi.mocked(loadRecentActivity).mockReset();
vi.mocked(submitFloorOrder).mockReset();
vi.mocked(updateFloorOrder).mockReset();
vi.mocked(getSessionMe).mockReset();
window.__APP_CONFIG__ = {
API_BASE_URL: 'http://localhost:8080',
@ -42,14 +47,26 @@ describe('Waiter Floor App', () => {
expect(link.href).toContain('tenantId=demo-tenant');
});
it('loads assignments for authenticated users', async () => {
it('loads assignments and recent activity for authenticated users', async () => {
vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 0
provider: 2
});
vi.mocked(loadDashboard).mockResolvedValue({
contextId: 'demo-context',
locationId: 'floor-a',
summary: '2 waiters assigned',
assignments: [{ waiterId: 'w-1', tableId: 'T-12', status: 'Assigned', activeOrders: 3 }],
recentActivity: ['legacy assignment feed']
});
vi.mocked(loadRecentActivity).mockResolvedValue({
contextId: 'demo-context',
locationId: 'floor-a',
summary: 'activity',
recentActivity: ['Waiter w-1 picked up table T-12']
});
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'assigned' });
render(<App />);
@ -57,24 +74,74 @@ describe('Waiter Floor App', () => {
fireEvent.click(screen.getByRole('button', { name: 'Load Assignments' }));
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
expect(loadRecentActivity).toHaveBeenCalledWith('demo-context');
expect(await screen.findByText('floor-a')).toBeInTheDocument();
expect(await screen.findByText('Waiter w-1 picked up table T-12')).toBeInTheDocument();
});
it('submits floor orders from action route', async () => {
it('submits and updates floor orders from the order route', async () => {
vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 0
provider: 2
});
vi.mocked(submitFloorOrder).mockResolvedValue({
contextId: 'demo-context',
orderId: 'ORD-2200',
accepted: true,
summary: 'submitted',
status: 'Queued',
processedAtUtc: '2026-03-31T12:00:00Z'
});
vi.mocked(updateFloorOrder).mockResolvedValue({
contextId: 'demo-context',
orderId: 'ORD-2200',
accepted: true,
summary: 'updated',
status: 'Updated',
processedAtUtc: '2026-03-31T12:05:00Z'
});
vi.mocked(submitFloorOrder).mockResolvedValue({ orderId: 'ORD-1001', accepted: true, message: 'ok' });
render(<App />);
await waitFor(() => expect(screen.getByText('Submit Order')).toBeInTheDocument());
fireEvent.click(screen.getByText('Submit Order'));
fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } });
await waitFor(() => expect(screen.getByText('Order Actions')).toBeInTheDocument());
fireEvent.click(screen.getByText('Order Actions'));
fireEvent.change(screen.getAllByPlaceholderText('Order Id')[0], { target: { value: 'ORD-2200' } });
fireEvent.click(screen.getByRole('button', { name: 'Submit Floor Order' }));
await waitFor(() => expect(submitFloorOrder).toHaveBeenCalledTimes(1));
expect((await screen.findAllByText('Queued')).length).toBeGreaterThan(0);
fireEvent.change(screen.getAllByPlaceholderText('Order Id')[1], { target: { value: 'ORD-2200' } });
fireEvent.click(screen.getByRole('button', { name: 'Update Floor Order' }));
await waitFor(() => expect(updateFloorOrder).toHaveBeenCalledTimes(1));
expect((await screen.findAllByText('Updated')).length).toBeGreaterThan(0);
});
it('shows reauthentication guidance when the workflow returns session expired', async () => {
vi.mocked(getSessionMe)
.mockResolvedValueOnce({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 2
})
.mockResolvedValueOnce({
isAuthenticated: false,
subjectId: '',
tenantId: '',
provider: 2
});
vi.mocked(loadDashboard).mockRejectedValue(new ApiError(401, 'No active session.', 'session_missing', 'corr-1'));
render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Load Assignments' })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Load Assignments' }));
await waitFor(() => expect(screen.getByText('Authentication Required')).toBeInTheDocument());
expect(screen.getByText('Session expired')).toBeInTheDocument();
});
});

View File

@ -1,26 +1,76 @@
import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Layout, Menu, Result, Space, Spin, Table, Tag, Typography } from 'antd';
import {
ClockCircleOutlined,
DeploymentUnitOutlined,
OrderedListOutlined,
ReloadOutlined,
ShoppingCartOutlined,
SyncOutlined
} from '@ant-design/icons';
import {
Alert,
Button,
Card,
Descriptions,
Empty,
Form,
Input,
InputNumber,
Layout,
List,
Menu,
Result,
Space,
Spin,
Table,
Tag,
Typography
} from 'antd';
import { type ReactNode, useMemo, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { ApiError } from './api/client';
import type { IdentityProvider } from './api/sessionApi';
import { SessionProvider, useSessionContext } from './auth/sessionContext';
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
import {
loadDashboard,
loadRecentActivity,
submitFloorOrder,
updateFloorOrder,
type SubmitFloorOrderRequest,
type SubmitFloorOrderResponse,
type WaiterAssignmentsResponse
type UpdateFloorOrderRequest,
type UpdateFloorOrderResponse,
type WaiterAssignmentsResponse,
type WaiterRecentActivityResponse
} from './api/dashboardApi';
type AppRoute = '/assignments' | '/orders' | '/session';
type OrderEvent =
| { kind: 'submitted'; response: SubmitFloorOrderResponse }
| { kind: 'updated'; response: UpdateFloorOrderResponse };
type WorkflowState = {
error: string | null;
sessionExpired: boolean;
};
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
{ key: '/assignments', label: 'Assignments', icon: <OrderedListOutlined /> },
{ key: '/orders', label: 'Submit Order', icon: <ShoppingCartOutlined /> },
{ key: '/orders', label: 'Order Actions', icon: <ShoppingCartOutlined /> },
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
];
const assignmentColumns = [
{ title: 'Waiter Id', dataIndex: 'waiterId' },
{ title: 'Table Id', dataIndex: 'tableId' },
{
title: 'Status',
dataIndex: 'status',
render: (value: string) => <Tag color={value === 'Assigned' ? 'blue' : 'gold'}>{value}</Tag>
},
{ title: 'Active Orders', dataIndex: 'activeOrders' }
];
function App() {
return (
<SessionProvider>
@ -38,11 +88,13 @@ function WaiterFloorShell() {
const [contextId, setContextId] = useState('demo-context');
const [assignments, setAssignments] = useState<WaiterAssignmentsResponse | null>(null);
const [orderResponse, setOrderResponse] = useState<SubmitFloorOrderResponse | null>(null);
const [orderHistory, setOrderHistory] = useState<SubmitFloorOrderResponse[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const [recentActivity, setRecentActivity] = useState<WaiterRecentActivityResponse | null>(null);
const [lastOrderResponse, setLastOrderResponse] = useState<SubmitFloorOrderResponse | UpdateFloorOrderResponse | null>(null);
const [orderHistory, setOrderHistory] = useState<OrderEvent[]>([]);
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
const [loadingAssignments, setLoadingAssignments] = useState(false);
const [submittingOrder, setSubmittingOrder] = useState(false);
const [updatingOrder, setUpdatingOrder] = useState(false);
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
const selectedKey = useMemo(() => {
@ -50,14 +102,33 @@ function WaiterFloorShell() {
return candidate?.key ?? '/assignments';
}, [location.pathname]);
const clearWorkflowError = () => setWorkflowState({ error: null, sessionExpired: false });
const handleWorkflowFailure = async (err: unknown, fallbackMessage: string) => {
if (err instanceof ApiError && err.status === 401) {
setWorkflowState({ error: 'Your session expired. Sign in again to continue floor operations.', sessionExpired: true });
await session.revalidate();
return;
}
setWorkflowState({
error: err instanceof Error ? err.message : fallbackMessage,
sessionExpired: false
});
};
const loadAssignments = async () => {
setLoadingAssignments(true);
setGlobalError(null);
clearWorkflowError();
try {
const payload = await loadDashboard(contextId);
setAssignments(payload);
const [assignmentsPayload, activityPayload] = await Promise.all([
loadDashboard(contextId),
loadRecentActivity(contextId)
]);
setAssignments(assignmentsPayload);
setRecentActivity(activityPayload);
} catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to load waiter assignments.');
await handleWorkflowFailure(err, 'Failed to load waiter assignments.');
} finally {
setLoadingAssignments(false);
}
@ -65,18 +136,32 @@ function WaiterFloorShell() {
const submitOrder = async (request: SubmitFloorOrderRequest) => {
setSubmittingOrder(true);
setGlobalError(null);
clearWorkflowError();
try {
const payload = await submitFloorOrder(request);
setOrderResponse(payload);
setOrderHistory((previous) => [payload, ...previous].slice(0, 8));
setLastOrderResponse(payload);
setOrderHistory((previous) => [{ kind: 'submitted' as const, response: payload }, ...previous].slice(0, 8));
} catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to submit floor order.');
await handleWorkflowFailure(err, 'Failed to submit floor order.');
} finally {
setSubmittingOrder(false);
}
};
const reviseOrder = async (request: UpdateFloorOrderRequest) => {
setUpdatingOrder(true);
clearWorkflowError();
try {
const payload = await updateFloorOrder(request);
setLastOrderResponse(payload);
setOrderHistory((previous) => [{ kind: 'updated' as const, response: payload }, ...previous].slice(0, 8));
} catch (err) {
await handleWorkflowFailure(err, 'Failed to update floor order.');
} finally {
setUpdatingOrder(false);
}
};
if (session.status === 'loading') {
return (
<div className="fullscreen-center">
@ -99,6 +184,15 @@ function WaiterFloorShell() {
}
/>
{session.error && <Alert type="error" showIcon message={session.error} />}
{workflowState.sessionExpired && (
<Alert
className="stack-gap"
type="warning"
showIcon
message="Session expired"
description="Your last workflow action returned 401, so the app is asking you to sign in again."
/>
)}
</main>
);
}
@ -133,16 +227,31 @@ function WaiterFloorShell() {
<Layout.Content className="content">
<Typography.Title level={3}>Waiter Floor Operations</Typography.Title>
<Typography.Paragraph type="secondary">
Protected floor workflows for assignment visibility and order submission.
Protected floor workflows for assignment visibility, recent activity, and order submit or update actions.
</Typography.Paragraph>
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
{workflowState.error && (
<Alert
className="stack-gap"
type={workflowState.sessionExpired ? 'warning' : 'error'}
showIcon
message={workflowState.error}
action={
workflowState.sessionExpired ? (
<Button size="small" type="primary" href={loginUrl}>
Reauthenticate
</Button>
) : undefined
}
/>
)}
<Routes>
<Route
path="/assignments"
element={
<Card title="Assignments">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Assignments Snapshot">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Input
@ -154,24 +263,49 @@ function WaiterFloorShell() {
<Button type="primary" loading={loadingAssignments} onClick={() => void loadAssignments()}>
Load Assignments
</Button>
<Button icon={<SyncOutlined />} onClick={() => void loadAssignments()} disabled={loadingAssignments}>
Retry
</Button>
</Space>
{assignments ? (
<>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{assignments.contextId}</Descriptions.Item>
<Descriptions.Item label="Location Id">{assignments.locationId}</Descriptions.Item>
<Descriptions.Item label="Summary">{assignments.summary}</Descriptions.Item>
</Descriptions>
<Table
pagination={false}
rowKey={(record) => `${record.waiterId}-${record.tableId}`}
dataSource={assignments.assignments}
columns={assignmentColumns}
locale={{ emptyText: 'No active waiter assignments for this context.' }}
/>
</>
) : (
<Typography.Text type="secondary">No assignment snapshot loaded.</Typography.Text>
<Empty description="Load a context to review assignment coverage." />
)}
</Space>
</Card>
<Card title="Recent Activity" extra={<ClockCircleOutlined />}>
{recentActivity && recentActivity.recentActivity.length > 0 ? (
<List
bordered
dataSource={recentActivity.recentActivity}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
) : (
<Empty description="No activity loaded yet. Load assignments first to refresh this feed." />
)}
</Card>
</Space>
}
/>
<Route
path="/orders"
element={
<Card title="Submit Floor Order">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Submit Floor Order">
<Form
layout="vertical"
initialValues={{
@ -198,28 +332,68 @@ function WaiterFloorShell() {
Submit Floor Order
</Button>
</Form>
{orderResponse && (
</Card>
<Card title="Update Existing Order">
<Form
layout="vertical"
initialValues={{
contextId,
tableId: 'T-12',
orderId: 'ORD-1001',
itemCount: 4
}}
onFinish={(values: UpdateFloorOrderRequest) => void reviseOrder(values)}
>
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
<Input placeholder="Context Id" />
</Form.Item>
<Form.Item name="tableId" label="Table Id" rules={[{ required: true }]}>
<Input placeholder="Table Id" />
</Form.Item>
<Form.Item name="orderId" label="Order Id" rules={[{ required: true }]}>
<Input placeholder="Order Id" />
</Form.Item>
<Form.Item name="itemCount" label="Updated Item Count" rules={[{ required: true, type: 'number', min: 1 }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={updatingOrder}>
Update Floor Order
</Button>
</Form>
</Card>
<Card title="Latest Workflow Result">
{lastOrderResponse ? (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Order Id">{orderResponse.orderId}</Descriptions.Item>
<Descriptions.Item label="Accepted">{String(orderResponse.accepted)}</Descriptions.Item>
<Descriptions.Item label="Message">{orderResponse.message}</Descriptions.Item>
<Descriptions.Item label="Context Id">{lastOrderResponse.contextId}</Descriptions.Item>
<Descriptions.Item label="Order Id">{lastOrderResponse.orderId}</Descriptions.Item>
<Descriptions.Item label="Accepted">{String(lastOrderResponse.accepted)}</Descriptions.Item>
<Descriptions.Item label="Status">{lastOrderResponse.status}</Descriptions.Item>
<Descriptions.Item label="Summary">{lastOrderResponse.summary}</Descriptions.Item>
<Descriptions.Item label="Processed At">{lastOrderResponse.processedAtUtc}</Descriptions.Item>
</Descriptions>
) : (
<Empty description="Submit or update an order to inspect the response payload." />
)}
<Table<SubmitFloorOrderResponse>
</Card>
<Card title="Recent Workflow Actions">
<Table<OrderEvent>
pagination={false}
rowKey={(record) => record.orderId}
rowKey={(record) => `${record.kind}-${record.response.orderId}-${record.response.processedAtUtc}`}
dataSource={orderHistory}
locale={{ emptyText: 'No recent floor order actions yet.' }}
columns={[
{ title: 'Order Id', dataIndex: 'orderId' },
{
title: 'Accepted',
render: (_, record) => <Tag color={record.accepted ? 'green' : 'red'}>{String(record.accepted)}</Tag>
title: 'Action',
render: (_, record) => <Tag color={record.kind === 'submitted' ? 'green' : 'orange'}>{record.kind}</Tag>
},
{ title: 'Message', dataIndex: 'message' }
{ title: 'Order Id', render: (_, record) => record.response.orderId },
{ title: 'Status', render: (_, record) => record.response.status },
{ title: 'Summary', render: (_, record) => record.response.summary },
{ title: 'Processed At', render: (_, record) => record.response.processedAtUtc }
]}
/>
</Space>
</Card>
</Space>
}
/>
<Route

View File

@ -82,6 +82,17 @@ export async function postJson<TResponse>(
});
}
export async function putJson<TResponse>(
path: string,
body: unknown,
baseUrl = getApiBaseUrl()
): Promise<TResponse> {
return requestJson<TResponse>(baseUrl, path, {
method: 'PUT',
body: JSON.stringify(body)
});
}
export async function postNoContent(
path: string,
body: unknown,

View File

@ -2,11 +2,12 @@ import { describe, expect, it, vi } from 'vitest';
vi.mock('./client', () => ({
getJson: vi.fn(),
postJson: vi.fn()
postJson: vi.fn(),
putJson: vi.fn()
}));
import { getJson, postJson } from './client';
import { loadDashboard, submitFloorOrder } from './dashboardApi';
import { getJson, postJson, putJson } from './client';
import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './dashboardApi';
describe('waiter floor dashboard api', () => {
it('builds encoded assignments endpoint path', async () => {
@ -17,6 +18,14 @@ describe('waiter floor dashboard api', () => {
expect(getJson).toHaveBeenCalledWith('/api/waiter/floor/assignments?contextId=ctx%20floor%2F1');
});
it('builds encoded activity endpoint path', async () => {
vi.mocked(getJson).mockResolvedValue({ ok: true });
await loadRecentActivity('ctx floor/1');
expect(getJson).toHaveBeenCalledWith('/api/waiter/floor/activity?contextId=ctx%20floor%2F1');
});
it('posts floor order payload', async () => {
vi.mocked(postJson).mockResolvedValue({ accepted: true });
@ -34,4 +43,21 @@ describe('waiter floor dashboard api', () => {
itemCount: 2
});
});
it('puts floor order update payload without duplicating order id in the body', async () => {
vi.mocked(putJson).mockResolvedValue({ accepted: true });
await updateFloorOrder({
contextId: 'ctx',
tableId: 'T-1',
orderId: 'ORD/1',
itemCount: 4
});
expect(putJson).toHaveBeenCalledWith('/api/waiter/floor/orders/ORD%2F1', {
contextId: 'ctx',
tableId: 'T-1',
itemCount: 4
});
});
});

View File

@ -1,8 +1,25 @@
import { getJson, postJson } from './client';
import { getJson, postJson, putJson } from './client';
export type WaiterAssignment = {
waiterId: string;
tableId: string;
status: string;
activeOrders: number;
};
export type WaiterAssignmentsResponse = {
contextId: string;
locationId: string;
summary: string;
assignments: WaiterAssignment[];
recentActivity: string[];
};
export type WaiterRecentActivityResponse = {
contextId: string;
locationId: string;
summary: string;
recentActivity: string[];
};
export type SubmitFloorOrderRequest = {
@ -13,15 +30,46 @@ export type SubmitFloorOrderRequest = {
};
export type SubmitFloorOrderResponse = {
contextId: string;
orderId: string;
accepted: boolean;
message: string;
summary: string;
status: string;
processedAtUtc: string;
};
export type UpdateFloorOrderRequest = {
contextId: string;
tableId: string;
orderId: string;
itemCount: number;
};
export type UpdateFloorOrderResponse = {
contextId: string;
orderId: string;
accepted: boolean;
summary: string;
status: string;
processedAtUtc: string;
};
export async function loadDashboard(contextId: string): Promise<WaiterAssignmentsResponse> {
return getJson<WaiterAssignmentsResponse>(`/api/waiter/floor/assignments?contextId=${encodeURIComponent(contextId)}`);
}
export async function loadRecentActivity(contextId: string): Promise<WaiterRecentActivityResponse> {
return getJson<WaiterRecentActivityResponse>(`/api/waiter/floor/activity?contextId=${encodeURIComponent(contextId)}`);
}
export async function submitFloorOrder(request: SubmitFloorOrderRequest): Promise<SubmitFloorOrderResponse> {
return postJson<SubmitFloorOrderResponse>('/api/waiter/floor/orders', request);
}
export async function updateFloorOrder(request: UpdateFloorOrderRequest): Promise<UpdateFloorOrderResponse> {
return putJson<UpdateFloorOrderResponse>(`/api/waiter/floor/orders/${encodeURIComponent(request.orderId)}`, {
contextId: request.contextId,
tableId: request.tableId,
itemCount: request.itemCount
});
}