From e4b64f0f128804779b767f6c689e6db33db46d3e Mon Sep 17 00:00:00 2001 From: Arjen Furster Date: Mon, 30 Mar 2026 20:26:29 +0200 Subject: [PATCH 1/9] feat: implement earnings and payments dashboard with data parsing and visualization --- README.md | 30 +- package.json | 2 +- src/App.tsx | 53 +- src/components/EarningsView.tsx | 1204 ++++++++++++++++++++++++++++ src/components/HomeDashboard.tsx | 72 +- src/components/RenewalCalendar.tsx | 703 ++++++++++++++++ src/store/earningsStore.ts | 110 +++ src/types/EarningsData.ts | 208 +++++ src/utils/earningsDb.ts | 100 +++ src/utils/earningsParser.ts | 40 + src/utils/paymentsParser.ts | 36 + src/workers/earnings.worker.ts | 128 +++ src/workers/payments.worker.ts | 95 +++ 13 files changed, 2769 insertions(+), 12 deletions(-) create mode 100644 src/components/EarningsView.tsx create mode 100644 src/components/RenewalCalendar.tsx create mode 100644 src/store/earningsStore.ts create mode 100644 src/types/EarningsData.ts create mode 100644 src/utils/earningsDb.ts create mode 100644 src/utils/earningsParser.ts create mode 100644 src/utils/paymentsParser.ts create mode 100644 src/workers/earnings.worker.ts create mode 100644 src/workers/payments.worker.ts diff --git a/README.md b/README.md index 0f3b635..5795f02 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Live App](https://img.shields.io/badge/Live-cspinsights.app-6366f1)](https://cspinsights.app) -**A free, local-first billing reconciliation and pricing management toolkit for Microsoft CSP Direct (Tier 1) partners.** +**A free, local-first billing reconciliation, pricing management, and incentives analytics toolkit for Microsoft CSP Direct (Tier 1) partners.** > Your data never leaves your browser. No backend, no account, no cloud uploads. @@ -13,7 +13,7 @@ ## Why CSP Insights? -Managing Microsoft CSP operations means wrestling with massive reconciliation CSVs and complex price lists every month. CSP Insights gives you immediate visual clarity and powerful tools — without backend servers or third-party data processing. +Managing Microsoft CSP operations means wrestling with massive reconciliation CSVs, complex price lists, and incentive earnings reports every month. CSP Insights gives you immediate visual clarity and powerful tools — without backend servers or third-party data processing. ## Features @@ -37,6 +37,21 @@ Managing Microsoft CSP operations means wrestling with massive reconciliation CS - **Version comparison** — load two price lists and spot price changes - **Shopping cart & quotes** — build a cart and export professional PDF quotes +### Incentives & Earnings +- **Earnings Report** — import the Partner Center Incentives → Earnings → Export (Default) CSV + - Dashboard with total earnings, customer count, product count, and program breakdown + - Earnings over time (monthly bar chart) + - Top customers and top products by earning amount + - Drill-down: click a customer or product to open a detail view with per-lever and per-product charts + - Full records table with search filter and sortable columns +- **Payments Report** — import the Partner Center Incentives → Payments CSV + - Summary cards: total earned, total paid, total tax withheld, and payment count + - Payments by month chart (grouped earned vs. paid) + - Payment method breakdown with color-coded horizontal bar + - Sortable payments table with status, date, and amounts +- **Multi-file upload** — append multiple CSV exports to build a combined dataset +- **Session persistence** — earnings and payment data survive page refreshes via IndexedDB + ### Data Management - **Snapshots** — save and restore billing data and pricing catalogs - **Backup & restore** — export/import all your data @@ -50,6 +65,17 @@ Managing Microsoft CSP operations means wrestling with massive reconciliation CS - **No telemetry** — no analytics, no tracking, no data collection - Clearing your browser data removes everything +## Data Sources + +| Report | Where to export in Partner Center | +|--------|----------------------------------| +| Billing reconciliation | Billing → Reconciliation → Download CSV | +| CSP price list | Pricing → License-based / Usage-based → Download | +| Incentives Earnings | Incentives → Earnings → Export → Default | +| Incentives Payments | Incentives → Payments → Export | + +All files are processed entirely in your browser. Nothing is uploaded anywhere. + ## Getting Started ### Use the hosted version diff --git a/package.json b/package.json index 2a6729d..f12d834 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "csp-insights", "version": "1.0.0", - "description": "A local-first billing reconciliation and pricing management toolkit for Microsoft CSP Direct partners", + "description": "A local-first billing reconciliation, pricing management, and incentives analytics toolkit for Microsoft CSP Direct partners", "homepage": "https://cspinsights.app", "license": "MIT", "type": "module", diff --git a/src/App.tsx b/src/App.tsx index fb04d54..05b04f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,11 +6,14 @@ import { HistoryModal } from './components/HistoryModal'; import { parseBillingCSVs } from './utils/csvParser'; import { useBillingStore } from './store/billingStore'; import { useSettingsStore } from './store/settingsStore'; +import { useEarningsStore } from './store/earningsStore'; import { AzureAnalyzer } from './components/AzureAnalyzer'; import { NceAnalyzer } from './components/NceAnalyzer'; +import { RenewalCalendar } from './components/RenewalCalendar'; import { PricingView } from './components/PricingView'; import { HomeDashboard } from './components/HomeDashboard'; -import { Loader2, Settings, History, Sun, Moon, Search, LayoutGrid, BarChart3, Cloud, ShieldCheck, ExternalLink } from 'lucide-react'; +import { EarningsView } from './components/EarningsView'; +import { Loader2, Settings, History, Sun, Moon, Search, LayoutGrid, BarChart3, Cloud, ShieldCheck, ExternalLink, TrendingUp, CalendarDays } from 'lucide-react'; import { generateDemoData } from './utils/demoData'; import './App.css'; @@ -29,9 +32,10 @@ function App() { } = useBillingStore(); const { loadSettings, theme, setTheme, companyDetails } = useSettingsStore(); + const { loadFromDisk: loadEarningsFromDisk } = useEarningsStore(); // Navigation State - const [currentView, setCurrentView] = useState<'home' | 'dashboard' | 'settings' | 'azure' | 'nce' | 'pricing'>('home'); + const [currentView, setCurrentView] = useState<'home' | 'dashboard' | 'settings' | 'azure' | 'nce' | 'renewals' | 'pricing' | 'incentives'>('home'); const [showHistory, setShowHistory] = useState(false); const [isUploading, setIsUploading] = useState(false); // New state to control upload view overlay @@ -39,6 +43,7 @@ function App() { useEffect(() => { loadFromDisk(); loadSettings(); + loadEarningsFromDisk(); }, []); const handleFileSelect = async (files: File[]) => { @@ -111,7 +116,7 @@ function App() { {/* Search Section */} -
+
- {(currentView === 'dashboard' || currentView === 'azure' || currentView === 'nce' || currentView === 'pricing') && ( + {(currentView === 'dashboard' || currentView === 'azure' || currentView === 'nce' || currentView === 'renewals' || currentView === 'pricing' || currentView === 'incentives') && ( +
+ +
+ )} + + {(currentView === 'dashboard' || currentView === 'azure' || currentView === 'nce' || currentView === 'renewals') && ( <> {showDashboard && (
@@ -232,6 +261,17 @@ function App() { > NCE Insights +
)} @@ -277,6 +317,7 @@ function App() { {currentView === 'dashboard' && } {currentView === 'azure' && } {currentView === 'nce' && } + {currentView === 'renewals' && } )} diff --git a/src/components/EarningsView.tsx b/src/components/EarningsView.tsx new file mode 100644 index 0000000..a834ecf --- /dev/null +++ b/src/components/EarningsView.tsx @@ -0,0 +1,1204 @@ +import React, { useState, useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + LineChart, Line, Cell, Legend +} from 'recharts'; +import { + TrendingUp, Users, FileText, Clock, ShieldCheck, + Download, Search, X, ChevronUp, ChevronDown, Plus, Trash2, ArrowLeft, User, Package, + CreditCard, Banknote, Receipt +} from 'lucide-react'; +import { useEarningsStore } from '../store/earningsStore'; +import { parseEarningsCSVs } from '../utils/earningsParser'; +import { parsePaymentsCSV } from '../utils/paymentsParser'; +import { FileDropZone } from './FileDropZone'; +import * as XLSX from 'xlsx'; +import type { EarningRecord, PaymentRecord } from '../types/EarningsData'; + +type TabType = 'overview' | 'customers' | 'products' | 'records' | 'payments'; +type SortDir = 'asc' | 'desc'; + +// Brand palette matching the app +const CHART_COLORS = ['#10B981', '#00B5E2', '#FE5000', '#8B5CF6', '#F59E0B', '#0078D4', '#EC4899']; +const PAYMENT_STATUS_COLORS: Record = { + UNPROCESSED: '#F59E0B', + SENT: '#00B5E2', + PAID: '#10B981', +}; + +// ── Shared chart theme helpers ──────────────────────────────────────────────── +const axisStyle = { fontSize: 11, fill: 'var(--text-tertiary)' }; +const gridStyle = { stroke: 'var(--border-color)', strokeDasharray: '3 3' }; +const tooltipStyle = { + contentStyle: { + background: 'var(--bg-secondary)', + border: '1px solid var(--border-color)', + borderRadius: '8px', + fontSize: '0.85rem', + color: 'var(--text-primary)', + }, + labelStyle: { color: 'var(--text-primary)', fontWeight: 600 }, + itemStyle: { color: 'var(--text-secondary)' }, + cursor: { fill: 'var(--bg-tertiary)' }, +}; + +const fmt = (amount: number, currency: string) => + new Intl.NumberFormat('nl-NL', { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount); +const fmtShort = (amount: number, currency: string) => + new Intl.NumberFormat('nl-NL', { style: 'currency', currency, maximumFractionDigits: 0 }).format(amount); +const truncate = (str: string, max: number) => + str && str.length > max ? str.slice(0, max) + '…' : (str || ''); + +const PaymentBadge: React.FC<{ status: string }> = ({ status }) => { + const color = PAYMENT_STATUS_COLORS[status] || 'var(--text-tertiary)'; + return ( + + {status || '—'} + + ); +}; + +const thStyle: React.CSSProperties = { + padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.8rem', + color: 'var(--text-tertiary)', fontWeight: 600, textTransform: 'uppercase', + letterSpacing: '0.05em', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none', +}; +const tdStyle: React.CSSProperties = { + padding: '0.75rem 1rem', fontSize: '0.88rem', color: 'var(--text-primary)', + borderTop: '1px solid var(--border-color)', whiteSpace: 'nowrap', +}; + +const SortIcon: React.FC<{ col: string; current: { key: string; dir: SortDir } }> = ({ col, current }) => { + if (current.key !== col) return ; + return current.dir === 'asc' + ? + : ; +}; + +// ── Records table with sort + filter (reused in detail views) ──────────────── +const RecordsTable: React.FC<{ records: EarningRecord[]; currency: string }> = ({ records, currency }) => { + const [search, setSearch] = useState(''); + const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'earningDate', dir: 'desc' }); + + const filtered = useMemo(() => { + let f = records; + if (search) { + const q = search.toLowerCase(); + f = f.filter(r => + r.customerName?.toLowerCase().includes(q) || + r.productName?.toLowerCase().includes(q) || + r.lever?.toLowerCase().includes(q) || + r.paymentStatus?.toLowerCase().includes(q) || + r.earningId?.toLowerCase().includes(q) + ); + } + return [...f].sort((a, b) => { + const av = (a as any)[sort.key] ?? ''; + const bv = (b as any)[sort.key] ?? ''; + if (sort.key === 'earningDate') { + const ad = new Date(av).getTime() || 0; + const bd = new Date(bv).getTime() || 0; + return sort.dir === 'asc' ? ad - bd : bd - ad; + } + if (typeof av === 'number') return sort.dir === 'asc' ? av - bv : bv - av; + return sort.dir === 'asc' + ? String(av).localeCompare(String(bv)) + : String(bv).localeCompare(String(av)); + }); + }, [records, search, sort]); + + const toggleSort = (key: string) => + setSort(s => ({ key, dir: s.key === key && s.dir === 'desc' ? 'asc' : 'desc' })); + + const ColHeader: React.FC<{ col: string; label: string; alignRight?: boolean }> = ({ col, label, alignRight }) => ( + toggleSort(col)}> + + {label} + {sort.key === col + ? (sort.dir === 'asc' ? : ) + : } + + + ); + + return ( +
+ {/* Filter bar */} +
+
+ + setSearch(e.target.value)} + style={{ width: '100%', padding: '0.45rem 1.8rem', border: '1px solid var(--border-color)', borderRadius: '2rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', fontSize: '0.83rem', outline: 'none' }} + /> + {search && ( + + )} +
+ + {filtered.length !== records.length + ? `${filtered.length} of ${records.length} records` + : `${records.length} records`} + +
+ +
+ + + + + + + + + + + + + + + + {filtered.slice(0, 500).map(r => ( + (e.currentTarget.style.background = 'var(--bg-tertiary)')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + style={{ transition: 'background 0.12s' }} + > + + + + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
{r.customerName} + {truncate(r.productName, 35)} + + {truncate(r.lever, 28)} + {r.earningRate}%{r.quantity}{fmt(r.earningAmount, currency)}{r.estimatedPaymentMonth || '—'} + {r.earningDate ? new Date(r.earningDate).toLocaleDateString('nl-NL') : '—'} +
+ No records match your filter. +
+ {filtered.length > 500 && ( +
+ Showing first 500 of {filtered.length.toLocaleString('nl-NL')} records. Use Export Excel for the full dataset. +
+ )} +
+
+ ); +}; + +// ── Customer detail view ────────────────────────────────────────────────────── +const CustomerDetail: React.FC<{ + customerName: string; + records: EarningRecord[]; + currency: string; + onBack: () => void; +}> = ({ customerName, records, currency, onBack }) => { + const total = records.reduce((s, r) => s + r.earningAmount, 0); + const byProduct = useMemo(() => { + const map = new Map(); + records.forEach(r => { if (r.productName) map.set(r.productName, (map.get(r.productName) || 0) + r.earningAmount); }); + return Array.from(map.entries()).map(([name, amount]) => ({ name, shortName: truncate(name, 30), amount })).sort((a, b) => b.amount - a.amount); + }, [records]); + const byLever = useMemo(() => { + const map = new Map(); + records.forEach(r => { if (r.lever) map.set(r.lever, (map.get(r.lever) || 0) + r.earningAmount); }); + return Array.from(map.entries()).map(([lever, amount]) => ({ lever, shortLever: truncate(lever, 28), amount })).sort((a, b) => b.amount - a.amount); + }, [records]); + + return ( +
+
+ +
+ +
+
+

{customerName}

+
+ {records.length} records · {fmt(total, currency)} total earnings +
+
+
+ +
+
+

By Product

+ + + + fmtShort(v, currency)} tick={axisStyle} /> + + [fmt(v, currency), 'Earnings']} {...tooltipStyle} /> + + {byProduct.map((_, i) => )} + + + +
+ +
+

By Lever

+ + + + fmtShort(v, currency)} tick={axisStyle} /> + + [fmt(v, currency), 'Earnings']} {...tooltipStyle} /> + + {byLever.map((_, i) => )} + + + +
+
+ +
+
+ All Records +
+ +
+
+ ); +}; + +// ── Product detail view ─────────────────────────────────────────────────────── +const ProductDetail: React.FC<{ + productName: string; + records: EarningRecord[]; + currency: string; + onBack: () => void; +}> = ({ productName, records, currency, onBack }) => { + const byCustomer = useMemo(() => { + const map = new Map(); + records.forEach(r => { if (r.customerName) map.set(r.customerName, (map.get(r.customerName) || 0) + r.earningAmount); }); + return Array.from(map.entries()).map(([name, amount]) => ({ name, amount })).sort((a, b) => b.amount - a.amount).slice(0, 12); + }, [records]); + const lever = records[0]?.lever || ''; + const avgRate = records.length > 0 ? records.reduce((s, r) => s + r.earningRate, 0) / records.length : 0; + + return ( +
+
+ +
+ +
+
+

