diff --git a/src/App.tsx b/src/App.tsx index be4787c..a862817 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,10 +10,11 @@ import { useEarningsStore } from './store/earningsStore'; import { AzureAnalyzer } from './components/AzureAnalyzer'; import { NceAnalyzer } from './components/NceAnalyzer'; import { RenewalCalendar } from './components/RenewalCalendar'; +import { CostTimeline } from './components/CostTimeline'; import { PricingView } from './components/PricingView'; import { HomeDashboard } from './components/HomeDashboard'; import { EarningsView } from './components/EarningsView'; -import { Loader2, Settings, History, Sun, Moon, Search, LayoutGrid, BarChart3, Cloud, ShieldCheck, ExternalLink, TrendingUp, CalendarDays } from 'lucide-react'; +import { Loader2, Settings, History, Sun, Moon, Search, LayoutGrid, BarChart3, Cloud, ShieldCheck, ExternalLink, TrendingUp, CalendarDays, LineChart } from 'lucide-react'; import { generateDemoData } from './utils/demoData'; import './App.css'; @@ -35,7 +36,7 @@ function App() { const { loadFromDisk: loadEarningsFromDisk } = useEarningsStore(); // Navigation State - const [currentView, setCurrentView] = useState<'home' | 'dashboard' | 'settings' | 'azure' | 'nce' | 'renewals' | 'pricing' | 'incentives'>('home'); + const [currentView, setCurrentView] = useState<'home' | 'dashboard' | 'settings' | 'azure' | 'nce' | 'renewals' | 'timeline' | 'pricing' | 'incentives'>('home'); const [showHistory, setShowHistory] = useState(false); const [isUploading, setIsUploading] = useState(false); // New state to control upload view overlay @@ -116,7 +117,7 @@ function App() { {/* Search Section */} -
+
- {(currentView === 'dashboard' || currentView === 'azure' || currentView === 'nce' || currentView === 'renewals' || currentView === 'pricing' || currentView === 'incentives') && ( + {(currentView === 'dashboard' || currentView === 'azure' || currentView === 'nce' || currentView === 'renewals' || currentView === 'timeline' || currentView === 'pricing' || currentView === 'incentives') && (
)} - {(currentView === 'dashboard' || currentView === 'azure' || currentView === 'nce' || currentView === 'renewals') && ( + {(currentView === 'dashboard' || currentView === 'azure' || currentView === 'nce' || currentView === 'renewals' || currentView === 'timeline') && ( <> {showDashboard && (
@@ -272,6 +273,17 @@ function App() { > Renewal Calendar +
)} @@ -318,6 +330,7 @@ function App() { {currentView === 'azure' && } {currentView === 'nce' && } {currentView === 'renewals' && } + {currentView === 'timeline' && } )} diff --git a/src/components/CostTimeline.tsx b/src/components/CostTimeline.tsx new file mode 100644 index 0000000..616b963 --- /dev/null +++ b/src/components/CostTimeline.tsx @@ -0,0 +1,754 @@ +import React, { useMemo, useState, useEffect, useRef } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + AreaChart, Area, Cell, +} from 'recharts'; +import { useBillingStore } from '../store/billingStore'; +import type { BillingRecord } from '../types/BillingData'; +import { TrendingUp, TrendingDown, Minus, ChevronUp, ChevronDown, X } from 'lucide-react'; + +// ── Category detection ───────────────────────────────────────────────────────── + +type Category = 'Azure' | 'M365' | 'Dynamics' | 'Marketplace' | 'Other'; + +const CAT_COLORS: Record = { + Azure: '#00B5E2', + M365: '#10B981', + Dynamics: '#8B5CF6', + Marketplace: '#F59E0B', + Other: '#6B7280', +}; + +const CATEGORIES: Category[] = ['M365', 'Azure', 'Dynamics', 'Marketplace', 'Other']; + +function categorize(row: BillingRecord): Category { + const product = (row.ProductName || '').toLowerCase(); + const publisher = (row.PublisherName || '').toLowerCase(); + if (row.MeterId || row.MeterCategory) return 'Azure'; + if (product.includes('azure')) return 'Azure'; + if (publisher && publisher !== 'microsoft' && publisher !== 'microsoft corporation') return 'Marketplace'; + if (product.includes('dynamics') || product.includes('d365') + || product.includes('power platform') || product.includes('power apps') + || product.includes('power automate') || product.includes('power bi') + || product.includes('power virtual') || product.includes('power pages')) + return 'Dynamics'; + if (product.includes('microsoft 365') || product.includes('office 365') + || product.includes('teams') || product.includes('exchange') + || product.includes('sharepoint') || product.includes('defender') + || product.includes('intune') || product.includes('entra') + || product.includes('windows') || product.includes('visio') + || product.includes('project online')) + return 'M365'; + return 'Other'; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function rowValue(r: BillingRecord): number { return r.Total ?? r.Subtotal ?? 0; } + +function parseMonth(d: string): string | null { + if (!d) return null; + const dt = new Date(d); + if (isNaN(dt.getTime())) return null; + return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`; +} + +function parseDay(d: string): string | null { + if (!d) return null; + const dt = new Date(d); + if (isNaN(dt.getTime())) return null; + return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; +} + +function monthLabel(ym: string): string { + const [y, m] = ym.split('-'); + return new Date(Number(y), Number(m) - 1, 1).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }); +} + +function fmt(v: number, currency = 'EUR'): string { + return v.toLocaleString('nl-NL', { style: 'currency', currency, minimumFractionDigits: 0, maximumFractionDigits: 0 }); +} + +function fmtShort(v: number): string { + if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(0)}k`; + return v.toFixed(0); +} + +function fmtDate(s: string | undefined): string { + if (!s) return '—'; + const d = new Date(s); + return isNaN(d.getTime()) ? s : d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); +} + +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.82rem', color: 'var(--text-primary)' }, + labelStyle: { color: 'var(--text-primary)', fontWeight: 600 }, + itemStyle: { color: 'var(--text-secondary)' }, + cursor: { fill: 'var(--bg-tertiary)' }, +}; + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface MonthBucket { + month: string; label: string; + Azure: number; M365: number; Dynamics: number; Marketplace: number; Other: number; + total: number; +} + +interface DayBucket { day: string; label: string; total: number; } + +// ── Sub-components ───────────────────────────────────────────────────────────── + +const SummaryCard: React.FC<{ label: string; value: string; sub?: string; color?: string; icon?: React.ReactNode }> = + ({ label, value, sub, color = 'var(--text-primary)', icon }) => ( +
+ {icon &&
{icon}
} +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+
+); + +// ── Dual Range Slider ────────────────────────────────────────────────────────── +// Uses a single pointer-capture hit area instead of two overlapping +// elements, which avoids the z-index race that makes the start handle unreachable. + +const DualRangeSlider: React.FC<{ + min: number; max: number; + start: number; end: number; + onStartChange: (v: number) => void; + onEndChange: (v: number) => void; + labels: string[]; +}> = ({ min, max, start, end, onStartChange, onEndChange, labels }) => { + const trackRef = useRef(null); + const dragging = useRef<'start' | 'end' | null>(null); + const range = max - min || 1; + const startPct = ((start - min) / range) * 100; + const endPct = ((end - min) / range) * 100; + + const valueFromClient = (clientX: number): number => { + const rect = trackRef.current!.getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return Math.round(min + pct * range); + }; + + const handlePointerDown = (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId); + const val = valueFromClient(e.clientX); + const distStart = Math.abs(val - start); + const distEnd = Math.abs(val - end); + // When equidistant prefer start if moving left, end if moving right + dragging.current = distStart <= distEnd ? 'start' : 'end'; + if (dragging.current === 'start' && val <= end) onStartChange(val); + if (dragging.current === 'end' && val >= start) onEndChange(val); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!dragging.current || !(e.buttons & 1)) return; + const val = valueFromClient(e.clientX); + if (dragging.current === 'start' && val <= end) onStartChange(val); + if (dragging.current === 'end' && val >= start) onEndChange(val); + }; + + const handlePointerUp = () => { dragging.current = null; }; + + const handleStyle: React.CSSProperties = { + position: 'absolute', top: '50%', width: 16, height: 16, borderRadius: '50%', + background: 'var(--accent-primary, #6366F1)', border: '2.5px solid var(--bg-secondary)', + transform: 'translate(-50%, -50%)', pointerEvents: 'none', + boxShadow: '0 1px 5px rgba(0,0,0,0.25)', transition: 'left 0.04s', + }; + + return ( +
+
+ {/* Base track */} +
+ {/* Highlighted range */} +
+ {/* Start handle */} +
+ {/* End handle */} +
+
+
+ {labels[start]} + {start !== end && {labels[end]}} +
+
+ ); +}; + +// ── Record Detail Panel ──────────────────────────────────────────────────────── + +const RecordDetail: React.FC<{ row: BillingRecord; currency: string }> = ({ row, currency }) => { + const fields: [string, string | number | undefined][] = [ + ['Customer', row.CustomerName], + ['Customer ID', row.CustomerId], + ['Domain', row.CustomerDomainName], + ['Product', row.ProductName], + ['SKU', row.SkuName], + ['SKU ID', row.SkuId], + ['Publisher', row.PublisherName], + ['Subscription', row.SubscriptionDescription], + ['Subscription ID', row.SubscriptionId], + ['Term', row.TermAndBillingCycle], + ['Charge Type', row.ChargeType], + ['Charge Start', fmtDate(row.ChargeStartDate)], + ['Charge End', fmtDate(row.ChargeEndDate)], + ['Order Date', fmtDate(row.OrderDate)], + ['Quantity', row.Quantity], + ['Billable Qty', row.BillableQuantity], + ['Unit Price', row.UnitPrice != null ? fmt(row.UnitPrice, currency) : undefined], + ['Eff. Unit Price', row.EffectiveUnitPrice != null ? fmt(row.EffectiveUnitPrice, currency) : undefined], + ['Subtotal', row.Subtotal != null ? fmt(row.Subtotal, currency) : undefined], + ['Tax', row.TaxTotal != null ? fmt(row.TaxTotal, currency) : undefined], + ['Total', row.Total != null ? fmt(row.Total, currency) : undefined], + ['Invoice #', row.InvoiceNumber], + ['Order ID', row.OrderId], + ['Meter', row.MeterName], + ['Meter Category', row.MeterCategory], + ]; + + return ( +
+
+ {fields.filter(([, v]) => v != null && v !== '' && v !== '—').map(([label, val]) => ( +
+ {label} + {String(val)} +
+ ))} +
+
+ ); +}; + +// ── Main Component ───────────────────────────────────────────────────────────── + +export const CostTimeline: React.FC = () => { + const { data } = useBillingStore(); + + const [granularity, setGranularity] = useState<'month' | 'day'>('month'); + const [rangeStart, setRangeStart] = useState(0); + const [rangeEnd, setRangeEnd] = useState(0); + const [rangeReady, setRangeReady] = useState(false); + const [selectedMonth, setSelectedMonth] = useState(null); + const [search, setSearch] = useState(''); + const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'value', dir: 'desc' }); + const [expandedRow, setExpandedRow] = useState(null); + + const currency = useMemo(() => + data.find(r => r.Currency)?.Currency ?? data.find(r => r.PricingCurrency)?.PricingCurrency ?? 'EUR', + [data]); + + // ── Monthly aggregation ──────────────────────────────────────────────────── + const { months, recordsByMonth } = useMemo(() => { + const buckets = new Map(); + const byMonth = new Map(); + for (const row of data) { + const month = parseMonth(row.ChargeStartDate); + if (!month) continue; + if (!buckets.has(month)) { + buckets.set(month, { month, label: monthLabel(month), Azure: 0, M365: 0, Dynamics: 0, Marketplace: 0, Other: 0, total: 0 }); + byMonth.set(month, []); + } + const b = buckets.get(month)!; + const v = rowValue(row); + b[categorize(row)] += v; + b.total += v; + byMonth.get(month)!.push(row); + } + const sorted = Array.from(buckets.values()).sort((a, b) => a.month.localeCompare(b.month)); + return { months: sorted, recordsByMonth: byMonth }; + }, [data]); + + // ── Daily aggregation ───────────────────────────────────────────────────── + const { days, recordsByDay } = useMemo(() => { + const buckets = new Map(); + for (const row of data) { + const day = parseDay(row.ChargeStartDate); + if (!day) continue; + if (!buckets.has(day)) { + const label = new Date(day).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); + buckets.set(day, { day, label, total: 0, records: [] }); + } + const b = buckets.get(day)!; + b.total += rowValue(row); + b.records.push(row); + } + const sorted = Array.from(buckets.values()).sort((a, b) => a.day.localeCompare(b.day)); + const byDay = new Map(sorted.map(b => [b.day, b.records])); + return { days: sorted, recordsByDay: byDay }; + }, [data]); + + // Initialise range to full span on first load or granularity switch + useEffect(() => { + if (!rangeReady && months.length) { + setRangeStart(0); + setRangeEnd(months.length - 1); + setRangeReady(true); + } + }, [months, rangeReady]); + + // Reset range when switching granularity + useEffect(() => { + setRangeStart(0); + setRangeEnd(granularity === 'month' ? Math.max(0, months.length - 1) : Math.max(0, days.length - 1)); + setSelectedMonth(null); + setExpandedRow(null); + }, [granularity]); // eslint-disable-line react-hooks/exhaustive-deps + + const monthLabels = useMemo(() => months.map(m => m.label), [months]); + const dayLabels = useMemo(() => days.map(d => d.label), [days]); + + // Months inside the selected range (month mode) + const periodMonths = useMemo( + () => granularity === 'month' ? months.slice(rangeStart, rangeEnd + 1) : [], + [granularity, months, rangeStart, rangeEnd]); + + // Days inside the selected range (day mode) + const periodDays = useMemo( + () => granularity === 'day' ? days.slice(rangeStart, rangeEnd + 1) : [], + [granularity, days, rangeStart, rangeEnd]); + + // All records in the selected period + const periodRecords = useMemo(() => { + if (granularity === 'month') { + const rows: BillingRecord[] = []; + for (const m of periodMonths) rows.push(...(recordsByMonth.get(m.month) ?? [])); + return rows; + } else { + const rows: BillingRecord[] = []; + for (const d of periodDays) rows.push(...(recordsByDay.get(d.day) ?? [])); + return rows; + } + }, [granularity, periodMonths, periodDays, recordsByMonth, recordsByDay]); + + // Daily chart data — for month drill-down (month mode) or the full period (day mode) + const dailyData = useMemo(() => { + if (granularity === 'day') { + return periodDays.map(d => ({ day: d.day, label: new Date(d.day).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }), total: d.total })); + } + if (!selectedMonth) return []; + const rows = recordsByMonth.get(selectedMonth) ?? []; + const buckets = new Map(); + for (const row of rows) { + const day = parseDay(row.ChargeStartDate); + if (!day) continue; + buckets.set(day, (buckets.get(day) ?? 0) + rowValue(row)); + } + return Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, total]) => ({ + day, + label: new Date(day).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }), + total, + })); + }, [granularity, selectedMonth, recordsByMonth, periodDays]); + + // Top customers for selected month (drill-down) or full period + const topCustomers = useMemo(() => { + const rows = selectedMonth ? (recordsByMonth.get(selectedMonth) ?? []) : periodRecords; + const map = new Map(); + for (const row of rows) map.set(row.CustomerName, (map.get(row.CustomerName) ?? 0) + rowValue(row)); + return Array.from(map.entries()).sort(([, a], [, b]) => b - a).slice(0, 8).map(([name, value]) => ({ name, value })); + }, [selectedMonth, recordsByMonth, periodRecords]); + + // Records for the table — drill-down month OR full period + const baseRows = selectedMonth ? (recordsByMonth.get(selectedMonth) ?? []) : periodRecords; + const tableRows = useMemo(() => { + const s = search.toLowerCase(); + const filtered = s + ? baseRows.filter(r => r.CustomerName.toLowerCase().includes(s) || r.ProductName.toLowerCase().includes(s)) + : baseRows; + return [...filtered].sort((a, b) => { + if (sort.key === 'value') { + const av = rowValue(a), bv = rowValue(b); + return sort.dir === 'asc' ? av - bv : bv - av; + } + const av = String((a as any)[sort.key] ?? ''); + const bv = String((b as any)[sort.key] ?? ''); + return sort.dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); + }); + }, [baseRows, search, sort]); + + // Period summary stats + const stats = useMemo(() => { + if (granularity === 'month') { + if (!periodMonths.length) return null; + const periodTotal = periodMonths.reduce((s, m) => s + m.total, 0); + const prev = rangeStart > 0 ? months[rangeStart - 1] : null; + const mom = prev && prev.total > 0 ? ((periodMonths[0].total - prev.total) / prev.total) * 100 : null; + const avg = periodTotal / periodMonths.length; + const peak = periodMonths.reduce((b, m) => m.total > b.total ? m : b, periodMonths[0]); + return { periodTotal, mom, avg, avgLabel: 'Monthly Average', peak: { total: peak.total, label: peak.label } }; + } else { + if (!periodDays.length) return null; + const periodTotal = periodDays.reduce((s, d) => s + d.total, 0); + const prev = rangeStart > 0 ? days[rangeStart - 1] : null; + const mom = prev && prev.total > 0 ? ((periodDays[0].total - prev.total) / prev.total) * 100 : null; + const avg = periodTotal / periodDays.length; + const peak = periodDays.reduce((b, d) => d.total > b.total ? d : b, periodDays[0]); + return { periodTotal, mom, avg, avgLabel: 'Daily Average', peak: { total: peak.total, label: peak.label } }; + } + }, [granularity, periodMonths, periodDays, months, days, rangeStart]); + + // Category totals for period + const periodCatTotals = useMemo(() => { + const t: Record = { Azure: 0, M365: 0, Dynamics: 0, Marketplace: 0, Other: 0 }; + if (granularity === 'month') { + for (const m of periodMonths) CATEGORIES.forEach(cat => { t[cat] += m[cat]; }); + } else { + for (const row of periodRecords) t[categorize(row)] += rowValue(row); + } + return t; + }, [granularity, periodMonths, periodRecords]); + + const toggleSort = (key: string) => + setSort(s => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'desc' }); + + const SortIcon = ({ col }: { col: string }) => + sort.key === col + ? sort.dir === 'asc' ? : + : ; + + // ── Empty state ──────────────────────────────────────────────────────────── + if (!data.length) return ( +
+ +

Load billing data to view the cost timeline.

+
+ ); + + const selectedData = selectedMonth ? months.find(m => m.month === selectedMonth) : null; + const hasDailyData = dailyData.length > 1; + const periodTotal = stats?.periodTotal ?? 0; + const drillTotal = selectedData?.total ?? 0; + const displayTotal = selectedMonth ? drillTotal : periodTotal; + + const sliderMax = granularity === 'month' ? Math.max(0, months.length - 1) : Math.max(0, days.length - 1); + const sliderLabels = granularity === 'month' ? monthLabels : dayLabels; + const periodCount = granularity === 'month' ? periodMonths.length : periodDays.length; + const periodUnit = granularity === 'month' ? 'month' : 'day'; + + return ( +
+ + {/* ── Period selector ─────────────────────────────────────────── */} + {sliderMax > 0 && ( +
+
+
+ Period + {/* Granularity toggle */} +
+ {(['month', 'day'] as const).map(g => ( + + ))} +
+
+
+ + {sliderLabels[rangeStart]} + {rangeStart !== rangeEnd && <> → {sliderLabels[rangeEnd]}} + {' '}·{' '} + + {periodCount} {periodUnit}{periodCount !== 1 ? 's' : ''} + + + {(rangeStart !== 0 || rangeEnd !== sliderMax) && ( + + )} +
+
+ { setRangeStart(v); setSelectedMonth(null); setExpandedRow(null); }} + onEndChange={v => { setRangeEnd(v); setSelectedMonth(null); setExpandedRow(null); }} + labels={sliderLabels} + /> +
+ )} + + {/* ── Summary cards ───────────────────────────────────────────── */} + {stats && ( +
+ } + /> + } + /> + = 0 ? '+' : ''}${stats.mom.toFixed(1)}%` : '—'} + sub="First month in period vs. prior" + color={stats.mom == null ? 'var(--text-secondary)' : stats.mom > 5 ? '#FE5000' : stats.mom < -5 ? '#10B981' : 'var(--text-primary)'} + icon={stats.mom == null ? : stats.mom >= 0 ? : } + /> + } + /> +
+ )} + + {/* ── Chart (month = stacked bar / day = area) ─────────────────── */} +
+
+
+
+ {granularity === 'month' ? 'Monthly Cost Breakdown' : 'Daily Cost Overview'} +
+
+ {granularity === 'month' + ? 'Months outside the period are dimmed · click a bar to drill in' + : `${periodDays.length} day${periodDays.length !== 1 ? 's' : ''} selected`} +
+
+ {granularity === 'month' && ( +
+ {CATEGORIES.map(cat => ( +
+ + {cat} +
+ ))} +
+ )} +
+ + {granularity === 'month' ? ( + + { + if (e?.activeLabel) { + const m = months.find(x => x.label === e.activeLabel); + if (m) { + const idx = months.indexOf(m); + if (idx >= rangeStart && idx <= rangeEnd) + setSelectedMonth(selectedMonth === m.month ? null : m.month); + } + } + }}> + + + fmtShort(v)} tick={axisStyle} width={65} /> + [fmt(v, currency), name]} /> + {CATEGORIES.map(cat => ( + + {months.map((m, i) => { + const inPeriod = i >= rangeStart && i <= rangeEnd; + const isSelected = selectedMonth === m.month; + return ; + })} + + ))} + + + ) : ( + + + + + + + + + + + fmtShort(v)} tick={axisStyle} width={65} /> + [fmt(v, currency), 'Cost']} /> + + + + )} +
+ + {/* ── Category pills for period / drill-down ──────────────────── */} +
+ {CATEGORIES.filter(cat => (selectedData ? selectedData[cat] : periodCatTotals[cat]) > 0).map(cat => { + const val = selectedData ? selectedData[cat] : periodCatTotals[cat]; + const total = selectedData ? selectedData.total : periodTotal; + return ( +
+ + {cat}: {fmt(val, currency)} + ({total > 0 ? ((val / total) * 100).toFixed(0) : 0}%) +
+ ); + })} +
+ + {/* ── Drill-down header (month mode only) ─────────────────────── */} + {granularity === 'month' && selectedMonth && selectedData && ( +
+
+ {selectedData.label} + + {fmt(selectedData.total, currency)} · {(recordsByMonth.get(selectedMonth) ?? []).length} records + +
+ +
+ )} + + {/* ── Daily chart (month drill-down only) + top customers ─────── */} +
+ {granularity === 'month' && hasDailyData && ( +
+
+ Daily Costs — {selectedData?.label} +
+ + + + + + + + + + + fmtShort(v)} tick={axisStyle} width={65} /> + [fmt(v, currency), 'Cost']} /> + + + +
+ )} + {topCustomers.length > 0 && ( +
+
+ Top Customers {selectedMonth ? '' : '— Period'} +
+
+ {topCustomers.map((c, i) => { + const base = selectedMonth ? (selectedData?.total ?? 1) : periodTotal; + const pct = base > 0 ? (c.value / base) * 100 : 0; + return ( +
+
+ {c.name} + {fmt(c.value, currency)} +
+
+
+
+
+ ); + })} +
+
+ )} +
+ + {/* ── Records table ────────────────────────────────────────────── */} +
+ {/* Toolbar */} +
+ setSearch(e.target.value)} + style={{ flex: 1, padding: '0.35rem 0.7rem', borderRadius: '8px', border: '1px solid var(--border-color)', background: 'var(--bg-tertiary)', color: 'var(--text-primary)', fontSize: '0.83rem' }} + /> + + {tableRows.length} record{tableRows.length !== 1 ? 's' : ''} + {granularity === 'month' + ? (selectedMonth ? ` · ${selectedData?.label}` : ` · ${periodMonths.length} months`) + : ` · ${periodDays.length} day${periodDays.length !== 1 ? 's' : ''}`} + + {expandedRow && ( + + )} +
+ + {/* Header */} +
+
+ {([['CustomerName','Customer'],['ProductName','Product'],['ChargeType','Type'],['ChargeStartDate','Date'],['value','Amount']] as const).map(([key, label]) => ( +
toggleSort(key)} + style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.2rem', + justifyContent: key === 'value' || key === 'ChargeType' || key === 'ChargeStartDate' ? 'flex-end' : 'flex-start' }}> + {label} +
+ ))} +
+ + {/* Body */} +
+ {tableRows.slice(0, 300).map((row, i) => { + const key = `${row.SubscriptionId ?? ''}-${row.ChargeStartDate ?? ''}-${i}`; + const isExpanded = expandedRow === key; + return ( + +
setExpandedRow(isExpanded ? null : key)} + style={{ display: 'grid', gridTemplateColumns: '24px 1fr 1fr 110px 110px 110px', gap: '0.5rem', padding: '0.45rem 0.75rem', borderBottom: '1px solid var(--border-color)', fontSize: '0.82rem', cursor: 'pointer', background: isExpanded ? 'rgba(99,102,241,0.07)' : i % 2 === 0 ? 'transparent' : 'var(--bg-tertiary)', transition: 'background 0.1s' }} + onMouseEnter={e => { if (!isExpanded) (e.currentTarget as HTMLElement).style.background = 'var(--bg-tertiary)'; }} + onMouseLeave={e => { if (!isExpanded) (e.currentTarget as HTMLElement).style.background = i % 2 === 0 ? 'transparent' : 'var(--bg-tertiary)'; }} + > +
+ {isExpanded ? : } +
+
{row.CustomerName}
+
{row.ProductName}
+
{row.ChargeType ?? '—'}
+
+ {row.ChargeStartDate ? new Date(row.ChargeStartDate).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) : '—'} +
+
{fmt(rowValue(row), currency)}
+
+ {isExpanded && } +
+ ); + })} + {tableRows.length > 300 && ( +
+ Showing first 300 of {tableRows.length} records — use the filter to narrow down +
+ )} + {tableRows.length === 0 && ( +
+ No records match the current filter. +
+ )} +
+
+
+ ); +};