feat(customer-orders-web): show shared order progression

This commit is contained in:
José René White Enciso 2026-03-31 19:03:54 -06:00
parent 429b58a629
commit d8da4aae5e
5 changed files with 82 additions and 24 deletions

View File

@ -23,9 +23,9 @@
## UI Workflow Coverage ## UI Workflow Coverage
- Customer order status dashboard with current orders - Customer order status dashboard with current orders from the shared restaurant lifecycle
- Selected order detail lookup - Selected order detail lookup
- Recent order history and event feed - Recent order history and event feed
- Customer order submission and recent submission results - Customer order submission and recent submission results with shared-lifecycle progression hints
- Session-expired handling with reauthentication guidance - 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

@ -25,8 +25,8 @@ npm run dev
## Available Screens ## Available Screens
- `/status`: current order status, selected order detail, history, and recent events - `/status`: current order status, selected order detail, history, recent events, and shared-lifecycle guidance
- `/submit`: customer order submission and recent submission results - `/submit`: customer order submission and recent submission results with kitchen/payment readiness hints
- `/session`: current Thalos session profile payload - `/session`: current Thalos session profile payload
## Build ## Build

View File

@ -17,7 +17,7 @@ npm run test:ci
- `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 for status, detail, history, and submit flows. - `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, status/detail/history loading, order submission, and session-expired reauthentication guidance. - `src/App.test.tsx`: central login screen, shared-lifecycle order messaging, order submission progression hints, and session-expired reauthentication guidance.
## Notes ## Notes

View File

@ -57,20 +57,20 @@ describe('Customer Orders App', () => {
vi.mocked(loadDashboard).mockResolvedValue({ vi.mocked(loadDashboard).mockResolvedValue({
contextId: 'demo-context', contextId: 'demo-context',
summary: '2 open orders', summary: '2 open orders',
orders: [{ orderId: 'CO-1001', tableId: 'T-08', status: 'Submitted', guestCount: 2, itemIds: ['ITEM-101'] }], orders: [{ orderId: 'ORD-1001', tableId: 'T-08', status: 'Preparing', guestCount: 2, itemIds: ['ITEM-101'] }],
recentEvents: ['status payload event'] recentEvents: ['status payload event']
}); });
vi.mocked(loadOrderHistory).mockResolvedValue({ vi.mocked(loadOrderHistory).mockResolvedValue({
contextId: 'demo-context', contextId: 'demo-context',
summary: 'recent history', summary: 'recent history',
orders: [{ orderId: 'CO-0999', tableId: 'T-04', status: 'Completed', guestCount: 3, itemIds: ['ITEM-202'] }], orders: [{ orderId: 'ORD-0999', tableId: 'T-04', status: 'Served', guestCount: 3, itemIds: ['ITEM-202'] }],
recentEvents: ['Order CO-0999 completed'] recentEvents: ['Order ORD-0999 completed service and is ready for payment capture']
}); });
vi.mocked(loadOrderDetail).mockResolvedValue({ vi.mocked(loadOrderDetail).mockResolvedValue({
contextId: 'demo-context', contextId: 'demo-context',
summary: 'selected order', summary: 'selected order',
order: { orderId: 'CO-1001', tableId: 'T-08', status: 'Submitted', guestCount: 2, itemIds: ['ITEM-101'] }, order: { orderId: 'ORD-1001', tableId: 'T-08', status: 'Preparing', guestCount: 2, itemIds: ['ITEM-101'] },
recentEvents: ['Order CO-1001 confirmed'] recentEvents: ['Order ORD-1001 confirmed']
}); });
render(<App />); render(<App />);
@ -80,9 +80,10 @@ describe('Customer Orders App', () => {
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
expect(loadOrderHistory).toHaveBeenCalledWith('demo-context'); expect(loadOrderHistory).toHaveBeenCalledWith('demo-context');
expect(loadOrderDetail).toHaveBeenCalledWith('demo-context', 'CO-1001'); expect(loadOrderDetail).toHaveBeenCalledWith('demo-context', 'ORD-1001');
expect(await screen.findByText('2 open orders')).toBeInTheDocument(); expect(await screen.findByText('2 open orders')).toBeInTheDocument();
expect(await screen.findByText('Order CO-0999 completed')).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 () => {
@ -94,21 +95,22 @@ describe('Customer Orders App', () => {
}); });
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: 'Submitted' 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('Submitted')).length).toBeGreaterThan(0); 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 () => { it('shows reauthentication guidance when status loading returns session expired', async () => {

View File

@ -71,7 +71,7 @@ const orderColumns = [
{ {
title: 'Status', title: 'Status',
dataIndex: 'status', dataIndex: 'status',
render: (value: string) => <Tag color={value === 'Submitted' ? 'blue' : 'green'}>{value}</Tag> render: (value: string) => <Tag color={workflowTagColor(value)}>{value}</Tag>
}, },
{ title: 'Guests', dataIndex: 'guestCount' }, { title: 'Guests', dataIndex: 'guestCount' },
{ {
@ -96,7 +96,7 @@ function CustomerOrdersShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const [contextId, setContextId] = useState('demo-context'); const [contextId, setContextId] = useState('demo-context');
const [statusOrderId, setStatusOrderId] = useState('CO-1001'); 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 [detailPayload, setDetailPayload] = useState<CustomerOrderDetailResponse | null>(null);
const [historyPayload, setHistoryPayload] = useState<CustomerOrderHistoryResponse | null>(null); const [historyPayload, setHistoryPayload] = useState<CustomerOrderHistoryResponse | null>(null);
@ -241,7 +241,7 @@ 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, detail, history, and 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} />}
{workflowState.error && ( {workflowState.error && (
@ -292,6 +292,9 @@ function CustomerOrdersShell() {
<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> <Table<CustomerOrderSummary>
pagination={false} pagination={false}
@ -313,9 +316,12 @@ function CustomerOrdersShell() {
<Descriptions.Item label="Summary">{detailPayload.summary}</Descriptions.Item> <Descriptions.Item label="Summary">{detailPayload.summary}</Descriptions.Item>
<Descriptions.Item label="Order Id">{detailPayload.order.orderId}</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="Table Id">{detailPayload.order.tableId}</Descriptions.Item>
<Descriptions.Item label="Status">{detailPayload.order.status}</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="Guest Count">{detailPayload.order.guestCount}</Descriptions.Item>
<Descriptions.Item label="Items">{detailPayload.order.itemIds.join(', ')}</Descriptions.Item> <Descriptions.Item label="Items">{detailPayload.order.itemIds.join(', ')}</Descriptions.Item>
<Descriptions.Item label="Next Step">{orderProgressHint(detailPayload.order.status)}</Descriptions.Item>
</Descriptions> </Descriptions>
) : ( ) : (
<Empty description="Provide an order id to inspect detail alongside the status dashboard." /> <Empty description="Provide an order id to inspect detail alongside the status dashboard." />
@ -354,7 +360,7 @@ function CustomerOrdersShell() {
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'
@ -385,8 +391,11 @@ function CustomerOrdersShell() {
<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">{orderResponse.status}</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." /> <Empty description="Submit an order to inspect the accepted response payload." />
@ -406,7 +415,10 @@ 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', dataIndex: 'status' }, {
title: 'Status',
render: (_, record) => <Tag color={workflowTagColor(record.status)}>{record.status}</Tag>
},
{ title: 'Summary', dataIndex: 'summary' } { title: 'Summary', dataIndex: 'summary' }
]} ]}
/> />
@ -447,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;