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. - The UI does not access DAL or internal services directly.
- Route shell uses Ant Design layout/menu and keeps business views behind session checks. - 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. - 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 ## Runtime Base URLs
@ -16,10 +17,14 @@
## Protected Workflow Endpoints ## Protected Workflow Endpoints
- `GET /api/waiter/floor/assignments?contextId=...` - `GET /api/waiter/floor/assignments?contextId=...`
- `GET /api/waiter/floor/activity?contextId=...`
- `POST /api/waiter/floor/orders` - `POST /api/waiter/floor/orders`
- `PUT /api/waiter/floor/orders/{orderId}`
## UI Workflow Coverage ## UI Workflow Coverage
- Waiter assignment lookup - Waiter assignment snapshot with location metadata and active-order counts
- Floor order submission - Recent waiter activity history feed
- Protected route shell for assignments, order submission, and session inspection - 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. - Login is executed via central Thalos OIDC start endpoint.
- Business calls are gated behind session checks. - Business calls are gated behind session checks.
- Session cookies are sent with `credentials: include`. - 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 ## Build

View File

@ -15,10 +15,11 @@ npm run test:ci
## Coverage Scope ## Coverage Scope
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior. - `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/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 ## Notes
- Use containerized Node execution when host `npm` is unavailable. - 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 { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from './api/client';
vi.mock('./api/sessionApi', () => ({ vi.mock('./api/sessionApi', () => ({
getSessionMe: vi.fn(), getSessionMe: vi.fn(),
@ -10,17 +11,21 @@ vi.mock('./api/sessionApi', () => ({
vi.mock('./api/dashboardApi', () => ({ vi.mock('./api/dashboardApi', () => ({
loadDashboard: vi.fn(), 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 { getSessionMe } from './api/sessionApi';
import App from './App'; import App from './App';
describe('Waiter Floor App', () => { describe('Waiter Floor App', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(loadDashboard).mockReset(); vi.mocked(loadDashboard).mockReset();
vi.mocked(loadRecentActivity).mockReset();
vi.mocked(submitFloorOrder).mockReset(); vi.mocked(submitFloorOrder).mockReset();
vi.mocked(updateFloorOrder).mockReset();
vi.mocked(getSessionMe).mockReset(); vi.mocked(getSessionMe).mockReset();
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
API_BASE_URL: 'http://localhost:8080', API_BASE_URL: 'http://localhost:8080',
@ -42,14 +47,26 @@ describe('Waiter Floor App', () => {
expect(link.href).toContain('tenantId=demo-tenant'); 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({ vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true, isAuthenticated: true,
subjectId: 'demo-user', subjectId: 'demo-user',
tenantId: 'demo-tenant', 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 />); render(<App />);
@ -57,24 +74,74 @@ describe('Waiter Floor App', () => {
fireEvent.click(screen.getByRole('button', { name: 'Load Assignments' })); fireEvent.click(screen.getByRole('button', { name: 'Load Assignments' }));
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); 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({ vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true, isAuthenticated: true,
subjectId: 'demo-user', subjectId: 'demo-user',
tenantId: 'demo-tenant', 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 />); render(<App />);
await waitFor(() => expect(screen.getByText('Submit Order')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Order Actions')).toBeInTheDocument());
fireEvent.click(screen.getByText('Submit Order')); fireEvent.click(screen.getByText('Order Actions'));
fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } }); fireEvent.change(screen.getAllByPlaceholderText('Order Id')[0], { target: { value: 'ORD-2200' } });
fireEvent.click(screen.getByRole('button', { name: 'Submit Floor Order' })); fireEvent.click(screen.getByRole('button', { name: 'Submit Floor Order' }));
await waitFor(() => expect(submitFloorOrder).toHaveBeenCalledTimes(1)); 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 {
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Layout, Menu, Result, Space, Spin, Table, Tag, Typography } from 'antd'; 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 { type ReactNode, useMemo, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { ApiError } from './api/client';
import type { IdentityProvider } from './api/sessionApi'; import type { IdentityProvider } from './api/sessionApi';
import { SessionProvider, useSessionContext } from './auth/sessionContext'; import { SessionProvider, useSessionContext } from './auth/sessionContext';
import { buildGoogleOidcStartUrl } from './auth/oidcLogin'; import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
import { import {
loadDashboard, loadDashboard,
loadRecentActivity,
submitFloorOrder, submitFloorOrder,
updateFloorOrder,
type SubmitFloorOrderRequest, type SubmitFloorOrderRequest,
type SubmitFloorOrderResponse, type SubmitFloorOrderResponse,
type WaiterAssignmentsResponse type UpdateFloorOrderRequest,
type UpdateFloorOrderResponse,
type WaiterAssignmentsResponse,
type WaiterRecentActivityResponse
} from './api/dashboardApi'; } from './api/dashboardApi';
type AppRoute = '/assignments' | '/orders' | '/session'; 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 }> = [ const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
{ key: '/assignments', label: 'Assignments', icon: <OrderedListOutlined /> }, { 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 /> } { 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() { function App() {
return ( return (
<SessionProvider> <SessionProvider>
@ -38,11 +88,13 @@ function WaiterFloorShell() {
const [contextId, setContextId] = useState('demo-context'); const [contextId, setContextId] = useState('demo-context');
const [assignments, setAssignments] = useState<WaiterAssignmentsResponse | null>(null); const [assignments, setAssignments] = useState<WaiterAssignmentsResponse | null>(null);
const [orderResponse, setOrderResponse] = useState<SubmitFloorOrderResponse | null>(null); const [recentActivity, setRecentActivity] = useState<WaiterRecentActivityResponse | null>(null);
const [orderHistory, setOrderHistory] = useState<SubmitFloorOrderResponse[]>([]); const [lastOrderResponse, setLastOrderResponse] = useState<SubmitFloorOrderResponse | UpdateFloorOrderResponse | null>(null);
const [globalError, setGlobalError] = useState<string | null>(null); const [orderHistory, setOrderHistory] = useState<OrderEvent[]>([]);
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
const [loadingAssignments, setLoadingAssignments] = useState(false); const [loadingAssignments, setLoadingAssignments] = useState(false);
const [submittingOrder, setSubmittingOrder] = useState(false); const [submittingOrder, setSubmittingOrder] = useState(false);
const [updatingOrder, setUpdatingOrder] = useState(false);
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []); const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
const selectedKey = useMemo(() => { const selectedKey = useMemo(() => {
@ -50,14 +102,33 @@ function WaiterFloorShell() {
return candidate?.key ?? '/assignments'; return candidate?.key ?? '/assignments';
}, [location.pathname]); }, [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 () => { const loadAssignments = async () => {
setLoadingAssignments(true); setLoadingAssignments(true);
setGlobalError(null); clearWorkflowError();
try { try {
const payload = await loadDashboard(contextId); const [assignmentsPayload, activityPayload] = await Promise.all([
setAssignments(payload); loadDashboard(contextId),
loadRecentActivity(contextId)
]);
setAssignments(assignmentsPayload);
setRecentActivity(activityPayload);
} catch (err) { } catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to load waiter assignments.'); await handleWorkflowFailure(err, 'Failed to load waiter assignments.');
} finally { } finally {
setLoadingAssignments(false); setLoadingAssignments(false);
} }
@ -65,18 +136,32 @@ function WaiterFloorShell() {
const submitOrder = async (request: SubmitFloorOrderRequest) => { const submitOrder = async (request: SubmitFloorOrderRequest) => {
setSubmittingOrder(true); setSubmittingOrder(true);
setGlobalError(null); clearWorkflowError();
try { try {
const payload = await submitFloorOrder(request); const payload = await submitFloorOrder(request);
setOrderResponse(payload); setLastOrderResponse(payload);
setOrderHistory((previous) => [payload, ...previous].slice(0, 8)); setOrderHistory((previous) => [{ kind: 'submitted' as const, response: payload }, ...previous].slice(0, 8));
} catch (err) { } catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to submit floor order.'); await handleWorkflowFailure(err, 'Failed to submit floor order.');
} finally { } finally {
setSubmittingOrder(false); 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') { if (session.status === 'loading') {
return ( return (
<div className="fullscreen-center"> <div className="fullscreen-center">
@ -99,6 +184,15 @@ function WaiterFloorShell() {
} }
/> />
{session.error && <Alert type="error" showIcon message={session.error} />} {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> </main>
); );
} }
@ -133,45 +227,85 @@ function WaiterFloorShell() {
<Layout.Content className="content"> <Layout.Content className="content">
<Typography.Title level={3}>Waiter Floor Operations</Typography.Title> <Typography.Title level={3}>Waiter Floor Operations</Typography.Title>
<Typography.Paragraph type="secondary"> <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> </Typography.Paragraph>
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />} {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> <Routes>
<Route <Route
path="/assignments" path="/assignments"
element={ element={
<Card title="Assignments"> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Card title="Assignments Snapshot">
<Space wrap> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Input <Space wrap>
value={contextId} <Input
onChange={(event) => setContextId(event.target.value)} value={contextId}
placeholder="Context Id" onChange={(event) => setContextId(event.target.value)}
style={{ width: 280 }} placeholder="Context Id"
/> style={{ width: 280 }}
<Button type="primary" loading={loadingAssignments} onClick={() => void loadAssignments()}> />
Load Assignments <Button type="primary" loading={loadingAssignments} onClick={() => void loadAssignments()}>
</Button> 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.' }}
/>
</>
) : (
<Empty description="Load a context to review assignment coverage." />
)}
</Space> </Space>
{assignments ? ( </Card>
<Descriptions bordered size="small" column={1}> <Card title="Recent Activity" extra={<ClockCircleOutlined />}>
<Descriptions.Item label="Context Id">{assignments.contextId}</Descriptions.Item> {recentActivity && recentActivity.recentActivity.length > 0 ? (
<Descriptions.Item label="Summary">{assignments.summary}</Descriptions.Item> <List
</Descriptions> 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 <Route
path="/orders" path="/orders"
element={ 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">
<Form <Form
layout="vertical" layout="vertical"
initialValues={{ initialValues={{
@ -198,28 +332,68 @@ function WaiterFloorShell() {
Submit Floor Order Submit Floor Order
</Button> </Button>
</Form> </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 bordered size="small" column={1}>
<Descriptions.Item label="Order Id">{orderResponse.orderId}</Descriptions.Item> <Descriptions.Item label="Context Id">{lastOrderResponse.contextId}</Descriptions.Item>
<Descriptions.Item label="Accepted">{String(orderResponse.accepted)}</Descriptions.Item> <Descriptions.Item label="Order Id">{lastOrderResponse.orderId}</Descriptions.Item>
<Descriptions.Item label="Message">{orderResponse.message}</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> </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} pagination={false}
rowKey={(record) => record.orderId} rowKey={(record) => `${record.kind}-${record.response.orderId}-${record.response.processedAtUtc}`}
dataSource={orderHistory} dataSource={orderHistory}
locale={{ emptyText: 'No recent floor order actions yet.' }}
columns={[ columns={[
{ title: 'Order Id', dataIndex: 'orderId' },
{ {
title: 'Accepted', title: 'Action',
render: (_, record) => <Tag color={record.accepted ? 'green' : 'red'}>{String(record.accepted)}</Tag> 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>
</Card> </Space>
} }
/> />
<Route <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( export async function postNoContent(
path: string, path: string,
body: unknown, body: unknown,

View File

@ -2,11 +2,12 @@ import { describe, expect, it, vi } from 'vitest';
vi.mock('./client', () => ({ vi.mock('./client', () => ({
getJson: vi.fn(), getJson: vi.fn(),
postJson: vi.fn() postJson: vi.fn(),
putJson: vi.fn()
})); }));
import { getJson, postJson } from './client'; import { getJson, postJson, putJson } from './client';
import { loadDashboard, submitFloorOrder } from './dashboardApi'; import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './dashboardApi';
describe('waiter floor dashboard api', () => { describe('waiter floor dashboard api', () => {
it('builds encoded assignments endpoint path', async () => { 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'); 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 () => { it('posts floor order payload', async () => {
vi.mocked(postJson).mockResolvedValue({ accepted: true }); vi.mocked(postJson).mockResolvedValue({ accepted: true });
@ -34,4 +43,21 @@ describe('waiter floor dashboard api', () => {
itemCount: 2 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 = { export type WaiterAssignmentsResponse = {
contextId: string; contextId: string;
locationId: string;
summary: string; summary: string;
assignments: WaiterAssignment[];
recentActivity: string[];
};
export type WaiterRecentActivityResponse = {
contextId: string;
locationId: string;
summary: string;
recentActivity: string[];
}; };
export type SubmitFloorOrderRequest = { export type SubmitFloorOrderRequest = {
@ -13,15 +30,46 @@ export type SubmitFloorOrderRequest = {
}; };
export type SubmitFloorOrderResponse = { export type SubmitFloorOrderResponse = {
contextId: string;
orderId: string; orderId: string;
accepted: boolean; 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> { export async function loadDashboard(contextId: string): Promise<WaiterAssignmentsResponse> {
return getJson<WaiterAssignmentsResponse>(`/api/waiter/floor/assignments?contextId=${encodeURIComponent(contextId)}`); 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> { export async function submitFloorOrder(request: SubmitFloorOrderRequest): Promise<SubmitFloorOrderResponse> {
return postJson<SubmitFloorOrderResponse>('/api/waiter/floor/orders', request); 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
});
}