{productName}

+
+ {new Set(records.map(r => r.customerName)).size} customers · {records.length} records · avg {avgRate.toFixed(2)}% earning rate + {lever && <> · {lever}} +
+
+
+ +
+
+

Earnings by Customer

+ + + + + fmtShort(v, currency)} tick={axisStyle} width={70} /> + [fmt(v, currency), 'Earnings']} {...tooltipStyle} /> + + {byCustomer.map((_, i) => )} + + + +
+
+ +
+
+ All Records +
+ +
+
+ ); +}; + +// ── Main EarningsView ───────────────────────────────────────────────────────── +export const EarningsView: React.FC = () => { + const { + data, meta, isLoading, setData, appendData, reset, + payments, paymentsMeta, setPayments, appendPayments, resetPayments, + } = useEarningsStore(); + + const [activeTab, setActiveTab] = useState('overview'); + const [isUploading, setIsUploading] = useState(false); + const [isParsing, setIsParsing] = useState(false); + + // Drill-down state + const [drillCustomer, setDrillCustomer] = useState(null); + const [drillProduct, setDrillProduct] = useState(null); + + // Records tab filters + const [search, setSearch] = useState(''); + const [filterLever, setFilterLever] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); + + // Table sorting + const [custSort, setCustSort] = useState<{ key: string; dir: SortDir }>({ key: 'amount', dir: 'desc' }); + const [prodSort, setProdSort] = useState<{ key: string; dir: SortDir }>({ key: 'amount', dir: 'desc' }); + + // Payments upload state + const [isUploadingPayments, setIsUploadingPayments] = useState(false); + const [isParsingPayments, setIsParsingPayments] = useState(false); + + const showDashboard = data.length > 0 && !isUploading; + const currency = data[0]?.transactionCurrency || meta.currency || 'EUR'; + + // ── Derived aggregations ─────────────────────────────────────────────────── + + const earningsByLever = useMemo(() => { + const map = new Map(); + data.forEach(r => { if (r.lever) map.set(r.lever, (map.get(r.lever) || 0) + r.earningAmount); }); + return Array.from(map.entries()) + .map(([lever, amount]) => ({ lever, shortLever: truncate(lever, 32), amount })) + .sort((a, b) => b.amount - a.amount); + }, [data]); + + const earningsByMonth = useMemo(() => { + const map = new Map(); + data.forEach(r => { + if (!r.earningDate) return; + const d = new Date(r.earningDate); + if (isNaN(d.getTime())) return; + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + map.set(key, (map.get(key) || 0) + r.earningAmount); + }); + return Array.from(map.entries()) + .map(([month, amount]) => ({ month, amount })) + .sort((a, b) => a.month.localeCompare(b.month)); + }, [data]); + + const earningsByCustomer = useMemo(() => { + const map = new Map }>(); + data.forEach(r => { + if (!r.customerName) return; + const e = map.get(r.customerName) || { amount: 0, records: 0, levers: new Set() }; + e.amount += r.earningAmount; + e.records++; + if (r.lever) e.levers.add(r.lever); + map.set(r.customerName, e); + }); + return Array.from(map.entries()).map(([customerName, s]) => ({ + customerName, + amount: s.amount, + records: s.records, + levers: Array.from(s.levers).join(', '), + })); + }, [data]); + + const sortedCustomers = useMemo(() => { + return [...earningsByCustomer].sort((a, b) => { + const av = (a as any)[custSort.key]; + const bv = (b as any)[custSort.key]; + if (typeof av === 'number') return custSort.dir === 'asc' ? av - bv : bv - av; + return custSort.dir === 'asc' ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); + }); + }, [earningsByCustomer, custSort]); + + const earningsByProduct = useMemo(() => { + const map = new Map }>(); + data.forEach(r => { + if (!r.productName) return; + const e = map.get(r.productName) || { amount: 0, records: 0, lever: r.lever, customers: new Set() }; + e.amount += r.earningAmount; + e.records++; + if (r.customerName) e.customers.add(r.customerName); + map.set(r.productName, e); + }); + return Array.from(map.entries()).map(([productName, s]) => ({ + productName, amount: s.amount, records: s.records, lever: s.lever, customersCount: s.customers.size, + })); + }, [data]); + + const sortedProducts = useMemo(() => { + return [...earningsByProduct].sort((a, b) => { + const av = (a as any)[prodSort.key]; + const bv = (b as any)[prodSort.key]; + if (typeof av === 'number') return prodSort.dir === 'asc' ? av - bv : bv - av; + return prodSort.dir === 'asc' ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); + }); + }, [earningsByProduct, prodSort]); + + const filteredRecords = useMemo(() => { + let f = data; + if (search) { + const q = search.toLowerCase(); + f = f.filter(r => + r.customerName?.toLowerCase().includes(q) || + r.productName?.toLowerCase().includes(q) || + r.lever?.toLowerCase().includes(q) || + r.earningId?.toLowerCase().includes(q) + ); + } + if (filterLever) f = f.filter(r => r.lever === filterLever); + if (filterStatus) f = f.filter(r => r.paymentStatus === filterStatus); + return f; + }, [data, search, filterLever, filterStatus]); + + const uniqueLevers = useMemo(() => [...new Set(data.map(r => r.lever).filter(Boolean))], [data]); + const uniqueStatuses = useMemo(() => [...new Set(data.map(r => r.paymentStatus).filter(Boolean))], [data]); + const unprocessedAmount = useMemo( + () => data.filter(r => r.paymentStatus === 'UNPROCESSED').reduce((s, r) => s + r.earningAmount, 0), + [data] + ); + const nextPayment = useMemo(() => { + const months = [...new Set(data.map(r => r.estimatedPaymentMonth).filter(Boolean))]; + return months[0] || null; + }, [data]); + + // ── Handlers ────────────────────────────────────────────────────────────── + + const handleFileSelect = async (files: File[]) => { + setIsParsing(true); + try { + const result = await parseEarningsCSVs(files); + if (result.errors.length > 0) { + alert(`Warning: Some rows were skipped.\n${result.errors.slice(0, 3).join('\n')}`); + } + if (result.data.length === 0) { + alert('No valid earnings records found.\n\nExpected: Earnings Report (Default) CSV from Partner Center → Incentives → Earnings → Export.'); + return; + } + if (data.length > 0 && !isUploading) { + appendData(result.data, result.meta); + alert(`Added ${result.data.length} earnings records.`); + } else { + setData(result.data, result.meta); + } + setIsUploading(false); + setDrillCustomer(null); + setDrillProduct(null); + } catch (err: any) { + alert(err.message || 'Failed to parse earnings CSV'); + } finally { + setIsParsing(false); + } + }; + + const handleClear = () => { + if (confirm('Clear all earnings data? This cannot be undone.')) { + reset(); + setIsUploading(false); + setDrillCustomer(null); + setDrillProduct(null); + } + }; + + const handlePaymentsFileSelect = async (files: File[]) => { + setIsParsingPayments(true); + try { + const result = await parsePaymentsCSV(files); + if (result.errors.length > 0) { + alert(`Warning: Some rows were skipped.\n${result.errors.slice(0, 3).join('\n')}`); + } + if (result.data.length === 0) { + alert('No valid payment records found.\n\nExpected: Payments CSV from Partner Center → Incentives → Payments → Export.'); + return; + } + if (payments.length > 0 && !isUploadingPayments) { + appendPayments(result.data, result.meta); + alert(`Added ${result.data.length} payment records.`); + } else { + setPayments(result.data, result.meta); + } + setIsUploadingPayments(false); + } catch (err: any) { + alert(err.message || 'Failed to parse payments CSV'); + } finally { + setIsParsingPayments(false); + } + }; + + const handleClearPayments = () => { + if (confirm('Clear all payments data? This cannot be undone.')) { + resetPayments(); + setIsUploadingPayments(false); + } + }; + + const handleExportPaymentsExcel = () => { + const rows = payments.map(r => ({ + 'Payment ID': r.paymentId, + 'Participant': r.participantName, + 'Program': r.programName, + [`Earned (${paymentsCurrency})`]: r.earned, + 'Earned (USD)': r.earnedUSD, + 'Withheld Tax': r.withheldTax, + 'Sales Tax': r.salesTax, + 'Service Fee Tax': r.serviceFeeTax, + [`Total Payment (${paymentsCurrency})`]: r.totalPayment, + 'Payment Method': formatPaymentMethod(r.paymentMethod), + 'Payment Status': r.paymentStatus, + 'Payment Date': r.paymentDate ? new Date(r.paymentDate).toLocaleDateString('nl-NL') : '', + 'Reference': r.ciReferenceNumber || '', + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Payments'); + XLSX.writeFile(wb, `payments-${new Date().toISOString().slice(0, 10)}.xlsx`); + }; + + const handleExportExcel = () => { + const source = (drillCustomer + ? data.filter(r => r.customerName === drillCustomer) + : drillProduct + ? data.filter(r => r.productName === drillProduct) + : filteredRecords + ); + const rows = source.map(r => ({ + 'Earning ID': r.earningId, + 'Customer': r.customerName, + 'Product': r.productName, + 'Lever': r.lever, + 'Solution Area': r.solutionArea, + 'Earning Type': r.earningType, + 'Earning Rate %': r.earningRate, + 'Quantity': r.quantity, + [`Earning (${currency})`]: r.earningAmount, + 'Earning (USD)': r.earningAmountUSD, + 'Payment Status': r.paymentStatus, + 'Est. Payment Month': r.estimatedPaymentMonth, + 'Transaction Date': r.transactionDate, + 'Earning Date': r.earningDate, + 'Program': r.programName, + 'Engagement': r.engagementName, + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Earnings'); + XLSX.writeFile(wb, `earnings-${new Date().toISOString().slice(0, 10)}.xlsx`); + }; + + const handleLeverBarClick = (entry: any) => { + if (!entry?.lever) return; + setFilterLever(entry.lever === filterLever ? '' : entry.lever); + setActiveTab('records'); + }; + + const handleCustomerBarClick = (entry: any) => { + if (!entry?.name) return; + setDrillCustomer(entry.name); + }; + + const toggleCustSort = (key: string) => + setCustSort(s => ({ key, dir: s.key === key && s.dir === 'desc' ? 'asc' : 'desc' })); + const toggleProdSort = (key: string) => + setProdSort(s => ({ key, dir: s.key === key && s.dir === 'desc' ? 'asc' : 'desc' })); + + // ── Payments derived data ────────────────────────────────────────────────── + + const paymentsCurrency = payments[0]?.earnedCurrencyCode || paymentsMeta.currency || 'EUR'; + + const formatPaymentMethod = (method: string) => { + if (!method) return '—'; + if (method.toLowerCase().includes('electronicbank')) return 'Bank Transfer'; + if (method.toLowerCase().includes('lrdcredit')) return 'Credit Memo'; + return method; + }; + + const paymentMethodIcon = (method: string) => { + if (method.toLowerCase().includes('electronicbank')) return ; + if (method.toLowerCase().includes('lrdcredit')) return ; + return ; + }; + + const paymentsByMonth = useMemo(() => { + const map = new Map(); + payments.forEach(r => { + if (!r.paymentDate) return; + const d = new Date(r.paymentDate); + if (isNaN(d.getTime())) return; + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + const e = map.get(key) || { earned: 0, totalPayment: 0, tax: 0 }; + e.earned += r.earned; + e.totalPayment += r.totalPayment; + e.tax += r.salesTax + r.withheldTax; + map.set(key, e); + }); + return Array.from(map.entries()) + .map(([month, v]) => ({ month, ...v })) + .sort((a, b) => a.month.localeCompare(b.month)); + }, [payments]); + + const paymentsByMethod = useMemo(() => { + const map = new Map(); + payments.forEach(r => { + const m = formatPaymentMethod(r.paymentMethod); + map.set(m, (map.get(m) || 0) + r.totalPayment); + }); + return Array.from(map.entries()).map(([method, amount]) => ({ method, amount })).sort((a, b) => b.amount - a.amount); + }, [payments]); + + const lastPaymentDate = useMemo(() => { + if (!payments.length) return null; + const sorted = [...payments].filter(r => r.paymentDate).sort((a, b) => + new Date(b.paymentDate).getTime() - new Date(a.paymentDate).getTime() + ); + return sorted[0]?.paymentDate ? new Date(sorted[0].paymentDate) : null; + }, [payments]); + + const showPaymentsDashboard = payments.length > 0 && !isUploadingPayments; + + // ── Upload / empty state ─────────────────────────────────────────────────── + + if (isLoading) return ( +
+ +

