484 lines
20 KiB
TypeScript
484 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import {
|
|
Wifi,
|
|
WifiOff,
|
|
RefreshCw,
|
|
RotateCcw,
|
|
Store,
|
|
Receipt,
|
|
CheckCircle2,
|
|
AlertTriangle,
|
|
Clock,
|
|
Download,
|
|
Search,
|
|
Filter,
|
|
Plus
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { apiFetch } from '@/services/api/client';
|
|
import { SlideOver } from '@/components/ui/SlideOver';
|
|
|
|
interface POSTerminal {
|
|
terminal_id: string;
|
|
store_name: string;
|
|
status: 'ONLINE' | 'IDLE' | 'OFFLINE';
|
|
ip_address: string;
|
|
last_heartbeat: string;
|
|
synced_today: number;
|
|
pending_queue: number;
|
|
}
|
|
|
|
interface POSSyncStatus {
|
|
gateway_status: string;
|
|
active_terminals_count: number;
|
|
synced_today_count: number;
|
|
pending_retries_count: number;
|
|
offline_revenue_today: number;
|
|
last_sync_timestamp: string;
|
|
terminals: POSTerminal[];
|
|
}
|
|
|
|
interface POSSyncLog {
|
|
sync_id: string;
|
|
terminal_id: string;
|
|
invoice_no: string;
|
|
pos_transaction_id: string;
|
|
items_count: number;
|
|
total_amount: number;
|
|
status: 'SYNCED' | 'FAILED' | 'PENDING_RETRY';
|
|
created_at: string;
|
|
error_detail?: string | null;
|
|
}
|
|
|
|
export default function POSSyncPage() {
|
|
const [statusData, setStatusData] = useState<POSSyncStatus | null>(null);
|
|
const [logs, setLogs] = useState<POSSyncLog[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
|
const [resettingTerminal, setResettingTerminal] = useState<string | null>(null);
|
|
|
|
// Test Sync Simulation Drawer State
|
|
const [showSimModal, setShowSimModal] = useState(false);
|
|
const [simTerminalId, setSimTerminalId] = useState('TERM-MAIN-01');
|
|
const [simInvoiceNo, setSimInvoiceNo] = useState(`POS-${new Date().getFullYear()}-0990`);
|
|
const [simAmount, setSimAmount] = useState('1299.00');
|
|
const [simSubmitting, setSimSubmitting] = useState(false);
|
|
|
|
const fetchSyncData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [statusRes, logsRes] = await Promise.all([
|
|
apiFetch<POSSyncStatus>('/api/v1/pos/status', { skipAuth: true }),
|
|
apiFetch<{ total: number; logs: POSSyncLog[] }>('/api/v1/pos/logs', { skipAuth: true }),
|
|
]);
|
|
setStatusData(statusRes);
|
|
setLogs(logsRes.logs || []);
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to load POS sync telemetry');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchSyncData();
|
|
}, []);
|
|
|
|
const handleResetTerminal = async (terminalId: string) => {
|
|
setResettingTerminal(terminalId);
|
|
try {
|
|
const res = await apiFetch<{ status: string; message: string }>(`/api/v1/pos/terminals/${terminalId}/reset`, {
|
|
method: 'POST',
|
|
});
|
|
toast.success(res.message);
|
|
fetchSyncData();
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to reset terminal sync');
|
|
} finally {
|
|
setResettingTerminal(null);
|
|
}
|
|
};
|
|
|
|
const handleSimulateSync = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSimSubmitting(true);
|
|
try {
|
|
const payload = {
|
|
device_id: 'DEV-POS-TABLET-01',
|
|
terminal_id: simTerminalId,
|
|
store_id: 'STORE-CP-MAIN',
|
|
transactions: [
|
|
{
|
|
pos_transaction_id: `TX-${Date.now()}`,
|
|
local_sequence: Math.floor(Math.random() * 1000),
|
|
invoice_no: simInvoiceNo,
|
|
total_amount: parseFloat(simAmount) || 999.0,
|
|
created_at: new Date().toISOString(),
|
|
items: [
|
|
{
|
|
variant_id: 'VAR-IP15P-CLR-128',
|
|
qty: 1,
|
|
unit_price: parseFloat(simAmount) || 999.0,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const res = await apiFetch<{ status: string; synced_count: number }>('/api/v1/pos/sync', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
toast.success(`POS Transaction batch synced! (${res.synced_count} transaction processed)`);
|
|
setShowSimModal(false);
|
|
fetchSyncData();
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to sync POS transaction batch');
|
|
} finally {
|
|
setSimSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const filteredLogs = logs.filter((log) => {
|
|
const matchesSearch =
|
|
log.invoice_no.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
log.terminal_id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
log.pos_transaction_id.toLowerCase().includes(searchQuery.toLowerCase());
|
|
const matchesStatus = statusFilter === 'ALL' || log.status === statusFilter;
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6 pb-12">
|
|
{/* Top Action Bar */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
|
Offline POS Synchronization
|
|
</h1>
|
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
|
Monitor real-time sync telemetry, terminal heartbeat statuses, and offline retail billing transaction logs.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button
|
|
onClick={fetchSyncData}
|
|
disabled={loading}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh Telemetry
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowSimModal(true)}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Test POS Sync Payload
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metric Cards Grid */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
|
Gateway Status
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400">
|
|
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
{statusData?.gateway_status || 'ONLINE'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3">
|
|
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
|
{statusData?.active_terminals_count ?? 0} Active Terminals
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
|
Synced Today
|
|
</span>
|
|
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
|
</div>
|
|
<div className="mt-3">
|
|
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
|
{statusData?.synced_today_count ?? 0} Transactions
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
|
Offline Revenue Today
|
|
</span>
|
|
<Receipt className="h-5 w-5 text-blue-500" />
|
|
</div>
|
|
<div className="mt-3">
|
|
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
|
₹{(statusData?.offline_revenue_today ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
|
Pending Sync Retries
|
|
</span>
|
|
<AlertTriangle className={`h-5 w-5 ${statusData?.pending_retries_count ? 'text-amber-500' : 'text-slate-400'}`} />
|
|
</div>
|
|
<div className="mt-3">
|
|
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
|
{statusData?.pending_retries_count || 0} Queue Retries
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Terminal Health Grid */}
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-3">
|
|
Registered POS Terminals
|
|
</h2>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{statusData?.terminals.map((term) => (
|
|
<div
|
|
key={term.terminal_id}
|
|
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Store className="h-4 w-4 text-slate-500 dark:text-slate-400" />
|
|
<span className="font-semibold text-slate-900 dark:text-slate-100 text-sm">
|
|
{term.terminal_id}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
term.status === 'ONLINE'
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400'
|
|
: 'bg-amber-50 text-amber-700 dark:bg-amber-950/60 dark:text-amber-400'
|
|
}`}
|
|
>
|
|
{term.status === 'ONLINE' ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
|
|
{term.status}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400 truncate">
|
|
{term.store_name}
|
|
</p>
|
|
|
|
<div className="mt-3 space-y-1 text-xs text-slate-600 dark:text-slate-400">
|
|
<div className="flex justify-between">
|
|
<span>IP Address:</span>
|
|
<span className="font-mono text-slate-800 dark:text-slate-200">{term.ip_address}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Last Heartbeat:</span>
|
|
<span className="text-slate-800 dark:text-slate-200">{term.last_heartbeat}</span>
|
|
</div>
|
|
<div className="flex justify-between font-medium text-slate-900 dark:text-slate-100 pt-1">
|
|
<span>Synced Today:</span>
|
|
<span className="text-blue-600 dark:text-blue-400">{term.synced_today} bills</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 pt-3 border-t border-slate-100 dark:border-slate-800 flex justify-end">
|
|
<button
|
|
onClick={() => handleResetTerminal(term.terminal_id)}
|
|
disabled={resettingTerminal === term.terminal_id}
|
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 transition"
|
|
>
|
|
<RotateCcw className={`h-3.5 w-3.5 ${resettingTerminal === term.terminal_id ? 'animate-spin' : ''}`} />
|
|
Reset Session
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* POS Sync Audit Logs Table */}
|
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between border-b border-slate-200 dark:border-slate-800">
|
|
<div>
|
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-base">
|
|
Sync Audit History Logs
|
|
</h3>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
Transaction audit batch entries submitted from retail POS terminals.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative min-w-[220px]">
|
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search invoice or terminal..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full rounded-lg border border-slate-300 bg-slate-50 pl-9 pr-3 py-1.5 text-xs text-slate-900 focus:border-blue-500 focus:bg-white focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="rounded-lg border border-slate-300 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
|
>
|
|
<option value="ALL">All Statuses</option>
|
|
<option value="SYNCED">SYNCED</option>
|
|
<option value="FAILED">FAILED</option>
|
|
<option value="PENDING_RETRY">PENDING_RETRY</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table Content */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs text-slate-600 dark:text-slate-400">
|
|
<thead className="bg-slate-50 text-slate-700 uppercase tracking-wider font-medium border-b border-slate-200 dark:bg-slate-800/50 dark:text-slate-300 dark:border-slate-800">
|
|
<tr>
|
|
<th className="px-4 py-3">Sync ID</th>
|
|
<th className="px-4 py-3">Terminal ID</th>
|
|
<th className="px-4 py-3">Invoice No</th>
|
|
<th className="px-4 py-3">POS Tx ID</th>
|
|
<th className="px-4 py-3 text-center">Items</th>
|
|
<th className="px-4 py-3 text-right">Total Amount</th>
|
|
<th className="px-4 py-3 text-center">Status</th>
|
|
<th className="px-4 py-3">Sync Timestamp</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
|
|
{filteredLogs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} className="px-4 py-8 text-center text-slate-500 dark:text-slate-400">
|
|
No POS sync log entries found matching criteria.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredLogs.map((log) => (
|
|
<tr key={log.sync_id} className="hover:bg-slate-50/50 dark:hover:bg-slate-800/40 transition">
|
|
<td className="px-4 py-3 font-mono font-medium text-slate-900 dark:text-slate-100">
|
|
{log.sync_id}
|
|
</td>
|
|
<td className="px-4 py-3 font-medium text-slate-800 dark:text-slate-200">
|
|
{log.terminal_id}
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-blue-600 dark:text-blue-400">
|
|
{log.invoice_no}
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-slate-500 dark:text-slate-400">
|
|
{log.pos_transaction_id}
|
|
</td>
|
|
<td className="px-4 py-3 text-center font-medium text-slate-800 dark:text-slate-200">
|
|
{log.items_count}
|
|
</td>
|
|
<td className="px-4 py-3 text-right font-bold text-slate-900 dark:text-slate-100">
|
|
₹{log.total_amount.toFixed(2)}
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
{log.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">
|
|
{new Date(log.created_at).toLocaleString()}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Test Sync Simulation Drawer */}
|
|
<SlideOver
|
|
open={showSimModal}
|
|
onClose={() => setShowSimModal(false)}
|
|
title="Simulate POS Sync Transaction Payload"
|
|
>
|
|
<form onSubmit={handleSimulateSync} className="space-y-4 p-4 text-xs">
|
|
<p className="text-slate-500 dark:text-slate-400">
|
|
Submit a test offline retail invoice transaction payload to verify gateway idempotency and stock deduction handling.
|
|
</p>
|
|
|
|
<div>
|
|
<label className="block font-medium text-slate-700 dark:text-slate-300 mb-1">
|
|
POS Terminal ID
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. TERM-STORE-01"
|
|
value={simTerminalId}
|
|
onChange={(e) => setSimTerminalId(e.target.value)}
|
|
required
|
|
className="w-full rounded-lg border border-slate-300 bg-white p-2 font-mono text-xs text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block font-medium text-slate-700 dark:text-slate-300 mb-1">
|
|
Test Invoice Number
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={simInvoiceNo}
|
|
onChange={(e) => setSimInvoiceNo(e.target.value)}
|
|
required
|
|
className="w-full rounded-lg border border-slate-300 bg-white p-2 font-mono text-xs text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block font-medium text-slate-700 dark:text-slate-300 mb-1">
|
|
Total Amount (₹)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={simAmount}
|
|
onChange={(e) => setSimAmount(e.target.value)}
|
|
required
|
|
className="w-full rounded-lg border border-slate-300 bg-white p-2 font-mono text-xs text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSimModal(false)}
|
|
className="rounded-lg border border-slate-300 px-4 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={simSubmitting}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-xs font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
{simSubmitting && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
|
|
Send Sync Payload
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SlideOver>
|
|
</div>
|
|
);
|
|
}
|