Compare commits

..

3 Commits

Author SHA1 Message Date
José René White Enciso
d8da4aae5e feat(customer-orders-web): show shared order progression 2026-03-31 19:03:54 -06:00
José René White Enciso
429b58a629 feat(customer-orders-web): deepen customer order workflows 2026-03-31 16:56:26 -06:00
José René White Enciso
55458cd79f merge: integrate customer-orders-web auth and web updates 2026-03-11 12:39:03 -06:00
7 changed files with 409 additions and 84 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,15 @@
## Protected Workflow Endpoints ## Protected Workflow Endpoints
- `GET /api/customer/orders/status?contextId=...` - `GET /api/customer/orders/status?contextId=...`
- `GET /api/customer/orders/history?contextId=...`
- `GET /api/customer/orders/{orderId}?contextId=...`
- `POST /api/customer/orders` - `POST /api/customer/orders`
## UI Workflow Coverage ## UI Workflow Coverage
- Customer order status lookup - Customer order status dashboard with current orders from the shared restaurant lifecycle
- Customer order submission - Selected order detail lookup
- Recent order history and event feed
- Customer order submission and recent submission results with shared-lifecycle progression hints
- Session-expired handling with reauthentication guidance
- Protected route shell for status, submission, and session inspection - Protected route shell for status, submission, 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
- `/status`: current order status, selected order detail, history, recent events, and shared-lifecycle guidance
- `/submit`: customer order submission and recent submission results with kitchen/payment readiness hints
- `/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 for status, detail, history, and submit flows.
- `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 status flow, and order submission workflow. - `src/App.test.tsx`: central login screen, shared-lifecycle order messaging, order submission progression hints, 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,16 +11,20 @@ vi.mock('./api/sessionApi', () => ({
vi.mock('./api/dashboardApi', () => ({ vi.mock('./api/dashboardApi', () => ({
loadDashboard: vi.fn(), loadDashboard: vi.fn(),
loadOrderDetail: vi.fn(),
loadOrderHistory: vi.fn(),
submitCustomerOrder: vi.fn() submitCustomerOrder: vi.fn()
})); }));
import { loadDashboard, submitCustomerOrder } from './api/dashboardApi'; import { loadDashboard, loadOrderDetail, loadOrderHistory, submitCustomerOrder } from './api/dashboardApi';
import { getSessionMe } from './api/sessionApi'; import { getSessionMe } from './api/sessionApi';
import App from './App'; import App from './App';
describe('Customer Orders App', () => { describe('Customer Orders App', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(loadDashboard).mockReset(); vi.mocked(loadDashboard).mockReset();
vi.mocked(loadOrderDetail).mockReset();
vi.mocked(loadOrderHistory).mockReset();
vi.mocked(submitCustomerOrder).mockReset(); vi.mocked(submitCustomerOrder).mockReset();
vi.mocked(getSessionMe).mockReset(); vi.mocked(getSessionMe).mockReset();
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
@ -42,14 +47,31 @@ describe('Customer Orders App', () => {
expect(link.href).toContain('tenantId=demo-tenant'); expect(link.href).toContain('tenantId=demo-tenant');
}); });
it('loads order status for authenticated users', async () => { it('loads status, history, and detail 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',
summary: '2 open orders',
orders: [{ orderId: 'ORD-1001', tableId: 'T-08', status: 'Preparing', guestCount: 2, itemIds: ['ITEM-101'] }],
recentEvents: ['status payload event']
});
vi.mocked(loadOrderHistory).mockResolvedValue({
contextId: 'demo-context',
summary: 'recent history',
orders: [{ orderId: 'ORD-0999', tableId: 'T-04', status: 'Served', guestCount: 3, itemIds: ['ITEM-202'] }],
recentEvents: ['Order ORD-0999 completed service and is ready for payment capture']
});
vi.mocked(loadOrderDetail).mockResolvedValue({
contextId: 'demo-context',
summary: 'selected order',
order: { orderId: 'ORD-1001', tableId: 'T-08', status: 'Preparing', guestCount: 2, itemIds: ['ITEM-101'] },
recentEvents: ['Order ORD-1001 confirmed']
}); });
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'open' });
render(<App />); render(<App />);
@ -57,6 +79,11 @@ describe('Customer Orders App', () => {
fireEvent.click(screen.getByRole('button', { name: 'Load Status' })); fireEvent.click(screen.getByRole('button', { name: 'Load Status' }));
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
expect(loadOrderHistory).toHaveBeenCalledWith('demo-context');
expect(loadOrderDetail).toHaveBeenCalledWith('demo-context', 'ORD-1001');
expect(await screen.findByText('2 open orders')).toBeInTheDocument();
expect(await screen.findByText('Order ORD-0999 completed service and is ready for payment capture')).toBeInTheDocument();
expect(await screen.findByText('Submitted customer orders progress through kitchen preparation and become payable only after service is complete.')).toBeInTheDocument();
}); });
it('submits customer order from action route', async () => { it('submits customer order from action route', async () => {
@ -64,22 +91,50 @@ describe('Customer Orders App', () => {
isAuthenticated: true, isAuthenticated: true,
subjectId: 'demo-user', subjectId: 'demo-user',
tenantId: 'demo-tenant', tenantId: 'demo-tenant',
provider: 0 provider: 2
}); });
vi.mocked(submitCustomerOrder).mockResolvedValue({ vi.mocked(submitCustomerOrder).mockResolvedValue({
contextId: 'demo-context', contextId: 'demo-context',
orderId: 'CO-2200', orderId: 'ORD-2200',
accepted: true, accepted: true,
summary: 'accepted' summary: 'Order ORD-2200 was accepted and is ready for kitchen dispatch.',
status: 'accepted'
}); });
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByText('Submit Order')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Submit Order')).toBeInTheDocument());
fireEvent.click(screen.getByText('Submit Order')); fireEvent.click(screen.getByText('Submit Order'));
fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'CO-2200' } }); fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } });
fireEvent.click(screen.getByRole('button', { name: 'Submit Customer Order' })); fireEvent.click(screen.getByRole('button', { name: 'Submit Customer Order' }));
await waitFor(() => expect(submitCustomerOrder).toHaveBeenCalledTimes(1)); await waitFor(() => expect(submitCustomerOrder).toHaveBeenCalledTimes(1));
expect((await screen.findAllByText('accepted')).length).toBeGreaterThan(0);
expect(await screen.findByText('The kitchen should receive this order next.')).toBeInTheDocument();
});
it('shows reauthentication guidance when status loading 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 Status' })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Load Status' }));
await waitFor(() => expect(screen.getByText('Authentication Required')).toBeInTheDocument());
expect(screen.getByText('Session expired')).toBeInTheDocument();
}); });
}); });