Loading earnings data...

+
+ ); + + if (!showDashboard) return ( +
+ {isUploading && data.length > 0 && ( + + )} + } + /> +
+ How to export from Partner Center: +
    +
  1. Go to Partner CenterIncentivesEarnings
  2. +
  3. Apply any date filters you need
  4. +
  5. Click Export and select Default
  6. +
  7. Upload the downloaded CSV here
  8. +
+
+
+ + Processed locally in your browser. No data is uploaded. +
+
+ ); + + // ── Drill-down views ─────────────────────────────────────────────────────── + + if (drillCustomer) return ( +
+
+ +
+ r.customerName === drillCustomer)} + currency={currency} + onBack={() => setDrillCustomer(null)} + /> +
+ ); + + if (drillProduct) return ( +
+
+ +
+ r.productName === drillProduct)} + currency={currency} + onBack={() => setDrillProduct(null)} + /> +
+ ); + + // ── Main dashboard ───────────────────────────────────────────────────────── + + return ( +
+ {/* Action bar */} +
+
+

Incentives & Earnings

+

+ {meta.totalRows.toLocaleString('nl-NL')} records · {meta.customersCount} customers + {nextPayment && <> · Est. payment: {nextPayment}} +

+
+
+ + + +
+
+ + {/* Summary Cards */} +
+ {[ + { icon: , label: 'Total Earnings', value: fmtShort(meta.totalEarningAmount, currency), sub: currency, color: '#10B981' }, + { icon: , label: 'Unprocessed', value: fmtShort(unprocessedAmount, currency), sub: 'pending payment', color: '#F59E0B' }, + { icon: , label: 'Customers', value: String(meta.customersCount), sub: 'with earnings', color: 'var(--text-primary)' }, + { icon: , label: 'Records', value: meta.totalRows.toLocaleString('nl-NL'), sub: `${uniqueLevers.length} levers`, color: 'var(--text-primary)' }, + ].map(card => ( +
+
+ {card.icon} + {card.label} +
+
{card.value}
+
{card.sub}
+
+ ))} +
+ + {/* Tab Navigation */} +
+ {(['overview', 'customers', 'products', 'records'] as TabType[]).map(tab => ( + + ))} + +
+ + {/* ── Overview Tab ────────────────────────────────────────────────── */} + {activeTab === 'overview' && ( +
+ {/* Earnings by Lever */} +
+

Earnings by Lever

+

Click a bar to filter by lever

+ + + + fmtShort(v, currency)} tick={axisStyle} /> + + [fmt(v, currency), 'Earnings']} {...tooltipStyle} /> + + {earningsByLever.map((_, i) => )} + + + +
+ + {/* Monthly Trend */} +
+

Monthly Trend

