489 lines
19 KiB
TypeScript
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;
|