516 lines
21 KiB
TypeScript
516 lines
21 KiB
TypeScript
import {
|
|
CreditCardOutlined,
|
|
DeploymentUnitOutlined,
|
|
ReloadOutlined,
|
|
SearchOutlined,
|
|
WalletOutlined
|
|
} from '@ant-design/icons';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Descriptions,
|
|
Empty,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Layout,
|
|
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 {
|
|
capturePosPayment,
|
|
loadDashboard,
|
|
loadRecentPayments,
|
|
loadTransactionDetail,
|
|
type CapturePosPaymentRequest,
|
|
type CapturePosPaymentResponse,
|
|
type PosPaymentActivity,
|
|
type PosTransactionDetailResponse,
|
|
type PosTransactionSummaryResponse,
|
|
type RecentPosPaymentsResponse
|
|
} from './api/dashboardApi';
|
|
import type { IdentityProvider } from './api/sessionApi';
|
|
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
|
|
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
|
|
|
type AppRoute = '/summary' | '/payments' | '/session';
|
|
|
|
type WorkflowState = {
|
|
error: string | null;
|
|
sessionExpired: boolean;
|
|
};
|
|
|
|
type PaymentAttempt = {
|
|
request: CapturePosPaymentRequest;
|
|
response: CapturePosPaymentResponse;
|
|
};
|
|
|
|
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
|
|
{ key: '/summary', label: 'Transaction Summary', icon: <WalletOutlined /> },
|
|
{ key: '/payments', label: 'Capture Payment', icon: <CreditCardOutlined /> },
|
|
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
|
|
];
|
|
|
|
const paymentColumns = [
|
|
{ title: 'Transaction Id', dataIndex: 'transactionId' },
|
|
{ title: 'Method', dataIndex: 'paymentMethod' },
|
|
{ title: 'Amount', render: (_: unknown, record: PosPaymentActivity) => `${record.amount.toFixed(2)} ${record.currency}` },
|
|
{
|
|
title: 'Status',
|
|
render: (_: unknown, record: PosPaymentActivity) => (
|
|
<Tag color={paymentStatusColor(record.status)}>{record.status}</Tag>
|
|
)
|
|
},
|
|
{ title: 'Lifecycle Step', render: (_: unknown, record: PosPaymentActivity) => paymentProgressHint(record.status) },
|
|
{ title: 'Captured At', render: (_: unknown, record: PosPaymentActivity) => formatUtc(record.capturedAtUtc) }
|
|
];
|
|
|
|
function App() {
|
|
return (
|
|
<SessionProvider>
|
|
<BrowserRouter>
|
|
<PosTransactionsShell />
|
|
</BrowserRouter>
|
|
</SessionProvider>
|
|
);
|
|
}
|
|
|
|
function PosTransactionsShell() {
|
|
const session = useSessionContext();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
const [contextId, setContextId] = useState('demo-context');
|
|
const [transactionId, setTransactionId] = useState('POS-9001');
|
|
const [summaryPayload, setSummaryPayload] = useState<PosTransactionSummaryResponse | null>(null);
|
|
const [detailPayload, setDetailPayload] = useState<PosTransactionDetailResponse | null>(null);
|
|
const [recentPayload, setRecentPayload] = useState<RecentPosPaymentsResponse | null>(null);
|
|
const [paymentResponse, setPaymentResponse] = useState<CapturePosPaymentResponse | null>(null);
|
|
const [paymentHistory, setPaymentHistory] = useState<PaymentAttempt[]>([]);
|
|
const [lastCaptureRequest, setLastCaptureRequest] = useState<CapturePosPaymentRequest | null>(null);
|
|
const [workflowState, setWorkflowState] = useState<WorkflowState>({ error: null, sessionExpired: false });
|
|
const [loadingSummary, setLoadingSummary] = useState(false);
|
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
const [loadingRecentPayments, setLoadingRecentPayments] = useState(false);
|
|
const [submittingPayment, setSubmittingPayment] = 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 ?? '/summary';
|
|
}, [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 POS operations.', sessionExpired: true });
|
|
await session.revalidate();
|
|
return;
|
|
}
|
|
|
|
setWorkflowState({
|
|
error: err instanceof Error ? err.message : fallbackMessage,
|
|
sessionExpired: false
|
|
});
|
|
};
|
|
|
|
const loadSummary = async () => {
|
|
setLoadingSummary(true);
|
|
clearWorkflowError();
|
|
try {
|
|
const payload = await loadDashboard(contextId);
|
|
setSummaryPayload(payload);
|
|
} catch (err) {
|
|
await handleWorkflowFailure(err, 'Failed to load POS summary.');
|
|
} finally {
|
|
setLoadingSummary(false);
|
|
}
|
|
};
|
|
|
|
const loadDetail = async () => {
|
|
setLoadingDetail(true);
|
|
clearWorkflowError();
|
|
try {
|
|
const payload = await loadTransactionDetail(contextId, transactionId);
|
|
setDetailPayload(payload);
|
|
} catch (err) {
|
|
await handleWorkflowFailure(err, 'Failed to load transaction detail.');
|
|
} finally {
|
|
setLoadingDetail(false);
|
|
}
|
|
};
|
|
|
|
const refreshRecentPayments = async () => {
|
|
setLoadingRecentPayments(true);
|
|
clearWorkflowError();
|
|
try {
|
|
const payload = await loadRecentPayments(contextId);
|
|
setRecentPayload(payload);
|
|
} catch (err) {
|
|
await handleWorkflowFailure(err, 'Failed to load recent payments.');
|
|
} finally {
|
|
setLoadingRecentPayments(false);
|
|
}
|
|
};
|
|
|
|
const submitCapturePayment = async (request: CapturePosPaymentRequest) => {
|
|
setSubmittingPayment(true);
|
|
clearWorkflowError();
|
|
setLastCaptureRequest(request);
|
|
|
|
try {
|
|
const payload = await capturePosPayment(request);
|
|
setPaymentResponse(payload);
|
|
setPaymentHistory((previous) => [{ request, response: payload }, ...previous].slice(0, 8));
|
|
await refreshRecentPayments();
|
|
await loadSummary();
|
|
await loadDetail();
|
|
} catch (err) {
|
|
await handleWorkflowFailure(err, 'Failed to capture payment.');
|
|
} finally {
|
|
setSubmittingPayment(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 POS transaction workflows."
|
|
extra={
|
|
<Button type="primary" href={loginUrl}>
|
|
Continue with Google
|
|
</Button>
|
|
}
|
|
/>
|
|
{workflowState.sessionExpired && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="Session expired"
|
|
description="Sign in again through the central auth host before retrying POS actions."
|
|
/>
|
|
)}
|
|
{session.error && <Alert type="error" showIcon message={session.error} />}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout className="full-layout">
|
|
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}>
|
|
<div className="brand">POS Transactions 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}>POS Transactions</Typography.Title>
|
|
<Typography.Paragraph type="secondary">
|
|
Protected POS workflows for payable restaurant checks, capture retries, and recent payment activity once service is complete.
|
|
</Typography.Paragraph>
|
|
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
|
|
{workflowState.error && <Alert className="stack-gap" type="error" showIcon message={workflowState.error} />}
|
|
{workflowState.sessionExpired && (
|
|
<Alert
|
|
className="stack-gap"
|
|
type="warning"
|
|
showIcon
|
|
message="Session expired"
|
|
description="Refresh your session or sign in again before retrying POS actions."
|
|
action={
|
|
<Space>
|
|
<Button size="small" onClick={() => void session.refresh()}>
|
|
Refresh Session
|
|
</Button>
|
|
<Button size="small" type="primary" href={loginUrl}>
|
|
Reauthenticate
|
|
</Button>
|
|
</Space>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<Routes>
|
|
<Route
|
|
path="/summary"
|
|
element={
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Card title="POS Overview">
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Space wrap>
|
|
<Input value={contextId} onChange={(event) => setContextId(event.target.value)} placeholder="Context Id" style={{ width: 260 }} />
|
|
<Input
|
|
value={transactionId}
|
|
onChange={(event) => setTransactionId(event.target.value)}
|
|
placeholder="Transaction Id"
|
|
style={{ width: 260 }}
|
|
/>
|
|
<Button type="primary" icon={<SearchOutlined />} loading={loadingSummary} onClick={() => void loadSummary()}>
|
|
Load Summary
|
|
</Button>
|
|
<Button loading={loadingDetail} onClick={() => void loadDetail()}>
|
|
Load Transaction Detail
|
|
</Button>
|
|
<Button loading={loadingRecentPayments} onClick={() => void refreshRecentPayments()}>
|
|
Load Recent Payments
|
|
</Button>
|
|
</Space>
|
|
{summaryPayload ? (
|
|
<Descriptions bordered size="small" column={1}>
|
|
<Descriptions.Item label="Context Id">{summaryPayload.contextId}</Descriptions.Item>
|
|
<Descriptions.Item label="Summary">{summaryPayload.summary}</Descriptions.Item>
|
|
<Descriptions.Item label="Open Balance">
|
|
{summaryPayload.openBalance.toFixed(2)} {summaryPayload.currency}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Lifecycle Note">
|
|
Only restaurant orders that have completed service should appear here as payable checks for capture.
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
) : (
|
|
<Empty description="Load a POS summary to inspect which served orders are ready for payment capture." />
|
|
)}
|
|
</Space>
|
|
</Card>
|
|
|
|
<Card title="Transaction Detail">
|
|
{detailPayload?.transaction ? (
|
|
<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="Transaction Id">{detailPayload.transaction.transactionId}</Descriptions.Item>
|
|
<Descriptions.Item label="Method">{detailPayload.transaction.paymentMethod}</Descriptions.Item>
|
|
<Descriptions.Item label="Amount">
|
|
{detailPayload.transaction.amount.toFixed(2)} {detailPayload.transaction.currency}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Status">
|
|
<Tag color={paymentStatusColor(detailPayload.transaction.status)}>{detailPayload.transaction.status}</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Next Step">{paymentProgressHint(detailPayload.transaction.status)}</Descriptions.Item>
|
|
<Descriptions.Item label="Captured At">{formatUtc(detailPayload.transaction.capturedAtUtc)}</Descriptions.Item>
|
|
</Descriptions>
|
|
) : (
|
|
<Empty description="Load a transaction detail to inspect payable-check state and capture readiness." />
|
|
)}
|
|
</Card>
|
|
|
|
<Card title="Recent Payment Activity">
|
|
{recentPayload?.recentPayments.length ? (
|
|
<Table<PosPaymentActivity>
|
|
pagination={false}
|
|
rowKey={(record) => `${record.transactionId}-${record.capturedAtUtc}`}
|
|
dataSource={recentPayload.recentPayments}
|
|
columns={paymentColumns}
|
|
/>
|
|
) : (
|
|
<Empty description="Load recent payments to inspect the latest captured activity." />
|
|
)}
|
|
</Card>
|
|
</Space>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/payments"
|
|
element={
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Card title="Capture Payment">
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Form<CapturePosPaymentRequest>
|
|
layout="vertical"
|
|
initialValues={{
|
|
contextId,
|
|
transactionId: 'POS-9001',
|
|
amount: 25.5,
|
|
currency: 'USD',
|
|
paymentMethod: 'card'
|
|
}}
|
|
onFinish={(values) => void submitCapturePayment(values)}
|
|
>
|
|
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
|
<Input placeholder="Context Id" />
|
|
</Form.Item>
|
|
<Form.Item name="transactionId" label="Transaction Id" rules={[{ required: true }]}>
|
|
<Input placeholder="Transaction Id" />
|
|
</Form.Item>
|
|
<Form.Item name="amount" label="Amount" rules={[{ required: true, type: 'number', min: 0 }]}>
|
|
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="currency" label="Currency" rules={[{ required: true, min: 3, max: 3 }]}>
|
|
<Input placeholder="USD" />
|
|
</Form.Item>
|
|
<Form.Item name="paymentMethod" label="Payment Method" rules={[{ required: true }]}>
|
|
<Input placeholder="card" />
|
|
</Form.Item>
|
|
<Space>
|
|
<Button type="primary" htmlType="submit" loading={submittingPayment}>
|
|
Capture Payment Now
|
|
</Button>
|
|
<Button
|
|
disabled={!lastCaptureRequest}
|
|
onClick={() => lastCaptureRequest && void submitCapturePayment(lastCaptureRequest)}
|
|
>
|
|
Retry Last Capture
|
|
</Button>
|
|
</Space>
|
|
</Form>
|
|
<Typography.Text type="secondary">
|
|
Capture should be used only after kitchen and floor service complete the order, making the check payable.
|
|
</Typography.Text>
|
|
</Space>
|
|
</Card>
|
|
|
|
<Card title="Latest Capture Result">
|
|
{paymentResponse ? (
|
|
<Descriptions bordered size="small" column={1}>
|
|
<Descriptions.Item label="Context Id">{paymentResponse.contextId}</Descriptions.Item>
|
|
<Descriptions.Item label="Transaction Id">{paymentResponse.transactionId}</Descriptions.Item>
|
|
<Descriptions.Item label="Succeeded">{String(paymentResponse.succeeded)}</Descriptions.Item>
|
|
<Descriptions.Item label="Status">
|
|
<Tag color={paymentStatusColor(paymentResponse.status)}>{paymentResponse.status}</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Next Step">{paymentProgressHint(paymentResponse.status)}</Descriptions.Item>
|
|
<Descriptions.Item label="Captured At">{formatUtc(paymentResponse.capturedAtUtc)}</Descriptions.Item>
|
|
<Descriptions.Item label="Summary">{paymentResponse.summary}</Descriptions.Item>
|
|
</Descriptions>
|
|
) : (
|
|
<Empty description="Run a capture to inspect the latest POS response." />
|
|
)}
|
|
</Card>
|
|
|
|
<Card title="Recent Capture Attempts">
|
|
<Table<PaymentAttempt>
|
|
pagination={false}
|
|
rowKey={(record) => `${record.response.transactionId}-${record.response.capturedAtUtc}`}
|
|
dataSource={paymentHistory}
|
|
locale={{ emptyText: 'No capture attempts recorded in this session.' }}
|
|
columns={[
|
|
{ title: 'Transaction Id', render: (_, record) => record.response.transactionId },
|
|
{ title: 'Method', render: (_, record) => record.request.paymentMethod },
|
|
{
|
|
title: 'Amount',
|
|
render: (_, record) => `${record.request.amount.toFixed(2)} ${record.request.currency}`
|
|
},
|
|
{
|
|
title: 'Status',
|
|
render: (_, record) => (
|
|
<Tag color={paymentStatusColor(record.response.status)}>{record.response.status}</Tag>
|
|
)
|
|
},
|
|
{ title: 'Summary', render: (_, record) => record.response.summary },
|
|
{ title: 'Lifecycle Step', render: (_, record) => paymentProgressHint(record.response.status) }
|
|
]}
|
|
/>
|
|
</Card>
|
|
</Space>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/session"
|
|
element={
|
|
<Card title="Session Details">
|
|
<pre>{JSON.stringify(session.profile, null, 2)}</pre>
|
|
</Card>
|
|
}
|
|
/>
|
|
<Route path="/" element={<Navigate to="/summary" replace />} />
|
|
<Route path="*" element={<Navigate to="/summary" replace />} />
|
|
</Routes>
|
|
</Layout.Content>
|
|
</Layout>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
function providerLabel(provider: IdentityProvider) {
|
|
switch (provider) {
|
|
case 1:
|
|
return 'manual';
|
|
case 2:
|
|
return 'google';
|
|
default:
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
function paymentStatusColor(status: string) {
|
|
switch (status.toLowerCase()) {
|
|
case 'captured':
|
|
case 'paid':
|
|
return 'green';
|
|
case 'payable':
|
|
case 'ready':
|
|
return 'blue';
|
|
case 'pending':
|
|
case 'authorized':
|
|
return 'orange';
|
|
case 'failed':
|
|
case 'blocked':
|
|
return 'red';
|
|
default:
|
|
return 'default';
|
|
}
|
|
}
|
|
|
|
function paymentProgressHint(status: string) {
|
|
switch (status.toLowerCase()) {
|
|
case 'payable':
|
|
case 'ready':
|
|
return 'ready for payment capture after service completion';
|
|
case 'pending':
|
|
case 'authorized':
|
|
return 'payment is in progress and should be monitored before retrying';
|
|
case 'captured':
|
|
case 'paid':
|
|
return 'payment is complete and the restaurant workflow can close';
|
|
case 'failed':
|
|
case 'blocked':
|
|
return 'operator review is required before attempting another capture';
|
|
default:
|
|
return 'payment state is being resolved against the restaurant lifecycle';
|
|
}
|
|
}
|
|
|
|
function formatUtc(value: string) {
|
|
return new Date(value).toLocaleString('en-US', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
timeZone: 'UTC'
|
|
});
|
|
}
|
|
|
|
export default App;
|