Compare commits
3 Commits
feature/wa
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c8567b14 | ||
|
|
a0dc0a29ed | ||
|
|
e019a069dd |
@ -5,6 +5,7 @@
|
|||||||
- The UI does not access DAL or internal services directly.
|
- The UI does not access DAL or internal services directly.
|
||||||
- Route shell uses Ant Design layout/menu and keeps business views behind session checks.
|
- Route shell uses Ant Design layout/menu and keeps business views behind session checks.
|
||||||
- Unauthenticated users are redirected to the central auth host OIDC start endpoint.
|
- Unauthenticated users are redirected to the central auth host OIDC start endpoint.
|
||||||
|
- Session-expired responses are treated as an auth boundary concern and trigger revalidation before the UI prompts for login again.
|
||||||
|
|
||||||
## Runtime Base URLs
|
## Runtime Base URLs
|
||||||
|
|
||||||
@ -16,10 +17,14 @@
|
|||||||
## Protected Workflow Endpoints
|
## Protected Workflow Endpoints
|
||||||
|
|
||||||
- `GET /api/waiter/floor/assignments?contextId=...`
|
- `GET /api/waiter/floor/assignments?contextId=...`
|
||||||
|
- `GET /api/waiter/floor/activity?contextId=...`
|
||||||
- `POST /api/waiter/floor/orders`
|
- `POST /api/waiter/floor/orders`
|
||||||
|
- `PUT /api/waiter/floor/orders/{orderId}`
|
||||||
|
|
||||||
## UI Workflow Coverage
|
## UI Workflow Coverage
|
||||||
|
|
||||||
- Waiter assignment lookup
|
- Waiter assignment snapshot with location metadata and active-order counts derived from the shared restaurant lifecycle
|
||||||
- Floor order submission
|
- Recent waiter activity history feed
|
||||||
- Protected route shell for assignments, order submission, and session inspection
|
- Floor order submission and order update workflows that feed the shared restaurant order/check model
|
||||||
|
- Session-expired handling with reauthentication guidance
|
||||||
|
- Protected route shell for assignments, order actions, 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
|
||||||
|
|
||||||
|
- `/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
|
||||||
|
|
||||||
|
|||||||
@ -15,10 +15,11 @@ npm run test:ci
|
|||||||
## Coverage Scope
|
## Coverage Scope
|
||||||
|
|
||||||
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
|
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
|
||||||
- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
|
- `src/api/dashboardApi.test.ts`: endpoint path/query composition, activity loading, and order update payload mapping.
|
||||||
- `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
|
- `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
|
||||||
- `src/App.test.tsx`: central login screen, protected assignment flow, and order submission workflow.
|
- `src/App.test.tsx`: central login screen, shared-lifecycle assignment messaging, order submit/update 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.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ApiError } from './api/client';
|
||||||
|
|
||||||
vi.mock('./api/sessionApi', () => ({
|
vi.mock('./api/sessionApi', () => ({
|
||||||
getSessionMe: vi.fn(),
|
getSessionMe: vi.fn(),
|
||||||
@ -10,17 +11,21 @@ vi.mock('./api/sessionApi', () => ({
|
|||||||
|
|
||||||
vi.mock('./api/dashboardApi', () => ({
|
vi.mock('./api/dashboardApi', () => ({
|
||||||
loadDashboard: vi.fn(),
|
loadDashboard: vi.fn(),
|
||||||
submitFloorOrder: vi.fn()
|
loadRecentActivity: vi.fn(),
|
||||||
|
submitFloorOrder: vi.fn(),
|
||||||
|
updateFloorOrder: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { loadDashboard, submitFloorOrder } from './api/dashboardApi';
|
import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './api/dashboardApi';
|
||||||
import { getSessionMe } from './api/sessionApi';
|
import { getSessionMe } from './api/sessionApi';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
describe('Waiter Floor App', () => {
|
describe('Waiter Floor App', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(loadDashboard).mockReset();
|
vi.mocked(loadDashboard).mockReset();
|
||||||
|
vi.mocked(loadRecentActivity).mockReset();
|
||||||
vi.mocked(submitFloorOrder).mockReset();
|
vi.mocked(submitFloorOrder).mockReset();
|
||||||
|
vi.mocked(updateFloorOrder).mockReset();
|
||||||
vi.mocked(getSessionMe).mockReset();
|
vi.mocked(getSessionMe).mockReset();
|
||||||
window.__APP_CONFIG__ = {
|
window.__APP_CONFIG__ = {
|
||||||
API_BASE_URL: 'http://localhost:8080',
|
API_BASE_URL: 'http://localhost:8080',
|
||||||
@ -42,14 +47,26 @@ describe('Waiter Floor App', () => {
|
|||||||
expect(link.href).toContain('tenantId=demo-tenant');
|
expect(link.href).toContain('tenantId=demo-tenant');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads assignments for authenticated users', async () => {
|
it('loads assignments and recent activity for authenticated users', async () => {
|
||||||
vi.mocked(getSessionMe).mockResolvedValue({
|
vi.mocked(getSessionMe).mockResolvedValue({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
subjectId: 'demo-user',
|
subjectId: 'demo-user',
|
||||||
tenantId: 'demo-tenant',
|
tenantId: 'demo-tenant',
|
||||||
provider: 0
|
provider: 2
|
||||||
|
});
|
||||||
|
vi.mocked(loadDashboard).mockResolvedValue({
|
||||||
|
contextId: 'demo-context',
|
||||||
|
locationId: 'floor-a',
|
||||||
|
summary: '2 waiters assigned',
|
||||||
|
assignments: [{ waiterId: '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 />);
|
||||||
|
|
||||||
@ -57,24 +74,76 @@ 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 floor orders from action route', async () => {
|
it('submits and updates floor orders from the order route', async () => {
|
||||||
vi.mocked(getSessionMe).mockResolvedValue({
|
vi.mocked(getSessionMe).mockResolvedValue({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
subjectId: 'demo-user',
|
subjectId: 'demo-user',
|
||||||
tenantId: 'demo-tenant',
|
tenantId: 'demo-tenant',
|
||||||
provider: 0
|
provider: 2
|
||||||
|
});
|
||||||
|
vi.mocked(submitFloorOrder).mockResolvedValue({
|
||||||
|
contextId: 'demo-context',
|
||||||
|
orderId: 'ORD-2200',
|
||||||
|
accepted: true,
|
||||||
|
summary: '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('Submit Order')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Order Actions')).toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByText('Submit Order'));
|
fireEvent.click(screen.getByText('Order Actions'));
|
||||||
fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } });
|
fireEvent.change(screen.getAllByPlaceholderText('Order Id')[0], { target: { value: 'ORD-2200' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Submit Floor Order' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Submit Floor Order' }));
|
||||||
|
|
||||||
await waitFor(() => expect(submitFloorOrder).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(submitFloorOrder).toHaveBeenCalledTimes(1));
|
||||||
|
expect((await screen.findAllByText('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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
292
src/App.tsx
292
src/App.tsx
@ -1,26 +1,76 @@
|
|||||||
import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons';
|
import {
|
||||||
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Layout, Menu, Result, Space, Spin, Table, Tag, Typography } from 'antd';
|
ClockCircleOutlined,
|
||||||
|
DeploymentUnitOutlined,
|
||||||
|
OrderedListOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ShoppingCartOutlined,
|
||||||
|
SyncOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Empty,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Layout,
|
||||||
|
List,
|
||||||
|
Menu,
|
||||||
|
Result,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography
|
||||||
|
} from 'antd';
|
||||||
import { type ReactNode, useMemo, useState } from 'react';
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { ApiError } from './api/client';
|
||||||
import type { IdentityProvider } from './api/sessionApi';
|
import type { IdentityProvider } from './api/sessionApi';
|
||||||
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
||||||
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
|
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
|
||||||
import {
|
import {
|
||||||
loadDashboard,
|
loadDashboard,
|
||||||
|
loadRecentActivity,
|
||||||
submitFloorOrder,
|
submitFloorOrder,
|
||||||
|
updateFloorOrder,
|
||||||
type SubmitFloorOrderRequest,
|
type SubmitFloorOrderRequest,
|
||||||
type SubmitFloorOrderResponse,
|
type SubmitFloorOrderResponse,
|
||||||
type WaiterAssignmentsResponse
|
type UpdateFloorOrderRequest,
|
||||||
|
type UpdateFloorOrderResponse,
|
||||||
|
type WaiterAssignmentsResponse,
|
||||||
|
type WaiterRecentActivityResponse
|
||||||
} from './api/dashboardApi';
|
} from './api/dashboardApi';
|
||||||
|
|
||||||
type AppRoute = '/assignments' | '/orders' | '/session';
|
type AppRoute = '/assignments' | '/orders' | '/session';
|
||||||
|
type OrderEvent =
|
||||||
|
| { kind: 'submitted'; response: SubmitFloorOrderResponse }
|
||||||
|
| { kind: 'updated'; response: UpdateFloorOrderResponse };
|
||||||
|
|
||||||
|
type WorkflowState = {
|
||||||
|
error: string | null;
|
||||||
|
sessionExpired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
|
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
|
||||||
{ key: '/assignments', label: 'Assignments', icon: <OrderedListOutlined /> },
|
{ key: '/assignments', label: 'Assignments', icon: <OrderedListOutlined /> },
|
||||||
{ key: '/orders', label: 'Submit Order', icon: <ShoppingCartOutlined /> },
|
{ key: '/orders', label: 'Order Actions', icon: <ShoppingCartOutlined /> },
|
||||||
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
|
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const assignmentColumns = [
|
||||||
|
{ title: 'Waiter Id', dataIndex: 'waiterId' },
|
||||||
|
{ title: 'Table Id', dataIndex: 'tableId' },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (value: string) => <Tag color={workflowTagColor(value)}>{value}</Tag>
|
||||||
|
},
|
||||||
|
{ title: 'Active Orders', dataIndex: 'activeOrders' }
|
||||||
|
];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
@ -38,11 +88,13 @@ function WaiterFloorShell() {
|
|||||||
|
|
||||||
const [contextId, setContextId] = useState('demo-context');
|
const [contextId, setContextId] = useState('demo-context');
|
||||||
const [assignments, setAssignments] = useState<WaiterAssignmentsResponse | null>(null);
|
const [assignments, setAssignments] = useState<WaiterAssignmentsResponse | null>(null);
|
||||||
const [orderResponse, setOrderResponse] = useState<SubmitFloorOrderResponse | null>(null);
|
const [recentActivity, setRecentActivity] = useState<WaiterRecentActivityResponse | null>(null);
|
||||||
const [orderHistory, setOrderHistory] = useState<SubmitFloorOrderResponse[]>([]);
|
const [lastOrderResponse, setLastOrderResponse] = useState<SubmitFloorOrderResponse | UpdateFloorOrderResponse | null>(null);
|
||||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
const [orderHistory, setOrderHistory] = useState<OrderEvent[]>([]);
|
||||||
|
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
|
||||||
const [loadingAssignments, setLoadingAssignments] = useState(false);
|
const [loadingAssignments, setLoadingAssignments] = useState(false);
|
||||||
const [submittingOrder, setSubmittingOrder] = useState(false);
|
const [submittingOrder, setSubmittingOrder] = useState(false);
|
||||||
|
const [updatingOrder, setUpdatingOrder] = useState(false);
|
||||||
|
|
||||||
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
|
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
|
||||||
const selectedKey = useMemo(() => {
|
const selectedKey = useMemo(() => {
|
||||||
@ -50,14 +102,33 @@ function WaiterFloorShell() {
|
|||||||
return candidate?.key ?? '/assignments';
|
return candidate?.key ?? '/assignments';
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const clearWorkflowError = () => setWorkflowState({ error: null, sessionExpired: false });
|
||||||
|
|
||||||
|
const handleWorkflowFailure = async (err: unknown, fallbackMessage: string) => {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
setWorkflowState({ error: 'Your session expired. Sign in again to continue floor operations.', sessionExpired: true });
|
||||||
|
await session.revalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkflowState({
|
||||||
|
error: err instanceof Error ? err.message : fallbackMessage,
|
||||||
|
sessionExpired: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadAssignments = async () => {
|
const loadAssignments = async () => {
|
||||||
setLoadingAssignments(true);
|
setLoadingAssignments(true);
|
||||||
setGlobalError(null);
|
clearWorkflowError();
|
||||||
try {
|
try {
|
||||||
const payload = await loadDashboard(contextId);
|
const [assignmentsPayload, activityPayload] = await Promise.all([
|
||||||
setAssignments(payload);
|
loadDashboard(contextId),
|
||||||
|
loadRecentActivity(contextId)
|
||||||
|
]);
|
||||||
|
setAssignments(assignmentsPayload);
|
||||||
|
setRecentActivity(activityPayload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setGlobalError(err instanceof Error ? err.message : 'Failed to load waiter assignments.');
|
await handleWorkflowFailure(err, 'Failed to load waiter assignments.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAssignments(false);
|
setLoadingAssignments(false);
|
||||||
}
|
}
|
||||||
@ -65,18 +136,32 @@ function WaiterFloorShell() {
|
|||||||
|
|
||||||
const submitOrder = async (request: SubmitFloorOrderRequest) => {
|
const submitOrder = async (request: SubmitFloorOrderRequest) => {
|
||||||
setSubmittingOrder(true);
|
setSubmittingOrder(true);
|
||||||
setGlobalError(null);
|
clearWorkflowError();
|
||||||
try {
|
try {
|
||||||
const payload = await submitFloorOrder(request);
|
const payload = await submitFloorOrder(request);
|
||||||
setOrderResponse(payload);
|
setLastOrderResponse(payload);
|
||||||
setOrderHistory((previous) => [payload, ...previous].slice(0, 8));
|
setOrderHistory((previous) => [{ kind: 'submitted' as const, response: payload }, ...previous].slice(0, 8));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setGlobalError(err instanceof Error ? err.message : 'Failed to submit floor order.');
|
await handleWorkflowFailure(err, 'Failed to submit floor order.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmittingOrder(false);
|
setSubmittingOrder(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reviseOrder = async (request: UpdateFloorOrderRequest) => {
|
||||||
|
setUpdatingOrder(true);
|
||||||
|
clearWorkflowError();
|
||||||
|
try {
|
||||||
|
const payload = await updateFloorOrder(request);
|
||||||
|
setLastOrderResponse(payload);
|
||||||
|
setOrderHistory((previous) => [{ kind: 'updated' as const, response: payload }, ...previous].slice(0, 8));
|
||||||
|
} catch (err) {
|
||||||
|
await handleWorkflowFailure(err, 'Failed to update floor order.');
|
||||||
|
} finally {
|
||||||
|
setUpdatingOrder(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (session.status === 'loading') {
|
if (session.status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="fullscreen-center">
|
<div className="fullscreen-center">
|
||||||
@ -99,6 +184,15 @@ function WaiterFloorShell() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{session.error && <Alert type="error" showIcon message={session.error} />}
|
{session.error && <Alert type="error" showIcon message={session.error} />}
|
||||||
|
{workflowState.sessionExpired && (
|
||||||
|
<Alert
|
||||||
|
className="stack-gap"
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="Session expired"
|
||||||
|
description="Your last workflow action returned 401, so the app is asking you to sign in again."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -133,16 +227,31 @@ function WaiterFloorShell() {
|
|||||||
<Layout.Content className="content">
|
<Layout.Content className="content">
|
||||||
<Typography.Title level={3}>Waiter Floor Operations</Typography.Title>
|
<Typography.Title level={3}>Waiter Floor Operations</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
Protected floor workflows for assignment visibility and order submission.
|
Protected floor workflows for assignment visibility, recent activity, and order actions that now feed the same restaurant lifecycle used by kitchen and POS.
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
|
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
|
||||||
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
|
{workflowState.error && (
|
||||||
|
<Alert
|
||||||
|
className="stack-gap"
|
||||||
|
type={workflowState.sessionExpired ? 'warning' : 'error'}
|
||||||
|
showIcon
|
||||||
|
message={workflowState.error}
|
||||||
|
action={
|
||||||
|
workflowState.sessionExpired ? (
|
||||||
|
<Button size="small" type="primary" href={loginUrl}>
|
||||||
|
Reauthenticate
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/assignments"
|
path="/assignments"
|
||||||
element={
|
element={
|
||||||
<Card title="Assignments">
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<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
|
||||||
@ -154,24 +263,55 @@ 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.' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">No assignment snapshot loaded.</Typography.Text>
|
<Empty description="Load a context to review assignment coverage." />
|
||||||
)}
|
)}
|
||||||
</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={
|
||||||
<Card title="Submit Floor Order">
|
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<Card title="Submit Floor Order">
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
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={{
|
||||||
@ -198,28 +338,77 @@ function WaiterFloorShell() {
|
|||||||
Submit Floor Order
|
Submit Floor Order
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
{orderResponse && (
|
</Card>
|
||||||
|
<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="Order Id">{orderResponse.orderId}</Descriptions.Item>
|
<Descriptions.Item label="Context Id">{lastOrderResponse.contextId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Accepted">{String(orderResponse.accepted)}</Descriptions.Item>
|
<Descriptions.Item label="Order Id">{lastOrderResponse.orderId}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Message">{orderResponse.message}</Descriptions.Item>
|
<Descriptions.Item label="Accepted">{String(lastOrderResponse.accepted)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Status">
|
||||||
|
<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." />
|
||||||
)}
|
)}
|
||||||
<Table<SubmitFloorOrderResponse>
|
</Card>
|
||||||
|
<Card title="Recent Workflow Actions">
|
||||||
|
<Table<OrderEvent>
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey={(record) => record.orderId}
|
rowKey={(record) => `${record.kind}-${record.response.orderId}-${record.response.processedAtUtc}`}
|
||||||
dataSource={orderHistory}
|
dataSource={orderHistory}
|
||||||
|
locale={{ emptyText: 'No recent floor order actions yet.' }}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'Order Id', dataIndex: 'orderId' },
|
|
||||||
{
|
{
|
||||||
title: 'Accepted',
|
title: 'Action',
|
||||||
render: (_, record) => <Tag color={record.accepted ? 'green' : 'red'}>{String(record.accepted)}</Tag>
|
render: (_, record) => <Tag color={record.kind === 'submitted' ? 'green' : 'orange'}>{record.kind}</Tag>
|
||||||
},
|
},
|
||||||
{ title: 'Message', dataIndex: 'message' }
|
{ title: 'Order Id', render: (_, record) => record.response.orderId },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
render: (_, record) => <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 }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
@ -255,4 +444,45 @@ 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;
|
||||||
|
|||||||
@ -82,6 +82,17 @@ export async function postJson<TResponse>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function putJson<TResponse>(
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
baseUrl = getApiBaseUrl()
|
||||||
|
): Promise<TResponse> {
|
||||||
|
return requestJson<TResponse>(baseUrl, path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function postNoContent(
|
export async function postNoContent(
|
||||||
path: string,
|
path: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@ -2,11 +2,12 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
vi.mock('./client', () => ({
|
vi.mock('./client', () => ({
|
||||||
getJson: vi.fn(),
|
getJson: vi.fn(),
|
||||||
postJson: vi.fn()
|
postJson: vi.fn(),
|
||||||
|
putJson: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getJson, postJson } from './client';
|
import { getJson, postJson, putJson } from './client';
|
||||||
import { loadDashboard, submitFloorOrder } from './dashboardApi';
|
import { loadDashboard, loadRecentActivity, submitFloorOrder, updateFloorOrder } from './dashboardApi';
|
||||||
|
|
||||||
describe('waiter floor dashboard api', () => {
|
describe('waiter floor dashboard api', () => {
|
||||||
it('builds encoded assignments endpoint path', async () => {
|
it('builds encoded assignments endpoint path', async () => {
|
||||||
@ -17,6 +18,14 @@ describe('waiter floor dashboard api', () => {
|
|||||||
expect(getJson).toHaveBeenCalledWith('/api/waiter/floor/assignments?contextId=ctx%20floor%2F1');
|
expect(getJson).toHaveBeenCalledWith('/api/waiter/floor/assignments?contextId=ctx%20floor%2F1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds encoded activity endpoint path', async () => {
|
||||||
|
vi.mocked(getJson).mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
await loadRecentActivity('ctx floor/1');
|
||||||
|
|
||||||
|
expect(getJson).toHaveBeenCalledWith('/api/waiter/floor/activity?contextId=ctx%20floor%2F1');
|
||||||
|
});
|
||||||
|
|
||||||
it('posts floor order payload', async () => {
|
it('posts floor order payload', async () => {
|
||||||
vi.mocked(postJson).mockResolvedValue({ accepted: true });
|
vi.mocked(postJson).mockResolvedValue({ accepted: true });
|
||||||
|
|
||||||
@ -34,4 +43,21 @@ describe('waiter floor dashboard api', () => {
|
|||||||
itemCount: 2
|
itemCount: 2
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('puts floor order update payload without duplicating order id in the body', async () => {
|
||||||
|
vi.mocked(putJson).mockResolvedValue({ accepted: true });
|
||||||
|
|
||||||
|
await updateFloorOrder({
|
||||||
|
contextId: 'ctx',
|
||||||
|
tableId: 'T-1',
|
||||||
|
orderId: 'ORD/1',
|
||||||
|
itemCount: 4
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(putJson).toHaveBeenCalledWith('/api/waiter/floor/orders/ORD%2F1', {
|
||||||
|
contextId: 'ctx',
|
||||||
|
tableId: 'T-1',
|
||||||
|
itemCount: 4
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,25 @@
|
|||||||
import { getJson, postJson } from './client';
|
import { getJson, postJson, putJson } from './client';
|
||||||
|
|
||||||
|
export type WaiterAssignment = {
|
||||||
|
waiterId: string;
|
||||||
|
tableId: string;
|
||||||
|
status: string;
|
||||||
|
activeOrders: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type WaiterAssignmentsResponse = {
|
export type WaiterAssignmentsResponse = {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
|
locationId: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
assignments: WaiterAssignment[];
|
||||||
|
recentActivity: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WaiterRecentActivityResponse = {
|
||||||
|
contextId: string;
|
||||||
|
locationId: string;
|
||||||
|
summary: string;
|
||||||
|
recentActivity: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SubmitFloorOrderRequest = {
|
export type SubmitFloorOrderRequest = {
|
||||||
@ -13,15 +30,46 @@ export type SubmitFloorOrderRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SubmitFloorOrderResponse = {
|
export type SubmitFloorOrderResponse = {
|
||||||
|
contextId: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
message: string;
|
summary: string;
|
||||||
|
status: string;
|
||||||
|
processedAtUtc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateFloorOrderRequest = {
|
||||||
|
contextId: string;
|
||||||
|
tableId: string;
|
||||||
|
orderId: string;
|
||||||
|
itemCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateFloorOrderResponse = {
|
||||||
|
contextId: string;
|
||||||
|
orderId: string;
|
||||||
|
accepted: boolean;
|
||||||
|
summary: string;
|
||||||
|
status: string;
|
||||||
|
processedAtUtc: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadDashboard(contextId: string): Promise<WaiterAssignmentsResponse> {
|
export async function loadDashboard(contextId: string): Promise<WaiterAssignmentsResponse> {
|
||||||
return getJson<WaiterAssignmentsResponse>(`/api/waiter/floor/assignments?contextId=${encodeURIComponent(contextId)}`);
|
return getJson<WaiterAssignmentsResponse>(`/api/waiter/floor/assignments?contextId=${encodeURIComponent(contextId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadRecentActivity(contextId: string): Promise<WaiterRecentActivityResponse> {
|
||||||
|
return getJson<WaiterRecentActivityResponse>(`/api/waiter/floor/activity?contextId=${encodeURIComponent(contextId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitFloorOrder(request: SubmitFloorOrderRequest): Promise<SubmitFloorOrderResponse> {
|
export async function submitFloorOrder(request: SubmitFloorOrderRequest): Promise<SubmitFloorOrderResponse> {
|
||||||
return postJson<SubmitFloorOrderResponse>('/api/waiter/floor/orders', request);
|
return postJson<SubmitFloorOrderResponse>('/api/waiter/floor/orders', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateFloorOrder(request: UpdateFloorOrderRequest): Promise<UpdateFloorOrderResponse> {
|
||||||
|
return putJson<UpdateFloorOrderResponse>(`/api/waiter/floor/orders/${encodeURIComponent(request.orderId)}`, {
|
||||||
|
contextId: request.contextId,
|
||||||
|
tableId: request.tableId,
|
||||||
|
itemCount: request.itemCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user