Compare commits

...

3 Commits

Author SHA1 Message Date
José René White Enciso
74c8567b14 feat(waiter-floor-web): show shared order progression 2026-03-31 19:00:52 -06:00
José René White Enciso
a0dc0a29ed feat(waiter-floor-web): deepen floor workflow experience 2026-03-31 16:50:12 -06:00
José René White Enciso
e019a069dd merge: integrate waiter-floor-web auth and web updates 2026-03-11 12:39:58 -06:00
8 changed files with 469 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 derived from the shared restaurant lifecycle
- Recent waiter activity history feed
- Floor order submission and order update workflows that feed the shared restaurant order/check model
- 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 with shared-lifecycle progression hints
- `/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, shared-lifecycle assignment messaging, order submit/update progression hints, 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: 'service-pool', tableId: 'T-12', status: 'Preparing', 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,76 @@ 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();
expect(await screen.findByText('Floor actions create or update shared restaurant orders that kitchen and POS observe next.')).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: 'Order ORD-2200 was accepted and is ready for kitchen dispatch.',
status: 'accepted',
processedAtUtc: '2026-03-31T12:00:00Z'
});
vi.mocked(updateFloorOrder).mockResolvedValue({
contextId: 'demo-context',
orderId: 'ORD-2200',
accepted: true,
summary: 'Updated order ORD-2200. Order ORD-2200 was accepted and is ready for kitchen dispatch.',
status: 'accepted',
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('accepted')).length).toBeGreaterThan(0);
expect(await screen.findByText('Kitchen should pick this order up next.')).toBeInTheDocument();
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('accepted')).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={workflowTagColor(value)}>{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,45 +227,91 @@ 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 actions that now feed the same restaurant lifecycle used by kitchen and POS.
</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%' }}>
<Space wrap>
<Input
value={contextId}
onChange={(event) => setContextId(event.target.value)}
placeholder="Context Id"
style={{ width: 280 }}
/>
<Button type="primary" loading={loadingAssignments} onClick={() => void loadAssignments()}>
Load Assignments
</Button>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Assignments Snapshot">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Input
value={contextId}
onChange={(event) => setContextId(event.target.value)}
placeholder="Context Id"
style={{ width: 280 }}
/>
<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.Item label="Lifecycle Note">
Floor actions create or update shared restaurant orders that kitchen and POS observe next.
</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.' }}
/>
</>
) : (
<Empty description="Load a context to review assignment coverage." />
)}
</Space>
{assignments ? (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{assignments.contextId}</Descriptions.Item>
<Descriptions.Item label="Summary">{assignments.summary}</Descriptions.Item>
</Descriptions>
</Card>
<Card title="Recent Activity" extra={<ClockCircleOutlined />}>
{recentActivity && recentActivity.recentActivity.length > 0 ? (
<List
bordered
dataSource={recentActivity.recentActivity}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
) : (
<Typography.Text type="secondary">No assignment snapshot loaded.</Typography.Text>
<Empty description="No activity loaded yet. Load assignments first to refresh this feed." />
)}
</Space>
</Card>
</Card>
</Space>
}
/>
<Route
path="/orders"
element={
<Card title="Submit Floor Order">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Submit Floor Order">
<Typography.Paragraph type="secondary">
New floor orders are accepted into the shared restaurant lifecycle first, then they progress through kitchen preparation and payment readiness.
</Typography.Paragraph>
<Form
layout="vertical"
initialValues={{
@ -198,28 +338,77 @@ function WaiterFloorShell() {
Submit Floor Order
</Button>
</Form>
{orderResponse && (
</Card>
<Card title="Update Existing Order">
<Typography.Paragraph type="secondary">
Updates keep the same shared order identity so the downstream kitchen and POS views stay consistent.
</Typography.Paragraph>
<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">
<Tag color={workflowTagColor(lastOrderResponse.status)}>{lastOrderResponse.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Summary">{lastOrderResponse.summary}</Descriptions.Item>
<Descriptions.Item label="Next Step">{orderProgressHint(lastOrderResponse.status)}</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) => <Tag color={workflowTagColor(record.response.status)}>{record.response.status}</Tag>
},
{ title: 'Summary', render: (_, record) => record.response.summary },
{ title: 'Processed At', render: (_, record) => record.response.processedAtUtc }
]}
/>
</Space>
</Card>
</Card>
</Space>
}
/>
<Route
@ -255,4 +444,45 @@ function providerLabel(provider: IdentityProvider): string {
return String(provider);
}
function workflowTagColor(status: string): string {
switch (status.toLowerCase()) {
case 'accepted':
return 'blue';
case 'preparing':
case 'cooking':
return 'gold';
case 'ready':
case 'readyforpickup':
return 'cyan';
case 'served':
case 'paid':
return 'green';
case 'blocked':
case 'failed':
case 'canceled':
return 'red';
default:
return 'default';
}
}
function orderProgressHint(status: string): string {
switch (status.toLowerCase()) {
case 'accepted':
return 'Kitchen should pick this order up next.';
case 'preparing':
case 'cooking':
return 'Kitchen is actively preparing this order.';
case 'ready':
case 'readyforpickup':
return 'The order is ready for handoff or service.';
case 'served':
return 'POS can now treat this check as payable.';
case 'paid':
return 'This restaurant check is fully closed.';
default:
return 'Track this order across the shared restaurant lifecycle.';
}
}
export default App;

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
});
}