ifixkart-admin/app/(admin)/pos-sync/page.tsx

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>
);
}