feat(customer-orders-web): deepen customer order workflows
This commit is contained in:
parent
55458cd79f
commit
429b58a629
@ -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
|
||||||
- Customer order submission
|
- Selected order detail lookup
|
||||||
|
- Recent order history and event feed
|
||||||
|
- Customer order submission and recent submission results
|
||||||
|
- Session-expired handling with reauthentication guidance
|
||||||
- Protected route shell for status, submission, and session inspection
|
- Protected route shell for status, submission, and session inspection
|
||||||
|
|||||||
@ -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, and recent events
|
||||||
|
- `/submit`: customer order submission and recent submission results
|
||||||
|
- `/session`: current Thalos session profile payload
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@ -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, status/detail/history loading, order submission, 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.
|
||||||
|
|||||||
@ -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: 'CO-1001', tableId: 'T-08', status: 'Submitted', guestCount: 2, itemIds: ['ITEM-101'] }],
|
||||||
|
recentEvents: ['status payload event']
|
||||||
|
});
|
||||||
|
vi.mocked(loadOrderHistory).mockResolvedValue({
|
||||||
|
contextId: 'demo-context',
|
||||||
|
summary: 'recent history',
|
||||||
|
orders: [{ orderId: 'CO-0999', tableId: 'T-04', status: 'Completed', guestCount: 3, itemIds: ['ITEM-202'] }],
|
||||||
|
recentEvents: ['Order CO-0999 completed']
|
||||||
|
});
|
||||||
|
vi.mocked(loadOrderDetail).mockResolvedValue({
|
||||||
|
contextId: 'demo-context',
|
||||||
|
summary: 'selected order',
|
||||||
|
order: { orderId: 'CO-1001', tableId: 'T-08', status: 'Submitted', guestCount: 2, itemIds: ['ITEM-101'] },
|
||||||
|
recentEvents: ['Order CO-1001 confirmed']
|
||||||
});
|
});
|
||||||
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'open' });
|
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
@ -57,6 +79,10 @@ 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', 'CO-1001');
|
||||||
|
expect(await screen.findByText('2 open orders')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Order CO-0999 completed')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits customer order from action route', async () => {
|
it('submits customer order from action route', async () => {
|
||||||
@ -64,13 +90,14 @@ 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: 'CO-2200',
|
||||||
accepted: true,
|
accepted: true,
|
||||||
summary: 'accepted'
|
summary: 'accepted',
|
||||||
|
status: 'Submitted'
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@ -81,5 +108,31 @@ describe('Customer Orders App', () => {
|
|||||||
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
179
src/App.tsx
179
src/App.tsx
@ -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={value === 'Submitted' ? 'blue' : 'green'}>{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('CO-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 customer-orders BFF.
|
||||||
</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,25 +274,80 @@ 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>
|
</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">{detailPayload.order.status}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Guest Count">{detailPayload.order.guestCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Items">{detailPayload.order.itemIds.join(', ')}</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>
|
||||||
@ -239,18 +380,25 @@ 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">{orderResponse.status}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Summary">{orderResponse.summary}</Descriptions.Item>
|
<Descriptions.Item label="Summary">{orderResponse.summary}</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 +406,12 @@ 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: 'Summary', dataIndex: 'summary' }
|
{ title: 'Summary', dataIndex: 'summary' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user