+ + + + + fmtShort(v, currency)} tick={axisStyle} width={70} /> + [fmt(v, currency), 'Earnings']} {...tooltipStyle} /> + + + + +
+ + {/* Top 10 Customers — clickable */} + {earningsByCustomer.length > 0 && ( +
+

Top 10 Customers

+

Click a bar for customer details

+ + b.amount - a.amount).slice(0, 10)} + margin={{ left: 10, right: 20, top: 4, bottom: 65 }} + > + + + fmtShort(v, currency)} tick={axisStyle} width={70} /> + [fmt(v, currency), 'Earnings']} {...tooltipStyle} /> + + {earningsByCustomer.slice(0, 10).map((_, i) => )} + + + +
+ )} +
+ )} + + {/* ── Customers Tab ──────────────────────────────────────────────── */} + {activeTab === 'customers' && ( +
+ + + + + + + + + + + + {sortedCustomers.map(row => ( + setDrillCustomer(row.customerName)} + style={{ cursor: 'pointer', transition: 'background 0.12s' }} + onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-tertiary)')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + > + + + + + + + ))} + +
toggleCustSort('customerName')}> + Customer + toggleCustSort('amount')}> + Earnings + toggleCustSort('records')}> + Records + Levers
{row.customerName}{fmt(row.amount, currency)}{row.records} + {truncate(row.levers, 55)} + Details →
+
+ )} + + {/* ── Products Tab ───────────────────────────────────────────────── */} + {activeTab === 'products' && ( +
+ + + + + + + + + + + + {sortedProducts.map(row => ( + setDrillProduct(row.productName)} + style={{ cursor: 'pointer', transition: 'background 0.12s' }} + onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-tertiary)')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + > + + + + + + + ))} + +
toggleProdSort('productName')}> + Product + toggleProdSort('amount')}> + Earnings + toggleProdSort('customersCount')}> + Customers + toggleProdSort('lever')}> + Lever +
{row.productName}{fmt(row.amount, currency)}{row.customersCount} + {truncate(row.lever, 45)} + Details →
+
+ )} + + {/* ── Records Tab ────────────────────────────────────────────────── */} + {activeTab === 'records' && ( +
+
+
+ + setSearch(e.target.value)} + style={{ width: '100%', padding: '0.55rem 2.2rem', border: '1px solid var(--border-color)', borderRadius: '2rem', background: 'var(--bg-primary)', color: 'var(--text-primary)', fontSize: '0.88rem', outline: 'none' }} + /> + {search && ( + + )} +
+ + + {filterLever && ( + + )} + + {filteredRecords.length.toLocaleString('nl-NL')} records + +
+ +
+ + {filteredRecords.length > 300 && ( +
+ Showing first 300 of {filteredRecords.length.toLocaleString('nl-NL')}. Use Export Excel for the full dataset. +
+ )} +
+
+ )} + + {/* ── Payments Tab ───────────────────────────────────────────────── */} + {activeTab === 'payments' && ( +
+ {!showPaymentsDashboard ? ( + /* Payments upload / empty state */ +
+ {isUploadingPayments && payments.length > 0 && ( + + )} + } + /> +
+ How to export from Partner Center: +
    +
  1. Go to Partner CenterIncentivesPayments
  2. +
  3. Apply any date filters you need
  4. +
  5. Click Export
  6. +
  7. Upload the downloaded CSV here
  8. +
+
+
+ + Processed locally in your browser. No data is uploaded. +
+
+ ) : ( + /* Payments dashboard */ +
+ {/* Action bar */} +
+
+

+ {paymentsMeta.totalRows} payments + {lastPaymentDate && <> · Last payment: {lastPaymentDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}} +

+
+
+ + + +
+
+ + {/* Summary Cards */} +
+ {[ + { icon: , label: 'Total Earned', value: fmtShort(paymentsMeta.totalEarned, paymentsCurrency), sub: paymentsCurrency, color: '#10B981' }, + { icon: , label: 'Total Paid Out', value: fmtShort(paymentsMeta.totalPaid, paymentsCurrency), sub: 'incl. tax', color: 'var(--brand-turquoise)' }, + { icon: , label: 'Total Tax', value: fmtShort(paymentsMeta.totalTax, paymentsCurrency), sub: 'sales + withheld', color: '#F59E0B' }, + { icon: , label: 'Payments', value: String(paymentsMeta.totalRows), sub: `${paymentsByMethod.length} method(s)`, color: 'var(--text-primary)' }, + ].map(card => ( +
+
+ {card.icon} + {card.label} +
+
{card.value}
+
{card.sub}
+
+ ))} +
+ + {/* Charts */} +
+ {/* Monthly payments */} +
+

Payments by Month

+ + + + + fmtShort(v, paymentsCurrency)} tick={axisStyle} width={75} /> + [fmt(v, paymentsCurrency), name]} + {...tooltipStyle} + /> + + + + + +
+ + {/* By payment method */} +
+

By Payment Method

+ + + + fmtShort(v, paymentsCurrency)} tick={axisStyle} /> + + [fmt(v, paymentsCurrency), 'Total Paid']} {...tooltipStyle} /> + + {paymentsByMethod.map((_, i) => )} + + + + + {/* Method legend with totals */} +
+ {paymentsByMethod.map((m, i) => ( +
+
+
+ {m.method} +
+ {fmt(m.amount, paymentsCurrency)} +
+ ))} +
+
+
+ + {/* Payments Table */} +
+ + + + {['Date', 'Payment ID', 'Method', 'Earned', 'Sales Tax', 'Total Paid', 'Status'].map(h => ( + + ))} + + + + {[...payments] + .sort((a, b) => new Date(b.paymentDate).getTime() - new Date(a.paymentDate).getTime()) + .map((r: PaymentRecord) => ( + (e.currentTarget.style.background = 'var(--bg-tertiary)')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} + style={{ transition: 'background 0.12s' }} + > + + + + + + + + + ))} + +
{h}
+ {r.paymentDate ? new Date(r.paymentDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '—'} + {r.paymentId} + + {paymentMethodIcon(r.paymentMethod)} + {formatPaymentMethod(r.paymentMethod)} + + {fmt(r.earned, paymentsCurrency)} + {r.salesTax > 0 ? fmt(r.salesTax, paymentsCurrency) : } + {fmt(r.totalPayment, paymentsCurrency)} + + {r.paymentStatus} + +
+
+
+ )} +
+ )} +
+ ); +}; diff --git a/src/components/HomeDashboard.tsx b/src/components/HomeDashboard.tsx index 8563f5f..84aeff7 100644 --- a/src/components/HomeDashboard.tsx +++ b/src/components/HomeDashboard.tsx @@ -1,16 +1,18 @@ import React, { useEffect, useState } from 'react'; -import { BarChart3, Tag, ArrowRight, Clock, RotateCcw } from 'lucide-react'; +import { BarChart3, Tag, ArrowRight, Clock, RotateCcw, TrendingUp } from 'lucide-react'; import { useBillingStore } from '../store/billingStore'; import { useSnapshotStore } from '../store/snapshotStore'; import { usePricingStore } from '../store/pricingStore'; +import { useEarningsStore } from '../store/earningsStore'; interface HomeDashboardProps { - onNavigate: (view: 'billing' | 'pricing') => void; + onNavigate: (view: 'billing' | 'pricing' | 'incentives') => void; } export const HomeDashboard: React.FC = ({ onNavigate }) => { // Stores const { meta: billingMeta, data: billingData } = useBillingStore(); + const { meta: earningsMeta, data: earningsData } = useEarningsStore(); const { snapshots: billingSnapshots, loadSnapshots: loadBillingSnapshots, restoreSnapshot: restoreBillingSnapshot } = useSnapshotStore(); const { snapshots: pricingSnapshots, loadSnapshots: loadPricingSnapshots, restoreSnapshot: restorePricingSnapshot } = usePricingStore(); @@ -28,6 +30,7 @@ export const HomeDashboard: React.FC = ({ onNavigate }) => { ].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3); const hasActiveSession = billingData && billingData.length > 0; + const hasActiveEarnings = earningsData && earningsData.length > 0; const handleRestore = async (snap: typeof recentActivity[0]) => { if (confirm(`Restore "${snap.name || 'Untitled'}"? This will overwrite active data.`)) { @@ -55,7 +58,7 @@ export const HomeDashboard: React.FC = ({ onNavigate }) => {
{/* Main Cards */} -
+
{/* Billing Card */}
= ({ onNavigate }) => { Open Catalog
+ + {/* Incentives Card */} +
onNavigate('incentives')} + style={{ + padding: '2rem', + cursor: 'pointer', + transition: 'transform 0.2s ease, box-shadow 0.2s ease', + display: 'flex', flexDirection: 'column', gap: '1rem', + border: '1px solid var(--border-color)', + position: 'relative', overflow: 'hidden' + }} + onMouseEnter={e => { + e.currentTarget.style.transform = 'translateY(-5px)'; + e.currentTarget.style.boxShadow = '0 10px 30px rgba(0,0,0,0.1)'; + }} + onMouseLeave={e => { + e.currentTarget.style.transform = 'none'; + e.currentTarget.style.boxShadow = 'none'; + }} + > +
+
+ +
+ {hasActiveEarnings && ( + + Active Session + + )} +
+ +
+

Incentives & Earnings

+

+ Analyze Partner Center incentive earnings, levers, and estimated payments. +

+
+ + {hasActiveEarnings && earningsMeta && ( +
+
+
Earnings
+
+ {new Intl.NumberFormat('nl-NL', { style: 'currency', currency: earningsMeta.currency || 'EUR', maximumFractionDigits: 0 }).format(earningsMeta.totalEarningAmount)} +
+
+
+
Customers
+
{earningsMeta.customersCount}
+
+
+ )} + +
+ {hasActiveEarnings ? 'Continue Analysis' : 'Start Analysis'} +
+
{/* Recent Activity Widget */} diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx new file mode 100644 index 0000000..3ff7ab5 --- /dev/null +++ b/src/components/RenewalCalendar.tsx @@ -0,0 +1,703 @@ +import React, { useMemo, useState } from 'react'; +import { useBillingStore } from '../store/billingStore'; +import type { BillingRecord } from '../types/BillingData'; +import { Calendar, ChevronLeft, ChevronRight, List, Clock, AlertTriangle, TrendingUp, Download, Hourglass } from 'lucide-react'; +import * as XLSX from 'xlsx'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type TermCategory = + | 'Monthly (Flex)' + | 'Annual (Monthly Pay)' + | 'Annual (Prepaid)' + | '3 Year (Commit)' + | 'Trial' + | 'Other'; + +type ViewMode = 'calendar' | 'list'; + +interface RenewalEntry { + subscriptionId: string; + customerName: string; + productName: string; + termCategory: TermCategory; + term: string; + endDate: Date; + startDate: Date | null; + quantity: number; + value: number; + currency: string; + daysUntil: number; + isCancellable: boolean; + isInEST: boolean; + orderId?: string; +} + +const EST_COLOR = '#6366F1'; + +// EST (Extended Service Terms) applies to annual/multi-year subscriptions that +// expire on or after the EST launch date. Subscriptions that expired before this +// date fell under the old post-expiry grace period, not EST. +const EST_LAUNCH_DATE = new Date('2025-05-04T00:00:00'); + +// ── Constants ────────────────────────────────────────────────────────────────── + +const TERM_COLORS: Record = { + 'Monthly (Flex)': '#F59E0B', + 'Annual (Monthly Pay)': '#00B5E2', + 'Annual (Prepaid)': '#10B981', + '3 Year (Commit)': '#8B5CF6', + 'Trial': '#6B7280', + 'Other': '#FE5000', +}; + +const TERM_LABELS: TermCategory[] = [ + 'Monthly (Flex)', 'Annual (Monthly Pay)', 'Annual (Prepaid)', '3 Year (Commit)', 'Trial', 'Other', +]; + +const DAY_HEADERS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function classifyTerm(raw: string): TermCategory { + const t = (raw || '').toLowerCase(); + if (t.includes('trial')) return 'Trial'; + if (t.includes('three-year') || t.includes('3 year') || t.includes('triennial') || t.includes('p3y')) return '3 Year (Commit)'; + if (t.includes('one-year commitment for monthly')) return 'Annual (Monthly Pay)'; + if (t.includes('one-year commitment for yearly') || t.includes('one-year commitment for annual') || t === 'annual') return 'Annual (Prepaid)'; + if (t.includes('one-month commitment for monthly') || t === 'monthly' || t.includes('p1m')) return 'Monthly (Flex)'; + if (t.includes('annual') || t.includes('1 year')) return t.includes('monthly') ? 'Annual (Monthly Pay)' : 'Annual (Prepaid)'; + if (t.includes('month')) return 'Monthly (Flex)'; + return 'Other'; +} + +function parseDate(s: string | undefined): Date | null { + if (!s) return null; + const d = new Date(s); + return isNaN(d.getTime()) ? null : d; +} + +function toMidnight(d: Date): Date { + const c = new Date(d); + c.setHours(0, 0, 0, 0); + return c; +} + +function formatDate(d: Date): string { + return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); +} + +function formatCurrency(v: number, currency: string): string { + return v.toLocaleString('nl-NL', { + style: 'currency', currency: currency || 'EUR', + minimumFractionDigits: 0, maximumFractionDigits: 0, + }); +} + +function isSameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth() + && a.getDate() === b.getDate(); +} + +function dayKey(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +// ── Sub-components ───────────────────────────────────────────────────────────── + +const SummaryCard: React.FC<{ + icon: React.ReactNode; color: string; + label: string; value: string; sub: string; alert?: boolean; +}> = ({ icon, color, label, value, sub, alert }) => ( +
+
{icon}
+
+
{label}
+
{value}
+
{sub}
+
+
+); + +const TermDot: React.FC<{ category: TermCategory; size?: number }> = ({ category, size = 8 }) => ( + +); + +const RenewalDetailRow: React.FC<{ renewal: RenewalEntry; isExpanded: boolean; onToggle: () => void }> = ({ renewal: r, isExpanded, onToggle }) => ( +
+
+
+ +
+
{r.customerName}
+
{r.productName}
+
+
+
+
{formatCurrency(r.value, r.currency)}
+
{r.termCategory}
+
+
+ {isExpanded && ( +
+
+
Subscription ID
{r.subscriptionId}
+ {r.startDate &&
Start Date
{formatDate(r.startDate)}
} +
Renewal Date
{formatDate(r.endDate)}
+
Quantity
{r.quantity}
+ {r.orderId &&
Order ID
{r.orderId}
} +
Full Term
{r.term}
+ {r.isCancellable && ( +
+ NCE 7-day cancellation window active +
+ )} + {r.isInEST && ( +
+ Extended Service Term (EST) — subscription continues month-to-month, cancel anytime +
+ )} +
+
+ )} +
+); + +// ── Main Component ───────────────────────────────────────────────────────────── + +export const RenewalCalendar: React.FC = () => { + const { data } = useBillingStore(); + + const [viewMode, setViewMode] = useState('calendar'); + const [currentMonth, setCurrentMonth] = useState(() => toMidnight(new Date(new Date().getFullYear(), new Date().getMonth(), 1))); + const [selectedDay, setSelectedDay] = useState(null); + const [filterTerm, setFilterTerm] = useState(null); + const [filterDays, setFilterDays] = useState(90); + const [search, setSearch] = useState(''); + const [expandedRow, setExpandedRow] = useState(null); + + const today = useMemo(() => toMidnight(new Date()), []); + + // ── Build deduplicated renewal entries ───────────────────────────────────── + const allRenewals = useMemo(() => { + // Keep only the row with the latest SubscriptionEndDate per SubscriptionId + const map = new Map(); + for (const row of data) { + if (!row.SubscriptionEndDate || !row.SubscriptionId) continue; + const existing = map.get(row.SubscriptionId); + if (!existing || row.SubscriptionEndDate > (existing.SubscriptionEndDate ?? '')) { + map.set(row.SubscriptionId, row); + } + } + + const entries: RenewalEntry[] = []; + for (const row of map.values()) { + const endDate = parseDate(row.SubscriptionEndDate); + if (!endDate) continue; + const end = toMidnight(endDate); + + const startDate = row.SubscriptionStartDate ? parseDate(row.SubscriptionStartDate) : null; + if (startDate) startDate.setHours(0, 0, 0, 0); + + const daysUntil = Math.ceil((end.getTime() - today.getTime()) / 86_400_000); + const daysSinceStart = startDate + ? Math.floor((today.getTime() - startDate.getTime()) / 86_400_000) + : 999; + + const termCategory = classifyTerm(row.TermAndBillingCycle); + // EST applies to annual/multi-year subscriptions whose end date is on or after + // the EST launch date (2025-05-05) and that are now past their end date. + // Subscriptions expired before EST launch fall under the old grace period model. + // Monthly (Flex) and Trial are excluded — they auto-renew or expire normally. + const isInEST = daysUntil < 0 + && end >= EST_LAUNCH_DATE + && termCategory !== 'Monthly (Flex)' + && termCategory !== 'Trial'; + + entries.push({ + subscriptionId: row.SubscriptionId, + customerName: row.CustomerName, + productName: row.ProductName, + termCategory, + term: row.TermAndBillingCycle, + endDate: end, + startDate, + quantity: row.Quantity ?? 0, + value: row.Total ?? row.Subtotal ?? 0, + currency: row.Currency ?? row.PricingCurrency ?? 'EUR', + daysUntil, + isCancellable: daysSinceStart >= -1 && daysSinceStart <= 7, + isInEST, + orderId: row.OrderId, + }); + } + return entries; + }, [data, today]); + + // ── Apply search + term filter ───────────────────────────────────────────── + const filteredRenewals = useMemo(() => { + const s = search.toLowerCase(); + return allRenewals.filter(r => { + if (filterTerm && r.termCategory !== filterTerm) return false; + if (s && !r.customerName.toLowerCase().includes(s) && !r.productName.toLowerCase().includes(s)) return false; + return true; + }); + }, [allRenewals, filterTerm, search]); + + // Renewals within the selected days window, ascending by date + const upcomingRenewals = useMemo(() => + filteredRenewals + .filter(r => r.daysUntil >= 0 && r.daysUntil <= filterDays) + .sort((a, b) => a.daysUntil - b.daysUntil), + [filteredRenewals, filterDays]); + + // ── Summary stats ────────────────────────────────────────────────────────── + const stats = useMemo(() => { + const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); + const thisMonth = filteredRenewals.filter(r => r.daysUntil >= 0 && r.endDate <= monthEnd); + const next30 = filteredRenewals.filter(r => r.daysUntil >= 0 && r.daysUntil <= 30); + const cancellable = allRenewals.filter(r => r.isCancellable); + const inEST = filteredRenewals.filter(r => r.isInEST); + const windowValue = upcomingRenewals.reduce((s, r) => s + r.value, 0); + const currency = filteredRenewals[0]?.currency ?? 'EUR'; + return { thisMonth, next30, cancellable, inEST, windowValue, currency }; + }, [filteredRenewals, upcomingRenewals, allRenewals, today]); + + // ── Calendar grid ────────────────────────────────────────────────────────── + const calendarCells = useMemo<(Date | null)[]>(() => { + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth(); + const firstDow = new Date(year, month, 1).getDay(); // 0 = Sun + const startOffset = (firstDow + 6) % 7; // convert to Monday-first + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const cells: (Date | null)[] = Array(startOffset).fill(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d)); + return cells; + }, [currentMonth]); + + const renewalsByDay = useMemo(() => { + const map = new Map(); + for (const r of filteredRenewals) { + const k = dayKey(r.endDate); + if (!map.has(k)) map.set(k, []); + map.get(k)!.push(r); + } + return map; + }, [filteredRenewals]); + + const selectedDayRenewals = useMemo(() => { + if (!selectedDay) return []; + return renewalsByDay.get(dayKey(selectedDay)) ?? []; + }, [selectedDay, renewalsByDay]); + + // ── Export ───────────────────────────────────────────────────────────────── + const handleExport = () => { + const exportRows = [...stats.inEST, ...upcomingRenewals]; + const rows = exportRows.map(r => ({ + 'Status': r.isInEST ? 'Extended Service Term (EST)' : 'Upcoming Renewal', + 'Customer': r.customerName, + 'Product': r.productName, + 'Term': r.term, + 'Category': r.termCategory, + 'Original End Date': formatDate(r.endDate), + 'Days': r.daysUntil, + 'Quantity': r.quantity, + 'Value': r.value, + 'Currency': r.currency, + 'NCE Cancellable (7-day window)': r.isCancellable ? 'Yes' : 'No', + 'Subscription ID': r.subscriptionId, + 'Order ID': r.orderId ?? '', + })); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Renewals'); + XLSX.writeFile(wb, `Renewal_Calendar_${dayKey(today)}.xlsx`); + }; + + // ── Empty state ──────────────────────────────────────────────────────────── + if (!data.length) { + return ( +
+ +

