Compare commits

..

No commits in common. "development" and "feature/waiter-floor-web-ant-protected-routes" have entirely different histories.

8 changed files with 72 additions and 469 deletions

View File

@ -5,7 +5,6 @@
- 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
@ -17,14 +16,10 @@
## 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 snapshot with location metadata and active-order counts derived from the shared restaurant lifecycle - Waiter assignment lookup
- Recent waiter activity history feed - Floor order submission
- Floor order submission and order update workflows that feed the shared restaurant order/check model - Protected route shell for assignments, order submission, and session inspection
- Session-expired handling with reauthentication guidance
- Protected route shell for assignments, order actions, and session inspection

View File

@ -21,13 +21,6 @@ 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 with shared-lifecycle progression hints
- `/session`: current Thalos session profile payload
## Build ## Build

View File

@ -15,11 +15,10 @@ 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, activity loading, and order update payload mapping. - `src/api/dashboardApi.test.ts`: endpoint path/query composition and 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, shared-lifecycle assignment messaging, order submit/update progression hints, and session-expired reauthentication guidance. - `src/App.test.tsx`: central login screen, protected assignment flow, and order submission workflow.
## 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,6 +1,5 @@
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(),
@ -11,21 +10,17 @@ vi.mock('./api/sessionApi', () => ({
vi.mock('./api/dashboardApi', () => ({ vi.mock('./api/dashboardApi', () => ({
loadDashboard: vi.fn(), loadDashboard: vi.fn(),
loadRecentActivity: vi.fn(), submitFloorOrder: vi.fn()
submitFloorOrder: vi.fn(),
updateFloorOrder: vi.fn()
})); }));
import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './api/dashboardApi'; import { loadDashboard, submitFloorOrder } 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',
@ -47,26 +42,14 @@ describe('Waiter Floor App', () => {
expect(link.href).toContain('tenantId=demo-tenant'); expect(link.href).toContain('tenantId=demo-tenant');
}); });
it('loads assignments and recent activity for authenticated users', async () => { it('loads assignments 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: 2 provider: 0
});
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 />); render(<App />);
@ -74,76 +57,24 @@ 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();
expect(await screen.findByText('Floor actions create or update shared restaurant orders that kitchen and POS observe next.')).toBeInTheDocument();
}); });
it('submits and updates floor orders from the order route', async () => { it('submits floor orders from action 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: 2 provider: 0
});
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 />); render(<App />);
await waitFor(() => expect(screen.getByText('Order Actions')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Submit Order')).toBeInTheDocument());
fireEvent.click(screen.getByText('Order Actions')); fireEvent.click(screen.getByText('Submit Order'));
fireEvent.change(screen.getAllByPlaceholderText('Order Id')[0], { target: { value: 'ORD-2200' } }); fireEvent.change(screen.getByPlaceholderText('Order Id'), { 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('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,76 +1,26 @@
import { import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons';
ClockCircleOutlined, import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Layout, Menu, Result, Space, Spin, Table, Tag, Typography } from 'antd';
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 UpdateFloorOrderRequest, type WaiterAssignmentsResponse
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: 'Order Actions', icon: <ShoppingCartOutlined /> }, { key: '/orders', label: 'Submit Order', 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={workflowTagColor(value)}>{value}</Tag>
},
{ title: 'Active Orders', dataIndex: 'activeOrders' }
];
function App() { function App() {
return ( return (
<SessionProvider> <SessionProvider>
@ -88,13 +38,11 @@ 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 [recentActivity, setRecentActivity] = useState<WaiterRecentActivityResponse | null>(null); const [orderResponse, setOrderResponse] = useState<SubmitFloorOrderResponse | null>(null);
const [lastOrderResponse, setLastOrderResponse] = useState<SubmitFloorOrderResponse | UpdateFloorOrderResponse | null>(null); const [orderHistory, setOrderHistory] = useState<SubmitFloorOrderResponse[]>([]);
const [orderHistory, setOrderHistory] = useState<OrderEvent[]>([]); const [globalError, setGlobalError] = useState<string | null>(null);
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(() => {
@ -102,33 +50,14 @@ 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);
clearWorkflowError(); setGlobalError(null);
try { try {
const [assignmentsPayload, activityPayload] = await Promise.all([ const payload = await loadDashboard(contextId);
loadDashboard(contextId), setAssignments(payload);
loadRecentActivity(contextId)
]);
setAssignments(assignmentsPayload);
setRecentActivity(activityPayload);
} catch (err) { } catch (err) {
await handleWorkflowFailure(err, 'Failed to load waiter assignments.'); setGlobalError(err instanceof Error ? err.message : 'Failed to load waiter assignments.');
} finally { } finally {
setLoadingAssignments(false); setLoadingAssignments(false);
} }
@ -136,32 +65,18 @@ function WaiterFloorShell() {
const submitOrder = async (request: SubmitFloorOrderRequest) => { const submitOrder = async (request: SubmitFloorOrderRequest) => {
setSubmittingOrder(true); setSubmittingOrder(true);
clearWorkflowError(); setGlobalError(null);
try { try {
const payload = await submitFloorOrder(request); const payload = await submitFloorOrder(request);
setLastOrderResponse(payload); setOrderResponse(payload);
setOrderHistory((previous) => [{ kind: 'submitted' as const, response: payload }, ...previous].slice(0, 8)); setOrderHistory((previous) => [payload, ...previous].slice(0, 8));
} catch (err) { } catch (err) {
await handleWorkflowFailure(err, 'Failed to submit floor order.'); setGlobalError(err instanceof Error ? err.message : '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">
@ -184,15 +99,6 @@ 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>
); );
} }
@ -227,31 +133,16 @@ 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, recent activity, and order actions that now feed the same restaurant lifecycle used by kitchen and POS. Protected floor workflows for assignment visibility and order submission.
</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} />}
{workflowState.error && ( {globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
<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={
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Card title="Assignments">
<Card title="Assignments Snapshot">
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap> <Space wrap>
<Input <Input
@ -263,55 +154,24 @@ function WaiterFloorShell() {
<Button type="primary" loading={loadingAssignments} onClick={() => void loadAssignments()}> <Button type="primary" loading={loadingAssignments} onClick={() => void loadAssignments()}>
Load Assignments Load Assignments
</Button> </Button>
<Button icon={<SyncOutlined />} onClick={() => void loadAssignments()} disabled={loadingAssignments}>
Retry
</Button>
</Space> </Space>
{assignments ? ( {assignments ? (
<>
<Descriptions bordered size="small" column={1}> <Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{assignments.contextId}</Descriptions.Item> <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="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> </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." /> <Typography.Text type="secondary">No assignment snapshot loaded.</Typography.Text>
)} )}
</Space> </Space>
</Card> </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 <Route
path="/orders" path="/orders"
element={ element={
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Submit Floor Order"> <Card title="Submit Floor Order">
<Typography.Paragraph type="secondary"> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
New floor orders are accepted into the shared restaurant lifecycle first, then they progress through kitchen preparation and payment readiness.
</Typography.Paragraph>
<Form <Form
layout="vertical" layout="vertical"
initialValues={{ initialValues={{
@ -338,77 +198,28 @@ function WaiterFloorShell() {
Submit Floor Order Submit Floor Order
</Button> </Button>
</Form> </Form>
</Card> {orderResponse && (
<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 bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{lastOrderResponse.contextId}</Descriptions.Item> <Descriptions.Item label="Order Id">{orderResponse.orderId}</Descriptions.Item>
<Descriptions.Item label="Order Id">{lastOrderResponse.orderId}</Descriptions.Item> <Descriptions.Item label="Accepted">{String(orderResponse.accepted)}</Descriptions.Item>
<Descriptions.Item label="Accepted">{String(lastOrderResponse.accepted)}</Descriptions.Item> <Descriptions.Item label="Message">{orderResponse.message}</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> </Descriptions>
) : (
<Empty description="Submit or update an order to inspect the response payload." />
)} )}
</Card> <Table<SubmitFloorOrderResponse>
<Card title="Recent Workflow Actions">
<Table<OrderEvent>
pagination={false} pagination={false}
rowKey={(record) => `${record.kind}-${record.response.orderId}-${record.response.processedAtUtc}`} rowKey={(record) => record.orderId}
dataSource={orderHistory} dataSource={orderHistory}
locale={{ emptyText: 'No recent floor order actions yet.' }}
columns={[ columns={[
{ title: 'Order Id', dataIndex: 'orderId' },
{ {
title: 'Action', title: 'Accepted',
render: (_, record) => <Tag color={record.kind === 'submitted' ? 'green' : 'orange'}>{record.kind}</Tag> render: (_, record) => <Tag color={record.accepted ? 'green' : 'red'}>{String(record.accepted)}</Tag>
}, },
{ title: 'Order Id', render: (_, record) => record.response.orderId }, { title: 'Message', dataIndex: 'message' }
{
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 }
]} ]}
/> />
</Card>
</Space> </Space>
</Card>
} }
/> />
<Route <Route
@ -444,45 +255,4 @@ function providerLabel(provider: IdentityProvider): string {
return String(provider); 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; export default App;

View File

@ -82,17 +82,6 @@ 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,12 +2,11 @@ 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, putJson } from './client'; import { getJson, postJson } from './client';
import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './dashboardApi'; import { loadDashboard, submitFloorOrder } 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 () => {
@ -18,14 +17,6 @@ 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 });
@ -43,21 +34,4 @@ 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,25 +1,8 @@
import { getJson, postJson, putJson } from './client'; import { getJson, postJson } 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 = {
@ -30,46 +13,15 @@ export type SubmitFloorOrderRequest = {
}; };
export type SubmitFloorOrderResponse = { export type SubmitFloorOrderResponse = {
contextId: string;
orderId: string; orderId: string;
accepted: boolean; accepted: boolean;
summary: string; message: 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
});
}