View File

@ -1,13 +1,22 @@
import { DeploymentUnitOutlined, ReloadOutlined, ShoppingCartOutlined, UnorderedListOutlined } from '@ant-design/icons'; import {
ClockCircleOutlined,
DeploymentUnitOutlined,
ReloadOutlined,
ShoppingCartOutlined,
SyncOutlined,
UnorderedListOutlined
} from '@ant-design/icons';
import { import {
Alert, Alert,
Button, Button,
Card, Card,
Descriptions, Descriptions,
Empty,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Layout, Layout,
List,
Menu, Menu,
Result, Result,
Space, Space,
@ -18,10 +27,16 @@ import {
} from 'antd'; } 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 { import {
loadDashboard, loadDashboard,
loadOrderDetail,
loadOrderHistory,
submitCustomerOrder, submitCustomerOrder,
type CustomerOrderDetailResponse,
type CustomerOrderHistoryResponse,
type CustomerOrderStatusResponse, type CustomerOrderStatusResponse,
type CustomerOrderSummary,
type SubmitCustomerOrderRequest, type SubmitCustomerOrderRequest,
type SubmitCustomerOrderResponse type SubmitCustomerOrderResponse
} from './api/dashboardApi'; } from './api/dashboardApi';
@ -39,12 +54,32 @@ type SubmitOrderFormValues = {
itemIdsText: string; itemIdsText: string;
}; };
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: '/status', label: 'Order Status', icon: <UnorderedListOutlined /> }, { key: '/status', label: 'Order Status', icon: <UnorderedListOutlined /> },
{ key: '/submit', label: 'Submit Order', icon: <ShoppingCartOutlined /> }, { key: '/submit', label: 'Submit Order', icon: <ShoppingCartOutlined /> },
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> } { key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
]; ];
const orderColumns = [
{ title: 'Order Id', dataIndex: 'orderId' },
{ title: 'Table Id', dataIndex: 'tableId' },
{
title: 'Status',
dataIndex: 'status',
render: (value: string) => <Tag color={workflowTagColor(value)}>{value}</Tag>
},
{ title: 'Guests', dataIndex: 'guestCount' },
{
title: 'Items',
render: (_: unknown, record: CustomerOrderSummary) => record.itemIds.join(', ')
}
];
function App() { function App() {
return ( return (
<SessionProvider> <SessionProvider>
@ -61,10 +96,13 @@ function CustomerOrdersShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const [contextId, setContextId] = useState('demo-context'); const [contextId, setContextId] = useState('demo-context');
const [statusOrderId, setStatusOrderId] = useState('ORD-1001');
const [statusPayload, setStatusPayload] = useState<CustomerOrderStatusResponse | null>(null); const [statusPayload, setStatusPayload] = useState<CustomerOrderStatusResponse | null>(null);
const [detailPayload, setDetailPayload] = useState<CustomerOrderDetailResponse | null>(null);
const [historyPayload, setHistoryPayload] = useState<CustomerOrderHistoryResponse | null>(null);
const [orderResponse, setOrderResponse] = useState<SubmitCustomerOrderResponse | null>(null); const [orderResponse, setOrderResponse] = useState<SubmitCustomerOrderResponse | null>(null);
const [orderHistory, setOrderHistory] = useState<SubmitCustomerOrderResponse[]>([]); const [orderHistory, setOrderHistory] = useState<SubmitCustomerOrderResponse[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null); const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
const [loadingStatus, setLoadingStatus] = useState(false); const [loadingStatus, setLoadingStatus] = useState(false);
const [submittingOrder, setSubmittingOrder] = useState(false); const [submittingOrder, setSubmittingOrder] = useState(false);
@ -74,14 +112,38 @@ function CustomerOrdersShell() {
return candidate?.key ?? '/status'; return candidate?.key ?? '/status';
}, [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 customer order workflows.',
sessionExpired: true
});
await session.revalidate();
return;
}
setWorkflowState({
error: err instanceof Error ? err.message : fallbackMessage,
sessionExpired: false
});
};
const loadStatus = async () => { const loadStatus = async () => {
setLoadingStatus(true); setLoadingStatus(true);
setGlobalError(null); clearWorkflowError();
try { try {
const payload = await loadDashboard(contextId); const [statusResult, historyResult, detailResult] = await Promise.all([
setStatusPayload(payload); loadDashboard(contextId),
loadOrderHistory(contextId),
statusOrderId.trim().length > 0 ? loadOrderDetail(contextId, statusOrderId.trim()) : Promise.resolve(null)
]);
setStatusPayload(statusResult);
setHistoryPayload(historyResult);
setDetailPayload(detailResult);
} catch (err) { } catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to load customer order status.'); await handleWorkflowFailure(err, 'Failed to load customer order status.');
} finally { } finally {
setLoadingStatus(false); setLoadingStatus(false);
} }
@ -89,7 +151,7 @@ function CustomerOrdersShell() {
const submitOrder = async (values: SubmitOrderFormValues) => { const submitOrder = async (values: SubmitOrderFormValues) => {
setSubmittingOrder(true); setSubmittingOrder(true);
setGlobalError(null); clearWorkflowError();
const request: SubmitCustomerOrderRequest = { const request: SubmitCustomerOrderRequest = {
contextId: values.contextId, contextId: values.contextId,
@ -105,10 +167,10 @@ function CustomerOrdersShell() {
try { try {
const payload = await submitCustomerOrder(request); const payload = await submitCustomerOrder(request);
setOrderResponse(payload); setOrderResponse(payload);
// Keep recent responses bounded so the session view stays readable over long demos.
setOrderHistory((previous) => [payload, ...previous].slice(0, 8)); setOrderHistory((previous) => [payload, ...previous].slice(0, 8));
setStatusOrderId(payload.orderId);
} catch (err) { } catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to submit customer order.'); await handleWorkflowFailure(err, 'Failed to submit customer order.');
} finally { } finally {
setSubmittingOrder(false); setSubmittingOrder(false);
} }
@ -136,6 +198,15 @@ function CustomerOrdersShell() {
} }
/> />
{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 customer order request returned 401, so the app is asking you to sign in again."
/>
)}
</main> </main>
); );
} }
@ -170,16 +241,31 @@ function CustomerOrdersShell() {
<Layout.Content className="content"> <Layout.Content className="content">
<Typography.Title level={3}>Customer Orders</Typography.Title> <Typography.Title level={3}>Customer Orders</Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
Protected order workflows for status lookup and order submission through the customer-orders BFF. Protected order workflows for status, detail, history, and submission through the shared restaurant lifecycle.
</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="/status" path="/status"
element={ element={
<Card title="Order Status"> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Order Status Dashboard">
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap> <Space wrap>
<Input <Input
@ -188,32 +274,93 @@ function CustomerOrdersShell() {
placeholder="Context Id" placeholder="Context Id"
style={{ width: 280 }} style={{ width: 280 }}
/> />
<Input
value={statusOrderId}
onChange={(event) => setStatusOrderId(event.target.value)}
placeholder="Order Id (optional detail)"
style={{ width: 280 }}
/>
<Button type="primary" loading={loadingStatus} onClick={() => void loadStatus()}> <Button type="primary" loading={loadingStatus} onClick={() => void loadStatus()}>
Load Status Load Status
</Button> </Button>
<Button icon={<SyncOutlined />} onClick={() => void loadStatus()} disabled={loadingStatus}>
Retry
</Button>
</Space> </Space>
{statusPayload ? ( {statusPayload ? (
<>
<Descriptions bordered size="small" column={1}> <Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{statusPayload.contextId}</Descriptions.Item> <Descriptions.Item label="Context Id">{statusPayload.contextId}</Descriptions.Item>
<Descriptions.Item label="Summary">{statusPayload.summary}</Descriptions.Item> <Descriptions.Item label="Summary">{statusPayload.summary}</Descriptions.Item>
<Descriptions.Item label="Lifecycle Note">
Submitted customer orders progress through kitchen preparation and become payable only after service is complete.
</Descriptions.Item>
</Descriptions> </Descriptions>
<Table<CustomerOrderSummary>
pagination={false}
rowKey={(record) => record.orderId}
dataSource={statusPayload.orders}
columns={orderColumns}
locale={{ emptyText: 'No current customer orders for this context.' }}
/>
</>
) : ( ) : (
<Typography.Text type="secondary">No order status loaded.</Typography.Text> <Empty description="Load a context to review current customer order status." />
)} )}
</Space> </Space>
</Card> </Card>
<Card title="Selected Order Detail">
{detailPayload?.order ? (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{detailPayload.contextId}</Descriptions.Item>
<Descriptions.Item label="Summary">{detailPayload.summary}</Descriptions.Item>
<Descriptions.Item label="Order Id">{detailPayload.order.orderId}</Descriptions.Item>
<Descriptions.Item label="Table Id">{detailPayload.order.tableId}</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={workflowTagColor(detailPayload.order.status)}>{detailPayload.order.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Guest Count">{detailPayload.order.guestCount}</Descriptions.Item>
<Descriptions.Item label="Items">{detailPayload.order.itemIds.join(', ')}</Descriptions.Item>
<Descriptions.Item label="Next Step">{orderProgressHint(detailPayload.order.status)}</Descriptions.Item>
</Descriptions>
) : (
<Empty description="Provide an order id to inspect detail alongside the status dashboard." />
)}
</Card>
<Card title="Recent Order History">
{historyPayload ? (
<Table<CustomerOrderSummary>
pagination={false}
rowKey={(record) => `${historyPayload.contextId}-${record.orderId}`}
dataSource={historyPayload.orders}
columns={orderColumns}
locale={{ emptyText: 'No recent customer orders for this context.' }}
/>
) : (
<Empty description="No order history loaded yet." />
)}
</Card>
<Card title="Recent Events" extra={<ClockCircleOutlined />}>
{historyPayload && historyPayload.recentEvents.length > 0 ? (
<List bordered dataSource={historyPayload.recentEvents} renderItem={(item) => <List.Item>{item}</List.Item>} />
) : (
<Empty description="No recent customer order events loaded yet." />
)}
</Card>
</Space>
} }
/> />
<Route <Route
path="/submit" path="/submit"
element={ element={
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Submit Customer Order"> <Card title="Submit Customer Order">
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Form<SubmitOrderFormValues> <Form<SubmitOrderFormValues>
layout="vertical" layout="vertical"
initialValues={{ initialValues={{
contextId, contextId,
orderId: 'CO-1001', orderId: 'ORD-1001',
tableId: 'T-08', tableId: 'T-08',
guestCount: 2, guestCount: 2,
itemIdsText: 'ITEM-101,ITEM-202' itemIdsText: 'ITEM-101,ITEM-202'
@ -239,18 +386,28 @@ function CustomerOrdersShell() {
Submit Customer Order Submit Customer Order
</Button> </Button>
</Form> </Form>
{orderResponse && ( {orderResponse ? (
<Descriptions bordered size="small" column={1}> <Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{orderResponse.contextId}</Descriptions.Item> <Descriptions.Item label="Context Id">{orderResponse.contextId}</Descriptions.Item>
<Descriptions.Item label="Order Id">{orderResponse.orderId}</Descriptions.Item> <Descriptions.Item label="Order Id">{orderResponse.orderId}</Descriptions.Item>
<Descriptions.Item label="Accepted">{String(orderResponse.accepted)}</Descriptions.Item> <Descriptions.Item label="Accepted">{String(orderResponse.accepted)}</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={workflowTagColor(orderResponse.status)}>{orderResponse.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Summary">{orderResponse.summary}</Descriptions.Item> <Descriptions.Item label="Summary">{orderResponse.summary}</Descriptions.Item>
<Descriptions.Item label="Next Step">{orderProgressHint(orderResponse.status)}</Descriptions.Item>
</Descriptions> </Descriptions>
) : (
<Empty description="Submit an order to inspect the accepted response payload." />
)} )}
</Space>
</Card>
<Card title="Recent Submission Results">
<Table<SubmitCustomerOrderResponse> <Table<SubmitCustomerOrderResponse>
pagination={false} pagination={false}
rowKey={(record) => `${record.contextId}-${record.orderId}`} rowKey={(record) => `${record.contextId}-${record.orderId}`}
dataSource={orderHistory} dataSource={orderHistory}
locale={{ emptyText: 'No recent customer order submissions yet.' }}
columns={[ columns={[
{ title: 'Order Id', dataIndex: 'orderId' }, { title: 'Order Id', dataIndex: 'orderId' },
{ title: 'Context Id', dataIndex: 'contextId' }, { title: 'Context Id', dataIndex: 'contextId' },
@ -258,11 +415,15 @@ function CustomerOrdersShell() {
title: 'Accepted', title: 'Accepted',
render: (_, record) => <Tag color={record.accepted ? 'green' : 'red'}>{String(record.accepted)}</Tag> render: (_, record) => <Tag color={record.accepted ? 'green' : 'red'}>{String(record.accepted)}</Tag>
}, },
{
title: 'Status',
render: (_, record) => <Tag color={workflowTagColor(record.status)}>{record.status}</Tag>
},
{ title: 'Summary', dataIndex: 'summary' } { title: 'Summary', dataIndex: 'summary' }
]} ]}
/> />
</Space>
</Card> </Card>
</Space>
} }
/> />
<Route <Route
@ -298,4 +459,48 @@ function providerLabel(provider: IdentityProvider): string {
return String(provider); return String(provider);
} }
function workflowTagColor(status: string): string {
switch (status.toLowerCase()) {
case 'accepted':
case 'submitted':
return 'blue';
case 'preparing':
case 'cooking':
return 'gold';
case 'ready':
case 'readyforpickup':
return 'cyan';
case 'served':
return 'green';
case 'paid':
return 'purple';
case 'blocked':
case 'failed':
case 'canceled':
return 'red';
default:
return 'default';
}
}
function orderProgressHint(status: string): string {
switch (status.toLowerCase()) {
case 'accepted':
case 'submitted':
return 'The kitchen should receive this order next.';
case 'preparing':
case 'cooking':
return 'Kitchen is actively preparing this order.';
case 'ready':
case 'readyforpickup':
return 'This order is ready for handoff or table delivery.';
case 'served':
return 'The restaurant can now open payment capture for this check.';
case 'paid':
return 'This order and its check are fully completed.';
default:
return 'Track this order across the shared restaurant lifecycle.';
}
}
export default App; export default App;

View File

@ -6,7 +6,7 @@ vi.mock('./client', () => ({
})); }));
import { getJson, postJson } from './client'; import { getJson, postJson } from './client';
import { loadDashboard, submitCustomerOrder } from './dashboardApi'; import { loadDashboard, loadOrderDetail, loadOrderHistory, submitCustomerOrder } from './dashboardApi';
describe('customer orders dashboard api', () => { describe('customer orders dashboard api', () => {
it('builds encoded status endpoint path', async () => { it('builds encoded status endpoint path', async () => {
@ -17,6 +17,22 @@ describe('customer orders dashboard api', () => {
expect(getJson).toHaveBeenCalledWith('/api/customer/orders/status?contextId=ctx%20customer%2F1'); expect(getJson).toHaveBeenCalledWith('/api/customer/orders/status?contextId=ctx%20customer%2F1');
}); });
it('builds encoded detail endpoint path', async () => {
vi.mocked(getJson).mockResolvedValue({ ok: true });
await loadOrderDetail('ctx customer/1', 'CO/1');
expect(getJson).toHaveBeenCalledWith('/api/customer/orders/CO%2F1?contextId=ctx%20customer%2F1');
});
it('builds encoded history endpoint path', async () => {
vi.mocked(getJson).mockResolvedValue({ ok: true });
await loadOrderHistory('ctx customer/1');
expect(getJson).toHaveBeenCalledWith('/api/customer/orders/history?contextId=ctx%20customer%2F1');
});
it('posts customer order payload', async () => { it('posts customer order payload', async () => {
vi.mocked(postJson).mockResolvedValue({ accepted: true }); vi.mocked(postJson).mockResolvedValue({ accepted: true });

View File

@ -1,8 +1,32 @@
import { getJson, postJson } from './client'; import { getJson, postJson } from './client';
export type CustomerOrderSummary = {
orderId: string;
tableId: string;
status: string;
guestCount: number;
itemIds: string[];
};
export type CustomerOrderStatusResponse = { export type CustomerOrderStatusResponse = {
contextId: string; contextId: string;
summary: string; summary: string;
orders: CustomerOrderSummary[];
recentEvents: string[];
};
export type CustomerOrderDetailResponse = {
contextId: string;
summary: string;
order: CustomerOrderSummary | null;
recentEvents: string[];
};
export type CustomerOrderHistoryResponse = {
contextId: string;
summary: string;
orders: CustomerOrderSummary[];
recentEvents: string[];
}; };
export type SubmitCustomerOrderRequest = { export type SubmitCustomerOrderRequest = {
@ -18,12 +42,23 @@ export type SubmitCustomerOrderResponse = {
orderId: string; orderId: string;
accepted: boolean; accepted: boolean;
summary: string; summary: string;
status: string;
}; };
export async function loadDashboard(contextId: string): Promise<CustomerOrderStatusResponse> { export async function loadDashboard(contextId: string): Promise<CustomerOrderStatusResponse> {
return getJson<CustomerOrderStatusResponse>(`/api/customer/orders/status?contextId=${encodeURIComponent(contextId)}`); return getJson<CustomerOrderStatusResponse>(`/api/customer/orders/status?contextId=${encodeURIComponent(contextId)}`);
} }
export async function loadOrderDetail(contextId: string, orderId: string): Promise<CustomerOrderDetailResponse> {
return getJson<CustomerOrderDetailResponse>(
`/api/customer/orders/${encodeURIComponent(orderId)}?contextId=${encodeURIComponent(contextId)}`
);
}
export async function loadOrderHistory(contextId: string): Promise<CustomerOrderHistoryResponse> {
return getJson<CustomerOrderHistoryResponse>(`/api/customer/orders/history?contextId=${encodeURIComponent(contextId)}`);
}
export async function submitCustomerOrder(request: SubmitCustomerOrderRequest): Promise<SubmitCustomerOrderResponse> { export async function submitCustomerOrder(request: SubmitCustomerOrderRequest): Promise<SubmitCustomerOrderResponse> {
return postJson<SubmitCustomerOrderResponse>('/api/customer/orders', request); return postJson<SubmitCustomerOrderResponse>('/api/customer/orders', request);
} }