Load billing data to view the renewal calendar.

+
+ ); + } + + // ── Render ───────────────────────────────────────────────────────────────── + return ( +
+ + {/* Summary cards */} +
+ } color="#10B981" label="Renewals This Month" + value={stats.thisMonth.length.toString()} + sub={formatCurrency(stats.thisMonth.reduce((s, r) => s + r.value, 0), stats.currency)} /> + } color="#00B5E2" label="Next 30 Days" + value={stats.next30.length.toString()} + sub={formatCurrency(stats.next30.reduce((s, r) => s + r.value, 0), stats.currency)} /> + } color="#8B5CF6" label={`Next ${filterDays} Days`} + value={upcomingRenewals.length.toString()} + sub={formatCurrency(stats.windowValue, stats.currency)} /> + } color="#FE5000" label="NCE Cancellable Now" + value={stats.cancellable.length.toString()} sub="Within 7-day window" + alert={stats.cancellable.length > 0} /> + } color={EST_COLOR} label="In Extended Service Term" + value={stats.inEST.length.toString()} + sub={stats.inEST.length > 0 ? `${formatCurrency(stats.inEST.reduce((s, r) => s + r.value, 0), stats.currency)} · cancel anytime` : 'None active'} + alert={stats.inEST.length > 0} /> +
+ + {/* Toolbar */} +
+ {/* View toggle */} +
+ {(['calendar', 'list'] as ViewMode[]).map(m => ( + + ))} +
+ + {/* Window filter */} + + + {/* Term filter */} + + + {/* Search */} + setSearch(e.target.value)} + style={{ + flex: 1, minWidth: '180px', padding: '0.4rem 0.75rem', borderRadius: '8px', + border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', + color: 'var(--text-primary)', fontSize: '0.85rem', + }} /> + + {/* Export */} + +
+ + {/* Term legend */} +
+ {TERM_LABELS.map(t => ( + + ))} +
+ + {/* ── Calendar view ─────────────────────────────────────────────── */} + {viewMode === 'calendar' && ( +
+ {/* Month navigation */} +
+ + + {currentMonth.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })} + + + +
+ + {/* Grid */} +
+ {/* Day headers */} +
+ {DAY_HEADERS.map(h => ( +
{h}
+ ))} +
+ + {/* Day cells */} +
+ {calendarCells.map((day, i) => { + if (!day) return ( +
+ ); + const k = dayKey(day); + const dayRenewals = renewalsByDay.get(k) ?? []; + const estCount = dayRenewals.filter(r => r.isInEST).length; + const isToday = isSameDay(day, today); + const isSelected = selectedDay ? isSameDay(day, selectedDay) : false; + const isPast = day < today && !isToday; + const hasItems = dayRenewals.length > 0; + return ( +
hasItems ? setSelectedDay(isSelected ? null : day) : undefined} + style={{ + minHeight: '80px', padding: '0.4rem', + borderRight: '1px solid var(--border-color)', borderBottom: '1px solid var(--border-color)', + background: isSelected ? 'rgba(99,102,241,0.1)' : isToday ? 'rgba(16,185,129,0.06)' : estCount > 0 && isPast ? 'rgba(99,102,241,0.04)' : 'transparent', + borderLeft: estCount > 0 && isPast ? `3px solid ${EST_COLOR}55` : undefined, + cursor: hasItems ? 'pointer' : 'default', + opacity: isPast && !estCount ? 0.45 : 1, + transition: 'background 0.12s', + }}> +
+ {day.getDate()} +
+
+ {dayRenewals.slice(0, 6).map((r, j) => ( + + ))} + {dayRenewals.length > 6 && ( + +{dayRenewals.length - 6} + )} +
+ {estCount > 0 && ( +
+ EST{estCount > 1 ? ` ×${estCount}` : ''} +
+ )} +
+ ); + })} +
+
+ + {/* Selected day detail */} + {selectedDay && ( +
+
+ {selectedDayRenewals.length > 0 + ? `${selectedDayRenewals.length} renewal${selectedDayRenewals.length > 1 ? 's' : ''} on ${formatDate(selectedDay)}` + : `No renewals on ${formatDate(selectedDay)}`} +
+ {selectedDayRenewals.length > 0 && ( +
+ {selectedDayRenewals.map(r => ( + setExpandedRow(expandedRow === r.subscriptionId ? null : r.subscriptionId)} + /> + ))} +
+ )} +
+ )} +
+ )} + + {/* ── List view ─────────────────────────────────────────────────── */} + {viewMode === 'list' && ( +
+ + {/* EST section */} + {stats.inEST.length > 0 && ( +
+
+ Extended Service Term — {stats.inEST.length} subscription{stats.inEST.length > 1 ? 's' : ''} · cancel anytime +
+ {stats.inEST.sort((a, b) => a.daysUntil - b.daysUntil).map((r, i) => { + const isExpanded = expandedRow === r.subscriptionId; + return ( + +
setExpandedRow(isExpanded ? null : r.subscriptionId)} + style={{ + display: 'grid', gridTemplateColumns: '1fr 1fr auto auto auto auto', + gap: '0.5rem', alignItems: 'center', + padding: '0.6rem 0.75rem', cursor: 'pointer', + borderBottom: '1px solid var(--border-color)', + background: i % 2 === 0 ? 'transparent' : 'var(--bg-tertiary)', + fontSize: '0.85rem', + }}> +
{r.customerName}
+
{r.productName}
+
+ + {r.termCategory} +
+
{formatDate(r.endDate)}
+
EST
+
{formatCurrency(r.value, r.currency)}
+
+ {isExpanded && ( +
+
+
Subscription ID
{r.subscriptionId}
+ {r.startDate &&
Start Date
{formatDate(r.startDate)}
} +
Original End Date
{formatDate(r.endDate)}
+
Overdue by
{Math.abs(r.daysUntil)} days
+
Quantity
{r.quantity}
+ {r.orderId &&
Order ID
{r.orderId}
} +
Full Term
{r.term}
+
+ Extended Service Term active — subscription continues month-to-month until renewed or cancelled +
+
+
+ )} +
+ ); + })} +
+ )} + +
+ {upcomingRenewals.length === 0 ? ( +
+ No renewals found in the selected window. +
+ ) : (() => { + // Group by month for section headers + const groups = new Map(); + for (const r of upcomingRenewals) { + const label = r.endDate.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }); + if (!groups.has(label)) groups.set(label, []); + groups.get(label)!.push(r); + } + return Array.from(groups.entries()).map(([month, items]) => ( + +
+ {month} — {items.length} renewal{items.length > 1 ? 's' : ''} +
+ {items.map((r, i) => { + const isExpanded = expandedRow === r.subscriptionId; + const urgencyColor = r.daysUntil <= 7 ? '#FE5000' : r.daysUntil <= 30 ? '#F59E0B' : 'var(--text-secondary)'; + return ( + +
setExpandedRow(isExpanded ? null : r.subscriptionId)} + style={{ + display: 'grid', gridTemplateColumns: '1fr 1fr auto auto auto auto', + gap: '0.5rem', alignItems: 'center', + padding: '0.6rem 0.75rem', cursor: 'pointer', + borderBottom: '1px solid var(--border-color)', + background: i % 2 === 0 ? 'transparent' : 'var(--bg-tertiary)', + fontSize: '0.85rem', + }}> +
{r.customerName}
+
{r.productName}
+
+ + {r.termCategory} +
+
{formatDate(r.endDate)}
+
+ {r.daysUntil === 0 ? 'Today' : `${r.daysUntil}d`} +
+
{formatCurrency(r.value, r.currency)}
+
+ {isExpanded && ( +
+
+
Subscription ID
{r.subscriptionId}
+ {r.startDate &&
Start Date
{formatDate(r.startDate)}
} +
Renewal Date
{formatDate(r.endDate)}
+
Quantity
{r.quantity}
+ {r.orderId &&
Order ID
{r.orderId}
} +
Full Term
{r.term}
+ {r.isCancellable && ( +
+ NCE 7-day cancellation window active +
+ )} +
+
+ )} +
+ ); + })} +
+ )); + })()} +
+
+ )} + + {/* EST alert panel */} + {stats.inEST.length > 0 && viewMode === 'calendar' && ( +
+
+ + {stats.inEST.length} subscription{stats.inEST.length > 1 ? 's' : ''} in Extended Service Term — past end date, running month-to-month +
+
+ {stats.inEST.map(r => ( +
+ {r.customerName} — {r.productName} + expired {formatDate(r.endDate)} · {Math.abs(r.daysUntil)}d ago +
+ ))} +
+
+ )} + + {/* NCE Cancellable alert panel */} + {stats.cancellable.length > 0 && ( +
+
+ + {stats.cancellable.length} subscription{stats.cancellable.length > 1 ? 's' : ''} within NCE 7-day cancellation window +
+
+ {stats.cancellable.map(r => ( +
+ {r.customerName} — {r.productName} + {r.startDate ? `started ${formatDate(r.startDate)}` : ''} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/store/earningsStore.ts b/src/store/earningsStore.ts new file mode 100644 index 0000000..c89c1ad --- /dev/null +++ b/src/store/earningsStore.ts @@ -0,0 +1,110 @@ +import { create } from 'zustand'; +import type { EarningRecord, EarningsMeta, PaymentRecord, PaymentsMeta } from '../types/EarningsData'; +import { + saveEarningsData, loadEarningsData, clearEarningsData, + savePaymentsData, loadPaymentsData, clearPaymentsData, +} from '../utils/earningsDb'; + +const DEFAULT_EARNINGS_META: EarningsMeta = { + totalRows: 0, customersCount: 0, totalEarningAmount: 0, currency: 'EUR', +}; + +const DEFAULT_PAYMENTS_META: PaymentsMeta = { + totalRows: 0, totalEarned: 0, totalPaid: 0, totalTax: 0, currency: 'EUR', +}; + +interface EarningsState { + // Earnings + data: EarningRecord[]; + meta: EarningsMeta; + + // Payments + payments: PaymentRecord[]; + paymentsMeta: PaymentsMeta; + + isLoading: boolean; + error: string | null; + + // Earnings actions + setData: (data: EarningRecord[], meta: EarningsMeta) => void; + appendData: (data: EarningRecord[], meta: EarningsMeta) => void; + reset: () => Promise; + + // Payments actions + setPayments: (data: PaymentRecord[], meta: PaymentsMeta) => void; + appendPayments: (data: PaymentRecord[], meta: PaymentsMeta) => void; + resetPayments: () => Promise; + + loadFromDisk: () => Promise; +} + +export const useEarningsStore = create((set) => ({ + data: [], + meta: DEFAULT_EARNINGS_META, + payments: [], + paymentsMeta: DEFAULT_PAYMENTS_META, + isLoading: false, + error: null, + + setData: (data, meta) => { + saveEarningsData(data, meta).catch(console.error); + set({ data, meta, error: null }); + }, + + appendData: (newData, _newMeta) => { + set((state) => { + const combined = [...state.data, ...newData]; + const currency = combined[0]?.transactionCurrency || 'EUR'; + const totalEarningAmount = combined.reduce((sum, r) => sum + (r.earningAmount || 0), 0); + const customersCount = new Set(combined.map(r => r.customerName).filter(Boolean)).size; + const meta: EarningsMeta = { totalRows: combined.length, customersCount, totalEarningAmount, currency }; + saveEarningsData(combined, meta).catch(console.error); + return { data: combined, meta }; + }); + }, + + reset: async () => { + await clearEarningsData(); + set({ data: [], meta: DEFAULT_EARNINGS_META, error: null }); + }, + + setPayments: (data, meta) => { + savePaymentsData(data, meta).catch(console.error); + set({ payments: data, paymentsMeta: meta, error: null }); + }, + + appendPayments: (newData, _newMeta) => { + set((state) => { + const combined = [...state.payments, ...newData]; + const currency = combined[0]?.earnedCurrencyCode || 'EUR'; + const totalEarned = combined.reduce((s, r) => s + r.earned, 0); + const totalPaid = combined.reduce((s, r) => s + r.totalPayment, 0); + const totalTax = combined.reduce((s, r) => s + r.salesTax + r.withheldTax, 0); + const meta: PaymentsMeta = { totalRows: combined.length, totalEarned, totalPaid, totalTax, currency }; + savePaymentsData(combined, meta).catch(console.error); + return { payments: combined, paymentsMeta: meta }; + }); + }, + + resetPayments: async () => { + await clearPaymentsData(); + set({ payments: [], paymentsMeta: DEFAULT_PAYMENTS_META }); + }, + + loadFromDisk: async () => { + set({ isLoading: true }); + try { + const [savedEarnings, savedPayments] = await Promise.all([ + loadEarningsData(), + loadPaymentsData(), + ]); + if (savedEarnings?.data) set({ data: savedEarnings.data, meta: savedEarnings.meta }); + if (savedPayments?.data) set({ payments: savedPayments.data, paymentsMeta: savedPayments.meta }); + } catch (err: any) { + console.error('Failed to load from DB', err); + set({ error: 'Failed to restore previous session.' }); + } finally { + set({ isLoading: false }); + } + }, +})); diff --git a/src/types/EarningsData.ts b/src/types/EarningsData.ts new file mode 100644 index 0000000..6ea6009 --- /dev/null +++ b/src/types/EarningsData.ts @@ -0,0 +1,208 @@ +/** + * A single row from the Partner Center Incentives → Earnings → Export (Default) CSV. + * Each record represents one incentive earning transaction tied to a customer and product. + */ +export interface EarningRecord { + /** Unique identifier for this earning record (CSV: earningId) */ + earningId: string; + /** Partner's MPN or program participant ID (CSV: participantId) */ + participantId: string; + /** Partner organisation name (CSV: participantName) */ + participantName: string; + /** ISO 2-letter country code of the partner (CSV: partnerCountryCode) */ + partnerCountryCode: string; + /** Incentive program name, e.g. "CSP Direct Bill Partner" (CSV: programName) */ + programName: string; + /** Source transaction ID from the billing or usage system (CSV: transactionId) */ + transactionId: string; + /** ISO 4217 currency code of the source transaction (CSV: transactionCurrency) */ + transactionCurrency: string; + /** Date of the underlying transaction (CSV: transactionDate) */ + transactionDate: string; + /** FX rate used to convert transactionAmount to USD (CSV: transactionExchangeRate) */ + transactionExchangeRate: number; + /** Transaction revenue amount in the transaction currency (CSV: transactionAmount) */ + transactionAmount: number; + /** Transaction revenue amount in USD (CSV: transactionAmountUSD) */ + transactionAmountUSD: number; + /** Incentive lever / earning rule that triggered this record (CSV: lever) */ + lever: string; + /** Name of the incentive engagement (CSV: engagementName) */ + engagementName: string; + /** Rate (percentage or fixed) applied to compute the earning (CSV: earningRate) */ + earningRate: number; + /** Unit quantity used in the earning calculation, e.g. seat count (CSV: quantity) */ + quantity: number; + /** Category of earning, e.g. "Rebate", "Co-op" (CSV: earningType) */ + earningType: string; + /** Earning amount in the transaction currency (CSV: earningAmount) */ + earningAmount: number; + /** Earning amount converted to USD (CSV: earningAmountUSD) */ + earningAmountUSD: number; + /** Date the earning was accrued (CSV: earningDate) */ + earningDate: string; + /** Payment status code, e.g. "Upcoming", "Sent" (CSV: paymentStatus) */ + paymentStatus: string; + /** Human-readable description of the payment status (CSV: paymentStatusDescription) */ + paymentStatusDescription: string; + /** Microsoft customer tenant ID (CSV: customerId) */ + customerId: string; + /** Customer organisation name (CSV: customerName) */ + customerName: string; + /** Microsoft product part number / SKU (CSV: partNumber) */ + partNumber: string; + /** Friendly product name (CSV: productName) */ + productName: string; + /** Internal Microsoft product ID (CSV: productId) */ + productId: string; + /** Azure or M365 workload / service family (CSV: workload) */ + workload: string; + /** Type of transaction, e.g. "New", "Renewal" (CSV: transactionType) */ + transactionType: string; + /** Microsoft solution area, e.g. "Modern Work", "Azure" (CSV: solutionArea) */ + solutionArea: string; + /** Expected payment month in YYYY-MM format (CSV: estimatedPaymentMonth) */ + estimatedPaymentMonth: string; + /** Fund category used for co-op earnings (CSV: fundCategory) */ + fundCategory: string; + /** Revenue classification label (CSV: revenueClassification) */ + revenueClassification: string; + + // Optional fields — present depending on program and earning type + /** Invoice number if applicable (CSV: invoiceNumber) */ + invoiceNumber?: string; + /** Azure subscription or NCE subscription ID (CSV: subscriptionId) */ + subscriptionId?: string; + /** Subscription start date (CSV: subscriptionStartDate) */ + subscriptionStartDate?: string; + /** Subscription end date (CSV: subscriptionEndDate) */ + subscriptionEndDate?: string; + /** Reseller MPN ID when the sale went through an indirect reseller (CSV: resellerId) */ + resellerId?: string; + /** Reseller organisation name (CSV: resellerName) */ + resellerName?: string; + /** Co-op claim ID (CSV: claimId) */ + claimId?: string; + /** Payment ID linking this earning to a Payments record (CSV: paymentId) */ + paymentId?: string; + /** Tax amount remitted by Microsoft on behalf of the partner (CSV: taxRemitted) */ + taxRemitted?: number; + /** Short program code used internally (CSV: programCode) */ + programCode?: string; + /** Earning amount converted to the currency of the last payment (CSV: earningAmountInLastPaymentCurrency) */ + earningAmountInLastPaymentCurrency?: number; + /** Currency code of the last payment batch (CSV: lastPaymentCurrency) */ + lastPaymentCurrency?: string; + /** Customer country/region (CSV: customerCountry) */ + customerCountry?: string; + /** Product SKU ID (CSV: skuId) */ + skuId?: string; + /** Customer Azure AD tenant ID (CSV: customerTenantId) */ + customerTenantId?: string; + /** Product category ID (CSV: categoryId) */ + categoryId?: string; + /** Reason code for adjustments or reversals (CSV: reasonCode) */ + reasonCode?: string; + /** Unit of measure for the quantity field (CSV: quantityType) */ + quantityType?: string; + /** Milestone name for milestone-based incentives (CSV: milestone) */ + milestone?: string; +} + +/** + * Aggregated summary computed from a set of EarningRecord rows. + * Stored alongside the data in IndexedDB and shown in dashboard cards. + */ +export interface EarningsMeta { + /** Total number of earning rows loaded */ + totalRows: number; + /** Number of distinct customer names across all records */ + customersCount: number; + /** Sum of earningAmount across all records, in the primary transaction currency */ + totalEarningAmount: number; + /** ISO 4217 currency code derived from the first record's transactionCurrency */ + currency: string; +} + +/** + * Result returned by the earnings Web Worker after parsing the CSV. + */ +export interface EarningsParseResult { + /** Parsed earning records */ + data: EarningRecord[]; + /** Non-fatal parse warnings or skipped-row messages */ + errors: string[]; + /** Computed summary for the parsed dataset */ + meta: EarningsMeta; +} + +/** + * A single row from the Partner Center Incentives → Payments CSV export. + * Each record represents one payment batch sent to the partner. + */ +export interface PaymentRecord { + /** Partner's MPN or program participant ID (CSV: participantID) */ + participantId: string; + /** Type of the participant ID, e.g. "MPN" (CSV: participantIDType) */ + participantIdType: string; + /** Partner organisation name (CSV: participantName) */ + participantName: string; + /** Incentive program name (CSV: programName) */ + programName: string; + /** Total amount earned in this payment batch, in the earned currency (CSV: earned) */ + earned: number; + /** Earned amount converted to USD (CSV: earnedUSD) */ + earnedUSD: number; + /** Tax withheld by Microsoft before remitting payment (CSV: withheldTax) */ + withheldTax: number; + /** Sales tax component of the payment (CSV: salesTax) */ + salesTax: number; + /** Service fee tax applied by Microsoft (CSV: serviceFeeTax) */ + serviceFeeTax: number; + /** Net amount actually paid to the partner (CSV: totalPayment) */ + totalPayment: number; + /** ISO 4217 currency code of the earned amount (CSV: earnedCurrencyCode) */ + earnedCurrencyCode: string; + /** ISO 4217 currency code in which payment was sent (CSV: paymentCurrencyCode) */ + paymentCurrencyCode: string; + /** Payment method used, e.g. "Wire Transfer", "EFT" (CSV: paymentMethod) */ + paymentMethod: string; + /** Unique identifier for this payment batch (CSV: paymentID) */ + paymentId: string; + /** Payment status code, e.g. "Sent", "Upcoming" (CSV: paymentStatus) */ + paymentStatus: string; + /** Human-readable description of the payment status (CSV: paymentStatusDescription) */ + paymentStatusDescription: string; + /** Date the payment was sent or is scheduled (CSV: paymentDate) */ + paymentDate: string; + /** CI (bank confirmation) reference number, if available (CSV: ciReferenceNumber) */ + ciReferenceNumber?: string; +} + +/** + * Aggregated summary computed from a set of PaymentRecord rows. + */ +export interface PaymentsMeta { + /** Total number of payment rows loaded */ + totalRows: number; + /** Sum of the earned field across all records */ + totalEarned: number; + /** Sum of totalPayment across all records */ + totalPaid: number; + /** Sum of withheldTax + salesTax across all records */ + totalTax: number; + /** ISO 4217 currency code derived from the first record's earnedCurrencyCode */ + currency: string; +} + +/** + * Result returned by the payments Web Worker after parsing the CSV. + */ +export interface PaymentsParseResult { + /** Parsed payment records */ + data: PaymentRecord[]; + /** Non-fatal parse warnings or skipped-row messages */ + errors: string[]; + /** Computed summary for the parsed dataset */ + meta: PaymentsMeta; +} diff --git a/src/utils/earningsDb.ts b/src/utils/earningsDb.ts new file mode 100644 index 0000000..9e2f4fc --- /dev/null +++ b/src/utils/earningsDb.ts @@ -0,0 +1,100 @@ +import { openDB, type DBSchema } from 'idb'; +import type { EarningRecord, EarningsMeta, PaymentRecord, PaymentsMeta } from '../types/EarningsData'; + +interface EarningsDB extends DBSchema { + earnings: { + key: string; + value: { + id: string; + name?: string; + data: EarningRecord[]; + meta: EarningsMeta; + updatedAt: number; + }; + }; + payments: { + key: string; + value: { + id: string; + name?: string; + data: PaymentRecord[]; + meta: PaymentsMeta; + updatedAt: number; + }; + }; +} + +const DB_NAME = 'csp-earnings-db'; + +const initEarningsDB = async () => { + return openDB(DB_NAME, 2, { + upgrade(db) { + if (!db.objectStoreNames.contains('earnings')) { + db.createObjectStore('earnings', { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains('payments')) { + db.createObjectStore('payments', { keyPath: 'id' }); + } + }, + }); +}; + +// ── Earnings ────────────────────────────────────────────────────────────────── + +export const saveEarningsData = async (data: EarningRecord[], meta: EarningsMeta) => { + const db = await initEarningsDB(); + await db.put('earnings', { id: 'latest', name: 'Current Session', data, meta, updatedAt: Date.now() }); +}; + +export const loadEarningsData = async () => { + const db = await initEarningsDB(); + return db.get('earnings', 'latest'); +}; + +export const clearEarningsData = async () => { + const db = await initEarningsDB(); + await db.delete('earnings', 'latest'); +}; + +export const saveEarningsSnapshot = async (name: string, data: EarningRecord[], meta: EarningsMeta) => { + const db = await initEarningsDB(); + const id = `snap-${Date.now()}`; + await db.put('earnings', { id, name, data, meta, updatedAt: Date.now() }); + return id; +}; + +export const getEarningsSnapshots = async () => { + const db = await initEarningsDB(); + const all = await db.getAll('earnings'); + return all + .filter(item => item.id !== 'latest') + .sort((a, b) => b.updatedAt - a.updatedAt) + .map(({ id, name, updatedAt, meta }) => ({ id, name, updatedAt, meta })); +}; + +export const loadEarningsSnapshot = async (id: string) => { + const db = await initEarningsDB(); + return db.get('earnings', id); +}; + +export const deleteEarningsSnapshot = async (id: string) => { + const db = await initEarningsDB(); + await db.delete('earnings', id); +}; + +// ── Payments ────────────────────────────────────────────────────────────────── + +export const savePaymentsData = async (data: PaymentRecord[], meta: PaymentsMeta) => { + const db = await initEarningsDB(); + await db.put('payments', { id: 'latest', name: 'Current Session', data, meta, updatedAt: Date.now() }); +}; + +export const loadPaymentsData = async () => { + const db = await initEarningsDB(); + return db.get('payments', 'latest'); +}; + +export const clearPaymentsData = async () => { + const db = await initEarningsDB(); + await db.delete('payments', 'latest'); +}; diff --git a/src/utils/earningsParser.ts b/src/utils/earningsParser.ts new file mode 100644 index 0000000..44cef14 --- /dev/null +++ b/src/utils/earningsParser.ts @@ -0,0 +1,40 @@ +import EarningsWorker from '../workers/earnings.worker?worker'; +import type { EarningRecord, EarningsParseResult } from '../types/EarningsData'; + +export const parseEarningsCSVs = (files: File[]): Promise => { + return new Promise((resolve, reject) => { + const worker = new EarningsWorker(); + + worker.onmessage = (e: MessageEvent) => { + const { type, payload } = e.data; + worker.terminate(); + + if (type === 'SUCCESS') { + const data: EarningRecord[] = payload.data; + const currency = data[0]?.transactionCurrency || 'EUR'; + const totalEarningAmount = data.reduce((sum, r) => sum + (r.earningAmount || 0), 0); + const customersCount = new Set(data.map(r => r.customerName).filter(Boolean)).size; + + resolve({ + data, + errors: payload.errors || [], + meta: { + totalRows: data.length, + customersCount, + totalEarningAmount, + currency, + } + }); + } else { + reject(new Error(payload)); + } + }; + + worker.onerror = (err) => { + worker.terminate(); + reject(err); + }; + + worker.postMessage({ files }); + }); +}; diff --git a/src/utils/paymentsParser.ts b/src/utils/paymentsParser.ts new file mode 100644 index 0000000..4957231 --- /dev/null +++ b/src/utils/paymentsParser.ts @@ -0,0 +1,36 @@ +import PaymentsWorker from '../workers/payments.worker?worker'; +import type { PaymentRecord, PaymentsParseResult } from '../types/EarningsData'; + +export const parsePaymentsCSV = (files: File[]): Promise => { + return new Promise((resolve, reject) => { + const worker = new PaymentsWorker(); + + worker.onmessage = (e: MessageEvent) => { + const { type, payload } = e.data; + worker.terminate(); + + if (type === 'SUCCESS') { + const data: PaymentRecord[] = payload.data; + const currency = data[0]?.earnedCurrencyCode || 'EUR'; + const totalEarned = data.reduce((s, r) => s + r.earned, 0); + const totalPaid = data.reduce((s, r) => s + r.totalPayment, 0); + const totalTax = data.reduce((s, r) => s + r.salesTax + r.withheldTax, 0); + + resolve({ + data, + errors: payload.errors || [], + meta: { totalRows: data.length, totalEarned, totalPaid, totalTax, currency } + }); + } else { + reject(new Error(payload)); + } + }; + + worker.onerror = (err) => { + worker.terminate(); + reject(err); + }; + + worker.postMessage({ files }); + }); +}; diff --git a/src/workers/earnings.worker.ts b/src/workers/earnings.worker.ts new file mode 100644 index 0000000..8c87c02 --- /dev/null +++ b/src/workers/earnings.worker.ts @@ -0,0 +1,128 @@ +import Papa from 'papaparse'; + +const EXPECTED_COLUMNS = ['earningId', 'earningAmount', 'customerName']; + +const toNum = (val: any): number => { + if (typeof val === 'number') return val; + if (typeof val === 'string') { + const n = parseFloat(val.replace(/[^0-9.-]/g, '')); + return isNaN(n) ? 0 : n; + } + return 0; +}; + +const toStr = (val: any): string => (val != null ? String(val) : ''); + +self.onmessage = async (e: MessageEvent) => { + const { files } = e.data; + const allData: any[] = []; + const errors: string[] = []; + + try { + for (const file of files) { + await new Promise((resolve) => { + let headerRowIndex = 0; + + const preReader = new FileReader(); + preReader.onload = (evt) => { + const text = evt.target?.result as string; + if (!text) { resolve(); return; } + + const lines = text.split(/\r\n|\n|\r/); + const foundIndex = lines.findIndex(line => { + const normalized = line.replace(/['"]/g, ''); + return EXPECTED_COLUMNS.filter(col => normalized.includes(col)).length >= 2; + }); + if (foundIndex > -1) headerRowIndex = foundIndex; + + Papa.parse(file, { + header: true, + skipEmptyLines: 'greedy', + encoding: 'UTF-8', + beforeFirstChunk: (chunk) => { + const rows = chunk.split(/\r\n|\n|\r/); + if (headerRowIndex > 0 && rows.length > headerRowIndex) { + return rows.slice(headerRowIndex).join('\n'); + } + return chunk; + }, + complete: (results) => { + results.data.forEach((row: any) => { + // Skip rows without a valid earningId + if (!row.earningId || String(row.earningId).trim() === '') return; + + const record = { + earningId: toStr(row.earningId), + participantId: toStr(row.participantId), + participantName: toStr(row.participantName), + partnerCountryCode: toStr(row.partnerCountryCode), + programName: toStr(row.programName), + transactionId: toStr(row.transactionId), + transactionCurrency: toStr(row.transactionCurrency) || 'EUR', + transactionDate: toStr(row.transactionDate), + transactionExchangeRate: toNum(row.transactionExchangeRate) || 1, + transactionAmount: toNum(row.transactionAmount), + transactionAmountUSD: toNum(row.transactionAmountUSD), + lever: toStr(row.lever), + engagementName: toStr(row.engagementName), + earningRate: toNum(row.earningRate), + quantity: toNum(row.quantity), + earningType: toStr(row.earningType), + earningAmount: toNum(row.earningAmount), + earningAmountUSD: toNum(row.earningAmountUSD), + earningDate: toStr(row.earningDate), + paymentStatus: toStr(row.paymentStatus), + paymentStatusDescription: toStr(row.paymentStatusDescription), + customerId: toStr(row.customerId), + customerName: toStr(row.customerName), + partNumber: toStr(row.partNumber), + productName: toStr(row.productName), + productId: toStr(row.productId), + workload: toStr(row.workload), + transactionType: toStr(row.transactionType), + solutionArea: toStr(row.solutionArea), + estimatedPaymentMonth: toStr(row.estimatedPaymentMonth), + fundCategory: toStr(row.fundCategory), + revenueClassification: toStr(row.revenueClassification), + // Optional fields — only include if non-empty + ...(row.invoiceNumber ? { invoiceNumber: toStr(row.invoiceNumber) } : {}), + ...(row.subscriptionId ? { subscriptionId: toStr(row.subscriptionId) } : {}), + ...(row.subscriptionStartDate ? { subscriptionStartDate: toStr(row.subscriptionStartDate) } : {}), + ...(row.subscriptionEndDate ? { subscriptionEndDate: toStr(row.subscriptionEndDate) } : {}), + ...(row.resellerId ? { resellerId: toStr(row.resellerId) } : {}), + ...(row.resellerName ? { resellerName: toStr(row.resellerName) } : {}), + ...(row.claimId ? { claimId: toStr(row.claimId) } : {}), + ...(row.paymentId ? { paymentId: toStr(row.paymentId) } : {}), + ...(row.taxRemitted ? { taxRemitted: toNum(row.taxRemitted) } : {}), + ...(row.programCode ? { programCode: toStr(row.programCode) } : {}), + ...(row.earningAmountInLastPaymentCurrency ? { earningAmountInLastPaymentCurrency: toNum(row.earningAmountInLastPaymentCurrency) } : {}), + ...(row.lastPaymentCurrency ? { lastPaymentCurrency: toStr(row.lastPaymentCurrency) } : {}), + ...(row.customerCountry ? { customerCountry: toStr(row.customerCountry) } : {}), + ...(row.skuId ? { skuId: toStr(row.skuId) } : {}), + ...(row.customerTenantId ? { customerTenantId: toStr(row.customerTenantId) } : {}), + ...(row.categoryId ? { categoryId: toStr(row.categoryId) } : {}), + ...(row.reasonCode ? { reasonCode: toStr(row.reasonCode) } : {}), + ...(row.quantityType ? { quantityType: toStr(row.quantityType) } : {}), + ...(row.milestone ? { milestone: toStr(row.milestone) } : {}), + }; + + allData.push(record); + }); + resolve(); + }, + error: (err: any) => { + errors.push(`Error in file ${file.name}: ${err.message}`); + resolve(); + } + }); + }; + + preReader.readAsText(file.slice(0, 10240)); + }); + } + + self.postMessage({ type: 'SUCCESS', payload: { data: allData, errors } }); + } catch (err: any) { + self.postMessage({ type: 'ERROR', payload: err.message }); + } +}; diff --git a/src/workers/payments.worker.ts b/src/workers/payments.worker.ts new file mode 100644 index 0000000..ecd0df0 --- /dev/null +++ b/src/workers/payments.worker.ts @@ -0,0 +1,95 @@ +import Papa from 'papaparse'; + +const EXPECTED_COLUMNS = ['participantID', 'paymentID', 'totalPayment']; + +const toNum = (val: any): number => { + if (typeof val === 'number') return val; + if (typeof val === 'string') { + const n = parseFloat(val.replace(/[^0-9.-]/g, '')); + return isNaN(n) ? 0 : n; + } + return 0; +}; + +const toStr = (val: any): string => (val != null ? String(val).trim() : ''); + +self.onmessage = async (e: MessageEvent) => { + const { files } = e.data; + const allData: any[] = []; + const errors: string[] = []; + + try { + for (const file of files) { + await new Promise((resolve) => { + let headerRowIndex = 0; + + const preReader = new FileReader(); + preReader.onload = (evt) => { + const text = evt.target?.result as string; + if (!text) { resolve(); return; } + + const lines = text.split(/\r\n|\n|\r/); + const foundIndex = lines.findIndex(line => { + const normalized = line.replace(/['"]/g, ''); + return EXPECTED_COLUMNS.filter(col => normalized.includes(col)).length >= 2; + }); + if (foundIndex > -1) headerRowIndex = foundIndex; + + Papa.parse(file, { + header: true, + skipEmptyLines: 'greedy', + encoding: 'UTF-8', + beforeFirstChunk: (chunk) => { + const rows = chunk.split(/\r\n|\n|\r/); + if (headerRowIndex > 0 && rows.length > headerRowIndex) { + return rows.slice(headerRowIndex).join('\n'); + } + return chunk; + }, + complete: (results) => { + results.data.forEach((row: any) => { + // participantID header has no quotes; accept both casing variants + const pid = row['participantID'] || row['participantId'] || ''; + if (!pid) return; + + const record = { + participantId: toStr(pid), + participantIdType: toStr(row['participantIDType'] || row['participantIdType']), + participantName: toStr(row.participantName), + programName: toStr(row.programName), + earned: toNum(row.earned), + earnedUSD: toNum(row.earnedUSD), + withheldTax: toNum(row.withheldTax), + salesTax: toNum(row.salesTax), + serviceFeeTax: toNum(row.serviceFeeTax), + totalPayment: toNum(row.totalPayment), + earnedCurrencyCode: toStr(row.earnedCurrencyCode) || 'EUR', + paymentCurrencyCode: toStr(row.paymentCurrencyCode) || 'EUR', + paymentMethod: toStr(row.paymentMethod), + paymentId: toStr(row.paymentID || row.paymentId), + paymentStatus: toStr(row.paymentStatus), + paymentStatusDescription: toStr(row.paymentStatusDescription), + paymentDate: toStr(row.paymentDate), + ...(row.ciReferenceNumber ? { ciReferenceNumber: toStr(row.ciReferenceNumber) } : {}), + }; + + allData.push(record); + }); + resolve(); + }, + error: (err: any) => { + errors.push(`Error in file ${file.name}: ${err.message}`); + resolve(); + } + }); + }; + + preReader.readAsText(file.slice(0, 10240)); + }); + } + + self.postMessage({ type: 'SUCCESS', payload: { data: allData, errors } }); + } catch (err: any) { + self.postMessage({ type: 'ERROR', payload: err.message }); + } +}; From 81f56d458a7f32f9fe9f342f38c778315ec99819 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:38:34 +0000 Subject: [PATCH 2/9] fix: align EST_LAUNCH_DATE comment with constant value (2025-05-04) Agent-Logs-Url: https://github.com/hardinxcore/CSPInsights.app/sessions/84c0f4ee-4957-4eae-803a-ffc37c508e06 Co-authored-by: hardinxcore <34623288+hardinxcore@users.noreply.github.com> --- src/components/RenewalCalendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/RenewalCalendar.tsx b/src/components/RenewalCalendar.tsx index 3ff7ab5..50422ba 100644 --- a/src/components/RenewalCalendar.tsx +++ b/src/components/RenewalCalendar.tsx @@ -218,7 +218,7 @@ export const RenewalCalendar: React.FC = () => { const termCategory = classifyTerm(row.TermAndBillingCycle); // EST applies to annual/multi-year subscriptions whose end date is on or after - // the EST launch date (2025-05-05) and that are now past their end date. + // the EST launch date (2025-05-04) and that are now past their end date. // Subscriptions expired before EST launch fall under the old grace period model. // Monthly (Flex) and Trial are excluded — they auto-renew or expire normally. const isInEST = daysUntil < 0 From 939dffba265321cf2a4a247199cb320de617e141 Mon Sep 17 00:00:00 2001 From: Arjen Furster Date: Mon, 30 Mar 2026 21:42:09 +0200 Subject: [PATCH 3/9] feat: implement main application layout and navigation with version bump to 1.2.0 --- package.json | 2 +- src/App.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f12d834..638e884 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "csp-insights", - "version": "1.0.0", + "version": "1.2.0", "description": "A local-first billing reconciliation, pricing management, and incentives analytics toolkit for Microsoft CSP Direct partners", "homepage": "https://cspinsights.app", "license": "MIT", diff --git a/src/App.tsx b/src/App.tsx index 05b04f5..be4787c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -325,7 +325,7 @@ function App() {