waiter-floor-web/src/App.tsx
2026-03-31 19:00:52 -06:00

489 lines
19 KiB
TypeScript

import {
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 { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { ApiError } from './api/client';
import type { IdentityProvider } from './api/sessionApi';
import { SessionProvider, useSessionContext } from './auth/sessionContext';
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
import {
loadDashboard,
loadRecentActivity,
submitFloorOrder,
updateFloorOrder,
type SubmitFloorOrderRequest,
type SubmitFloorOrderResponse,
type UpdateFloorOrderRequest,
type UpdateFloorOrderResponse,
type WaiterAssignmentsResponse,
type WaiterRecentActivityResponse
} from './api/dashboardApi';
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 }> = [
{ key: '/assignments', label: 'Assignments', icon: <OrderedListOutlined /> },
{ key: '/orders', label: 'Order Actions', icon: <ShoppingCartOutlined /> },
{ 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() {
return (
<SessionProvider>
<BrowserRouter>
<WaiterFloorShell />
</BrowserRouter>
</SessionProvider>
);
}
function WaiterFloorShell() {
const session = useSessionContext();
const location = useLocation();
const navigate = useNavigate();
const [contextId, setContextId] = useState('demo-context');
const [assignments, setAssignments] = useState<WaiterAssignmentsResponse | null>(null);
const [recentActivity, setRecentActivity] = useState<WaiterRecentActivityResponse | null>(null);
const [lastOrderResponse, setLastOrderResponse] = useState<SubmitFloorOrderResponse | UpdateFloorOrderResponse | null>(null);
const [orderHistory, setOrderHistory] = useState<OrderEvent[]>([]);
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
const [loadingAssignments, setLoadingAssignments] = useState(false);
const [submittingOrder, setSubmittingOrder] = useState(false);
const [updatingOrder, setUpdatingOrder] = useState(false);
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
const selectedKey = useMemo(() => {
const candidate = routeItems.find((item) => location.pathname.startsWith(item.key));
return candidate?.key ?? '/assignments';
}, [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 () => {
setLoadingAssignments(true);
clearWorkflowError();
try {
const [assignmentsPayload, activityPayload] = await Promise.all([
loadDashboard(contextId),
loadRecentActivity(contextId)
]);
setAssignments(assignmentsPayload);
setRecentActivity(activityPayload);
} catch (err) {
await handleWorkflowFailure(err, 'Failed to load waiter assignments.');
} finally {
setLoadingAssignments(false);
}
};
const submitOrder = async (request: SubmitFloorOrderRequest) => {
setSubmittingOrder(true);
clearWorkflowError();
try {
const payload = await submitFloorOrder(request);
setLastOrderResponse(payload);
setOrderHistory((previous) => [{ kind: 'submitted' as const, response: payload }, ...previous].slice(0, 8));
} catch (err) {
await handleWorkflowFailure(err, 'Failed to submit floor order.');
} finally {
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') {
return (
<div className="fullscreen-center">
<Spin size="large" />
</div>
);
}
if (session.status !== 'authenticated' || !session.profile) {
return (
<main className="app">
<Result
status="403"
title="Authentication Required"
subTitle="Sign in through the central auth host to access waiter-floor operations."
extra={
<Button type="primary" href={loginUrl}>
Continue with Google
</Button>
}
/>
{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>
);
}
return (
<Layout className="full-layout">
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}>
<div className="brand">Waiter Floor Web</div>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={routeItems}
onClick={(event) => navigate(event.key as AppRoute)}
/>
</Layout.Sider>
<Layout>
<Layout.Header className="header">
<Space wrap>
<Tag color="blue">subject: {session.profile.subjectId}</Tag>
<Tag color="geekblue">tenant: {session.profile.tenantId}</Tag>
<Tag color="purple">provider: {providerLabel(session.profile.provider)}</Tag>
</Space>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void session.refresh()}>
Refresh Session
</Button>
<Button danger onClick={() => void session.logout()}>
Logout
</Button>
</Space>
</Layout.Header>
<Layout.Content className="content">
<Typography.Title level={3}>Waiter Floor Operations</Typography.Title>
<Typography.Paragraph type="secondary">
Protected floor workflows for assignment visibility, recent activity, and order actions that now feed the same restaurant lifecycle used by kitchen and POS.
</Typography.Paragraph>
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
{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>
<Route
path="/assignments"
element={
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Assignments Snapshot">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Input
value={contextId}
onChange={(event) => setContextId(event.target.value)}
placeholder="Context Id"
style={{ width: 280 }}
/>
<Button type="primary" loading={loadingAssignments} onClick={() => void loadAssignments()}>
Load Assignments
</Button>
<Button icon={<SyncOutlined />} onClick={() => void loadAssignments()} disabled={loadingAssignments}>
Retry
</Button>
</Space>
{assignments ? (
<>
<Descriptions bordered size="small" column={1}>
<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="Lifecycle Note">
Floor actions create or update shared restaurant orders that kitchen and POS observe next.
</Descriptions.Item>
</Descriptions>
<Table
pagination={false}
rowKey={(record) => `${record.waiterId}-${record.tableId}`}
dataSource={assignments.assignments}
columns={assignmentColumns}
locale={{ emptyText: 'No active waiter assignments for this context.' }}
/>
</>
) : (
<Empty description="Load a context to review assignment coverage." />
)}
</Space>
</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
path="/orders"
element={
<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
layout="vertical"
initialValues={{
contextId,
tableId: 'T-12',
orderId: 'ORD-1001',
itemCount: 3
}}
onFinish={(values: SubmitFloorOrderRequest) => void submitOrder(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="Item Count" rules={[{ required: true, type: 'number', min: 1 }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={submittingOrder}>
Submit Floor Order
</Button>
</Form>
</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.Item label="Context Id">{lastOrderResponse.contextId}</Descriptions.Item>
<Descriptions.Item label="Order Id">{lastOrderResponse.orderId}</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>
) : (
<Empty description="Submit or update an order to inspect the response payload." />
)}
</Card>
<Card title="Recent Workflow Actions">
<Table<OrderEvent>
pagination={false}
rowKey={(record) => `${record.kind}-${record.response.orderId}-${record.response.processedAtUtc}`}
dataSource={orderHistory}
locale={{ emptyText: 'No recent floor order actions yet.' }}
columns={[
{
title: 'Action',
render: (_, record) => <Tag color={record.kind === 'submitted' ? 'green' : 'orange'}>{record.kind}</Tag>
},
{ 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 }
]}
/>
</Card>
</Space>
}
/>
<Route
path="/session"
element={
<Card title="Session Details">
<pre>{JSON.stringify(session.profile, null, 2)}</pre>
</Card>
}
/>
<Route path="/" element={<Navigate to="/assignments" replace />} />
<Route path="*" element={<Navigate to="/assignments" replace />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
);
}
function providerLabel(provider: IdentityProvider): string {
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
return 'Internal JWT';
}
if (provider === 1 || provider === '1' || provider === 'AzureAd') {
return 'Azure AD';
}
if (provider === 2 || provider === '2' || provider === 'Google') {
return 'Google';
}
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;