diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 35a3d45..269e1ba 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import ThreatDetection from './modules/ThreatDetection.jsx'; import VulnerabilityScanner from './modules/VulnerabilityScanner.jsx'; import ComplianceCentre from './modules/ComplianceCentre.jsx'; import DetectionRules from './modules/DetectionRules.jsx'; +import LossPrevention from './modules/LossPrevention.jsx'; export default function App() { const [route, setRoute] = useState('landing'); @@ -27,6 +28,7 @@ export default function App() { case 'vuln': return nav('portal')} />; case 'compliance': return nav('portal')} />; case 'rules': return nav('portal')} />; + case 'lp': return nav('portal')} />; default: return nav('portal')} onSignIn={() => nav('login')} />; } } diff --git a/frontend/src/lib/data.js b/frontend/src/lib/data.js index d0fe6d9..fd52a86 100644 --- a/frontend/src/lib/data.js +++ b/frontend/src/lib/data.js @@ -733,16 +733,16 @@ export const LP_INCIDENTS = [ ]; export const LP_STORE_RISK = [ - { storeId: 'Store-007', storeName: 'Oxford Street', riskScore: 92, openIncidents: 1, highestSignal: 'GhostEmployeeAfterHours', lastIncident: '2026-06-21T02:31:00Z', trend: 'up' }, - { storeId: 'Store-017', storeName: 'Canary Wharf', riskScore: 88, openIncidents: 1, highestSignal: 'HighValueVoidNoOverride', lastIncident: '2026-06-21T14:08:00Z', trend: 'up' }, - { storeId: 'Store-003', storeName: 'Westfield Strat.', riskScore: 84, openIncidents: 1, highestSignal: 'RapidCrossChannelRedemption', lastIncident: '2026-06-20T16:14:00Z', trend: 'up' }, - { storeId: 'Store-022', storeName: 'Bluewater', riskScore: 80, openIncidents: 1, highestSignal: 'ClosedStoreTerminalBurst', lastIncident: '2026-06-20T23:58:00Z', trend: 'stable'}, - { storeId: 'Store-031', storeName: 'Trafford Centre', riskScore: 74, openIncidents: 1, highestSignal: 'HighVolumeVoidRefund', lastIncident: '2026-06-21T11:42:00Z', trend: 'stable'}, - { storeId: 'Store-042', storeName: 'Meadowhall', riskScore: 68, openIncidents: 1, highestSignal: 'RepeatHighDiscountRelationship', lastIncident: '2026-06-21T06:00:00Z', trend: 'up' }, + { storeId: 'Store-007', storeName: 'Oxford Street', riskScore: 92, openIncidents: 1, highestSignal: 'GhostEmployeeAfterHours', lastIncident: '2026-06-21T02:31:00Z', trend: 'up' }, + { storeId: 'Store-017', storeName: 'Canary Wharf', riskScore: 88, openIncidents: 1, highestSignal: 'HighValueVoidNoOverride', lastIncident: '2026-06-21T14:08:00Z', trend: 'up' }, + { storeId: 'Store-003', storeName: 'Westfield Strat.', riskScore: 84, openIncidents: 1, highestSignal: 'RapidCrossChannelRedemption', lastIncident: '2026-06-20T16:14:00Z', trend: 'up' }, + { storeId: 'Store-022', storeName: 'Bluewater', riskScore: 80, openIncidents: 1, highestSignal: 'ClosedStoreTerminalBurst', lastIncident: '2026-06-20T23:58:00Z', trend: 'stable'}, + { storeId: 'Store-031', storeName: 'Trafford Centre', riskScore: 74, openIncidents: 1, highestSignal: 'HighVolumeVoidRefund', lastIncident: '2026-06-21T11:42:00Z', trend: 'stable'}, + { storeId: 'Store-042', storeName: 'Meadowhall', riskScore: 68, openIncidents: 1, highestSignal: 'RepeatHighDiscountRelationship', lastIncident: '2026-06-21T06:00:00Z', trend: 'up' }, { storeId: 'Store-019', storeName: 'Bullring', riskScore: 62, openIncidents: 1, highestSignal: 'HighDiscountConcentrationAtTerminal', lastIncident: '2026-06-20T06:00:00Z', trend: 'stable'}, - { storeId: 'Store-008', storeName: 'Brent Cross', riskScore: 58, openIncidents: 1, highestSignal: 'BulkGiftCardActivation', lastIncident: '2026-06-21T10:22:00Z', trend: 'down' }, - { storeId: 'Store-011', storeName: 'Lakeside', riskScore: 28, openIncidents: 0, highestSignal: null, lastIncident: null, trend: 'stable'}, - { storeId: 'Store-055', storeName: 'Arndale', riskScore: 21, openIncidents: 0, highestSignal: null, lastIncident: null, trend: 'down' }, + { storeId: 'Store-008', storeName: 'Brent Cross', riskScore: 58, openIncidents: 1, highestSignal: 'BulkGiftCardActivation', lastIncident: '2026-06-21T10:22:00Z', trend: 'down' }, + { storeId: 'Store-011', storeName: 'Lakeside', riskScore: 28, openIncidents: 0, highestSignal: null, lastIncident: null, trend: 'stable'}, + { storeId: 'Store-055', storeName: 'Arndale', riskScore: 21, openIncidents: 0, highestSignal: null, lastIncident: null, trend: 'down' }, ]; export const SUBMISSION_HISTORY = [ diff --git a/frontend/src/modules/LossPrevention.jsx b/frontend/src/modules/LossPrevention.jsx new file mode 100644 index 0000000..a419d07 --- /dev/null +++ b/frontend/src/modules/LossPrevention.jsx @@ -0,0 +1,420 @@ +import { useState, useMemo } from 'react'; +import { + ShoppingCart, AlertTriangle, TrendingUp, TrendingDown, + Minus, X, Clock, User, Monitor, ChevronRight, + PoundSterling, Store, BarChart2, +} from 'lucide-react'; +import TopBar from '../components/TopBar.jsx'; +import SeverityBadge from '../components/SeverityBadge.jsx'; +import StatCard from '../components/StatCard.jsx'; +import { LP_INCIDENTS, LP_STORE_RISK } from '../lib/data.js'; +import { useBreakpoint } from '../lib/hooks.js'; + +const LP_COLOR = '#F97316'; +const LP_DIM = 'rgba(249,115,22,0.12)'; + +const SIGNAL_LABELS = { + HighVolumeVoidRefund: 'High-volume voids', + HighValueVoidNoOverride: 'High-value void, no override', + RapidVoidBurst: 'Rapid void burst', + BulkGiftCardActivation: 'Bulk card activation', + RapidCrossChannelRedemption: 'Cross-channel redemption', + HighValueActivationSession: 'High-value activation', + RepeatHighDiscountRelationship: 'Repeat discount relationship', + BelowAverageBasketForCustomer: 'Below-average basket', + HighDiscountConcentrationAtTerminal: 'Discount concentration', + AfterHoursHighValueSale: 'After-hours sale', + GhostEmployeeAfterHours: 'Ghost employee', + ClosedStoreTerminalBurst: 'Closed-store terminal burst', +}; + +const RULE_LABELS = { + 'LP-001': 'POS Void/Refund Abuse', + 'LP-002': 'Gift Card Rapid Redemption', + 'LP-003': 'Sweethearting', + 'LP-004': 'After-Hours POS', +}; + +function fmtDate(iso) { + if (!iso) return '—'; + return new Date(iso).toLocaleString('en-GB', { + day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit', timeZone:'UTC', + }); +} + +function fmtGBP(v) { + if (!v && v !== 0) return '—'; + return `£${v.toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function TrendIcon({ trend }) { + if (trend === 'up') return ; + if (trend === 'down') return ; + return ; +} + +function RiskBar({ score }) { + const color = score >= 80 ? '#DC2626' : score >= 60 ? '#F97316' : '#16A34A'; + return ( +
+
+
+
+ {score} +
+ ); +} + +function IncidentDetail({ inc, onClose }) { + if (!inc) return null; + return ( +
+
e.stopPropagation()} + style={{ + width:'min(520px, calc(100vw - 32px))', maxHeight:'calc(100vh - 32px)', + overflowY:'auto', background:'var(--surface)', + border:'1px solid var(--border)', borderRadius:'var(--r-card)', + boxShadow:'var(--shadow-lg)', display:'flex', flexDirection:'column', + }} + > + {/* Detail header */} +
+
+
+ + {inc.id} + {inc.ruleId} +
+
{inc.title}
+
+ +
+ +
+ + {/* Meta row */} +
+ {[ + { label:'Detection signal', value: SIGNAL_LABELS[inc.detectionSignal] || inc.detectionSignal }, + { label:'MTTD', value: `${inc.mttd} min` }, + { label:'Store', value: inc.storeId }, + { label:'Operator', value: inc.operatorId || '—' }, + { label:'Terminal', value: inc.terminalId || '—' }, + { label:'Risk score', value: inc.riskScore }, + { label:'Estimated loss', value: fmtGBP(inc.estimatedLossGBP) }, + { label:'Detected', value: fmtDate(inc.detectedAt) }, + ].map(row => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ + {/* Description */} +
+
Description
+

{inc.description}

+
+ + {/* Timeline */} + {inc.timeline?.length > 0 && ( +
+
Event timeline
+
+ {inc.timeline.map((ev, i) => ( +
+
+
+ {i < inc.timeline.length - 1 &&
} +
+
+ {ev.time} + {ev.event} +
+
+ ))} +
+
+ )} + + {/* Auto-defence */} + {inc.autoDefence?.length > 0 && ( +
+
Automated response
+
+ {inc.autoDefence.map((a, i) => ( +
+
+ {a} +
+ ))} +
+
+ )} + + {/* Recommendations */} + {inc.recommendations?.length > 0 && ( +
+
Recommendations
+
+ {inc.recommendations.map((r, i) => ( +
+
+ {r} +
+ ))} +
+
+ )} +
+
+
+ ); +} + +export default function LossPrevention({ nav, onBack }) { + const { isMobile } = useBreakpoint(); + const [selectedInc, setSelectedInc] = useState(null); + const [filterRule, setFilterRule] = useState('All'); + const [filterSev, setFilterSev] = useState('All'); + + const incidents = LP_INCIDENTS; + const storeRisk = LP_STORE_RISK; + + const filtered = useMemo(() => incidents.filter(inc => { + if (filterRule !== 'All' && inc.ruleId !== filterRule) return false; + if (filterSev !== 'All' && inc.severity !== filterSev) return false; + return true; + }), [incidents, filterRule, filterSev]); + + const totalLoss = incidents.reduce((s, i) => s + (i.estimatedLossGBP || 0), 0); + const criticalCount = incidents.filter(i => i.severity === 'Critical').length; + const avgRisk = storeRisk.length > 0 ? Math.round(storeRisk.reduce((s, r) => s + r.riskScore, 0) / storeRisk.length) : 0; + const highRiskCount = storeRisk.filter(r => r.riskScore >= 80).length; + + const selectBtnStyle = (active) => ({ + padding:'4px 10px', borderRadius:'var(--r-btn)', fontSize:'11px', fontWeight:600, + cursor:'pointer', border:`1px solid ${active ? LP_COLOR : 'var(--border)'}`, + background: active ? LP_DIM : 'transparent', + color: active ? LP_COLOR : 'var(--text-dim)', + transition:'all var(--t)', + }); + + return ( +
+ + + +
+ + {/* Page header */} +
+
+ +
+
+

Loss Prevention

+

Financial fraud detection, void/refund abuse, and store risk scoring

+
+
+ + {/* KPI cards */} +
+ i.status !== 'Resolved').length} + sub={`${criticalCount} critical`} + accent={criticalCount > 0 ? '#DC2626' : LP_COLOR} + icon={AlertTriangle} + /> + + = 3 ? '#DC2626' : LP_COLOR} + icon={Store} + /> + = 70 ? '#DC2626' : avgRisk >= 50 ? LP_COLOR : '#16A34A'} + icon={BarChart2} + /> +
+ + {/* Main layout — two columns on wide screens */} +
+ + {/* Left: Incident feed */} +
+
+ + LP Incidents ({filtered.length}) + +
+ {['All', 'LP-001', 'LP-002', 'LP-003', 'LP-004'].map(r => ( + + ))} +
+ {['All', 'Critical', 'High'].map(s => ( + + ))} +
+
+ + {filtered.length === 0 ? ( +
+ No incidents match the selected filters. +
+ ) : ( +
+ {filtered.map(inc => ( +
setSelectedInc(inc)} + role="button" + tabIndex={0} + aria-label={`View incident ${inc.id}`} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setSelectedInc(inc); }} + style={{ + background:'var(--card)', border:'1px solid var(--border)', + borderRadius:'var(--r-card)', padding:'14px 16px', + cursor:'pointer', transition:'all var(--t)', + display:'flex', flexDirection:'column', gap:'8px', + }} + onMouseEnter={e => { e.currentTarget.style.background='var(--card-hover)'; e.currentTarget.style.borderColor='rgba(249,115,22,0.25)'; e.currentTarget.style.transform='translateY(-1px)'; }} + onMouseLeave={e => { e.currentTarget.style.background='var(--card)'; e.currentTarget.style.borderColor='var(--border)'; e.currentTarget.style.transform='none'; }} + > +
+
+
+ + {inc.id} + + {inc.ruleId} + +
+
{inc.title}
+
+ +
+ +
+
+ + {inc.storeId} +
+ {inc.operatorId && ( +
+ + {inc.operatorId} +
+ )} +
+ + {fmtDate(inc.detectedAt)} +
+ {inc.estimatedLossGBP > 0 && ( + + {fmtGBP(inc.estimatedLossGBP)} + + )} +
+ +
+ Signal: {SIGNAL_LABELS[inc.detectionSignal] || inc.detectionSignal} +
+
+ ))} +
+ )} +
+ + {/* Right: Store Risk Leaderboard */} +
+ + Store Risk Leaderboard + +
+ {storeRisk.map((s, i) => ( +
+
+
+
+ + #{i + 1} + + + {s.storeName} + +
+
{s.storeId}
+
+
+ + {s.openIncidents > 0 && ( + + {s.openIncidents} open + + )} +
+
+ + {s.highestSignal && ( +
+ {SIGNAL_LABELS[s.highestSignal] || s.highestSignal} +
+ )} +
+ ))} +
+
+ +
+
+ +
+ Loss Prevention · 4 active detection rules · T1657 — Financial Theft + MITRE ATT&CK · Impact +
+ + {selectedInc && setSelectedInc(null)} />} +
+ ); +} diff --git a/frontend/src/pages/Portal.jsx b/frontend/src/pages/Portal.jsx index 7af1508..c3f4fbc 100644 --- a/frontend/src/pages/Portal.jsx +++ b/frontend/src/pages/Portal.jsx @@ -11,7 +11,7 @@ const MODULES = [ { id:'vuln', icon:Search, name:'Vulnerability Scanner', desc:'Web and network security assessment with severity-ranked findings.', color:'#D97706', active:true }, { id:'compliance', icon:FileCheck, name:'Compliance Centre', desc:'UK regulatory deadline tracking — ICO, NCSC, PCI DSS, and NIS2.', color:'#16A34A', active:true }, { id:'rules', icon:BookOpen, name:'Detection Rules', desc:'KQL rule management, MITRE ATT&CK coverage, and rule performance.', color:'#2563EB', active:true }, - { id:null, icon:ShoppingCart, name:'Loss Prevention', desc:'Financial fraud detection, void/refund abuse, and store risk scoring.', color:'#5A6478', active:false }, + { id:'lp', icon:ShoppingCart, name:'Loss Prevention', desc:'Financial fraud detection, void/refund abuse, and store risk scoring.', color:'#F97316', active:true }, { id:null, icon:Link2, name:'ChainShield', desc:'Supply chain and third-party supplier compromise detection.', color:'#5A6478', active:false }, ];