From 71967cf5e51910f1735bc6d38249afd0c7322796 Mon Sep 17 00:00:00 2001 From: Arjen Furster Date: Thu, 2 Apr 2026 18:34:48 +0200 Subject: [PATCH 1/2] feat: add CostTimeline component and integrate it into the main navigation view --- src/App.tsx | 23 +- src/components/CostTimeline.tsx | 456 ++++++++++++++++++++++++++++++++ 2 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 src/components/CostTimeline.tsx 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..4d2117e --- /dev/null +++ b/src/components/CostTimeline.tsx @@ -0,0 +1,456 @@ +import React, { useMemo, useState } 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(); + + // Usage-based (Azure) rows always have a MeterId or MeterCategory + if (row.MeterId || row.MeterCategory) return 'Azure'; + if (product.includes('azure')) return 'Azure'; + + // ISV / Marketplace — non-Microsoft publisher + if (publisher && publisher !== 'microsoft' && publisher !== 'microsoft corporation') return 'Marketplace'; + + // Dynamics / Power Platform + 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'; + + // M365 / Modern Work + 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(dateStr: string): string | null { + if (!dateStr) return null; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return null; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; +} + +function parseDay(dateStr: string): string | null { + if (!dateStr) return null; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return null; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.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, _currency = 'EUR'): 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); +} + +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}
} +
+
+); + +// ── Main Component ───────────────────────────────────────────────────────────── + +export const CostTimeline: React.FC = () => { + const { data } = useBillingStore(); + + const [selectedMonth, setSelectedMonth] = useState(null); + const [search, setSearch] = useState(''); + const [sort, setSort] = useState<{ key: keyof BillingRecord | 'value'; dir: 'asc' | 'desc' }>({ key: 'value', dir: 'desc' }); + + 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); + const cat = categorize(row); + b[cat] += 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 breakdown for selected month ──────────────────────────────────── + const dailyData = useMemo(() => { + 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, + })); + }, [selectedMonth, recordsByMonth]); + + // ── Top customers for selected month ────────────────────────────────────── + const topCustomers = useMemo(() => { + const rows = selectedMonth ? (recordsByMonth.get(selectedMonth) ?? []) : []; + 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]); + + // ── Records table for selected month ────────────────────────────────────── + const tableRows = useMemo(() => { + const rows = selectedMonth ? (recordsByMonth.get(selectedMonth) ?? []) : []; + const s = search.toLowerCase(); + const filtered = s + ? rows.filter(r => r.CustomerName.toLowerCase().includes(s) || r.ProductName.toLowerCase().includes(s)) + : rows; + 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); + }); + }, [selectedMonth, recordsByMonth, search, sort]); + + // ── Summary stats ────────────────────────────────────────────────────────── + const stats = useMemo(() => { + if (!months.length) return null; + const last = months[months.length - 1]; + const prev = months.length > 1 ? months[months.length - 2] : null; + const mom = prev && prev.total > 0 ? ((last.total - prev.total) / prev.total) * 100 : null; + const avg = months.reduce((s, m) => s + m.total, 0) / months.length; + const peak = months.reduce((best, m) => m.total > best.total ? m : best, months[0]); + return { last, prev, mom, avg, peak }; + }, [months]); + + const toggleSort = (key: typeof sort.key) => { + setSort(s => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'desc' }); + }; + + const SortIcon = ({ col }: { col: typeof sort.key }) => + 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; + + return ( +
+ + {/* Summary cards */} + {stats && ( +
+ } + /> + = 0 ? '+' : ''}${stats.mom.toFixed(1)}%` : '—'} + sub={stats.prev ? `vs ${stats.prev.label}` : 'No previous month'} + 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 ? : } + /> + 1 ? 's' : ''}`} + icon={} + /> + } + /> +
+ )} + + {/* Monthly stacked bar chart */} +
+
+
+
Monthly Cost Breakdown
+
Click a bar to drill into that month
+
+ {/* Legend */} +
+ {CATEGORIES.map(cat => ( +
+ + {cat} +
+ ))} +
+
+ + { + if (e?.activeLabel) { + const m = months.find(x => x.label === e.activeLabel); + if (m) setSelectedMonth(selectedMonth === m.month ? null : m.month); + } + }}> + + + fmtShort(v, currency)} tick={axisStyle} width={65} /> + [fmt(v, currency), name]} + /> + {CATEGORIES.map(cat => ( + + {months.map((m, i) => ( + + ))} + + ))} + + +
+ + {/* Selected month drill-down */} + {selectedMonth && selectedData && ( +
+ + {/* Month header */} +
+
+ {selectedData.label} + + {fmt(selectedData.total, currency)} · {(recordsByMonth.get(selectedMonth) ?? []).length} records + +
+ +
+ + {/* Category breakdown pills */} +
+ {CATEGORIES.filter(cat => selectedData[cat] > 0).map(cat => ( +
+ + {cat}: {fmt(selectedData[cat], currency)} + ({((selectedData[cat] / selectedData.total) * 100).toFixed(0)}%) +
+ ))} +
+ + {/* Daily chart + top customers side by side */} +
+ + {hasDailyData && ( +
+
Daily Costs — {selectedData.label}
+ + + + + + + + + + + fmtShort(v, currency)} tick={axisStyle} width={65} /> + [fmt(v, currency), 'Cost']} /> + + + +
+ )} + + {/* Top customers */} + {topCustomers.length > 0 && ( +
+
Top Customers
+
+ {topCustomers.map((c, i) => { + const pct = selectedData.total > 0 ? (c.value / selectedData.total) * 100 : 0; + return ( +
+
+ {c.name} + {fmt(c.value, currency)} +
+
+
+
+
+ ); + })} +
+
+ )} +
+ + {/* Records table */} +
+
+ 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' : ''} + +
+ + {/* Table header */} +
+ {([ + ['CustomerName', 'Customer'], + ['ProductName', 'Product'], + ['ChargeType', 'Type'], + ['ChargeStartDate', 'Date'], + ['value', 'Amount'], + ] as const).map(([key, label]) => ( +
toggleSort(key as any)} + style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.25rem', userSelect: 'none', + justifyContent: key === 'value' || key === 'ChargeType' || key === 'ChargeStartDate' ? 'flex-end' : 'flex-start' }}> + {label} +
+ ))} +
+ + {/* Table body — capped at 200 rows for performance */} +
+ {tableRows.slice(0, 200).map((row, i) => ( +
+
{row.CustomerName}
+
{row.ProductName}
+
{row.ChargeType ?? '—'}
+
+ {row.ChargeStartDate ? new Date(row.ChargeStartDate).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) : '—'} +
+
{fmt(rowValue(row), currency)}
+
+ ))} + {tableRows.length > 200 && ( +
+ Showing first 200 of {tableRows.length} records — use the filter to narrow down +
+ )} +
+
+
+ )} +
+ ); +}; From 58d80bdb951ae1f30519977be612a5c5f78b9140 Mon Sep 17 00:00:00 2001 From: Arjen Furster Date: Thu, 2 Apr 2026 20:54:30 +0200 Subject: [PATCH 2/2] feat: add CostTimeline component with category-based billing visualization and dual-range filtering --- src/components/CostTimeline.tsx | 786 ++++++++++++++++++++++---------- 1 file changed, 542 insertions(+), 244 deletions(-) diff --git a/src/components/CostTimeline.tsx b/src/components/CostTimeline.tsx index 4d2117e..616b963 100644 --- a/src/components/CostTimeline.tsx +++ b/src/components/CostTimeline.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useEffect, useRef } from 'react'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area, Cell, @@ -22,24 +22,16 @@ const CAT_COLORS: Record = { const CATEGORIES: Category[] = ['M365', 'Azure', 'Dynamics', 'Marketplace', 'Other']; function categorize(row: BillingRecord): Category { - const product = (row.ProductName || '').toLowerCase(); - const publisher = (row.PublisherName || '').toLowerCase(); - - // Usage-based (Azure) rows always have a MeterId or MeterCategory + const product = (row.ProductName || '').toLowerCase(); + const publisher = (row.PublisherName || '').toLowerCase(); if (row.MeterId || row.MeterCategory) return 'Azure'; if (product.includes('azure')) return 'Azure'; - - // ISV / Marketplace — non-Microsoft publisher if (publisher && publisher !== 'microsoft' && publisher !== 'microsoft corporation') return 'Marketplace'; - - // Dynamics / Power Platform 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'; - - // M365 / Modern Work if (product.includes('microsoft 365') || product.includes('office 365') || product.includes('teams') || product.includes('exchange') || product.includes('sharepoint') || product.includes('defender') @@ -47,28 +39,25 @@ function categorize(row: BillingRecord): Category { || 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 rowValue(r: BillingRecord): number { return r.Total ?? r.Subtotal ?? 0; } -function parseMonth(dateStr: string): string | null { - if (!dateStr) return null; - const d = new Date(dateStr); - if (isNaN(d.getTime())) return null; - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '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(dateStr: string): string | null { - if (!dateStr) return null; - const d = new Date(dateStr); - if (isNaN(d.getTime())) return null; - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).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 { @@ -80,14 +69,20 @@ function fmt(v: number, currency = 'EUR'): string { return v.toLocaleString('nl-NL', { style: 'currency', currency, minimumFractionDigits: 0, maximumFractionDigits: 0 }); } -function fmtShort(v: number, _currency = 'EUR'): string { +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); } -const axisStyle = { fontSize: 11, fill: 'var(--text-tertiary)' }; -const gridStyle = { stroke: 'var(--border-color)', strokeDasharray: '3 3' }; +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 }, @@ -98,21 +93,17 @@ const tooltipStyle = { // ── Types ────────────────────────────────────────────────────────────────────── interface MonthBucket { - month: string; - label: string; + month: string; label: string; Azure: number; M365: number; Dynamics: number; Marketplace: number; Other: number; total: number; } -interface DayBucket { - day: string; - label: string; - 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 }) => ( +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}
}
@@ -123,44 +114,232 @@ const SummaryCard: React.FC<{ label: string; value: string; sub?: string; color?
); +// ── 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: keyof BillingRecord | 'value'; dir: 'asc' | 'desc' }>({ key: 'value', dir: 'desc' }); + 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]); + 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); - const cat = categorize(row); - b[cat] += v; + 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 breakdown for selected month ──────────────────────────────────── + // ── 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(); @@ -176,26 +355,23 @@ export const CostTimeline: React.FC = () => { label: new Date(day).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }), total, })); - }, [selectedMonth, recordsByMonth]); + }, [granularity, selectedMonth, recordsByMonth, periodDays]); - // ── Top customers for selected month ────────────────────────────────────── + // Top customers for selected month (drill-down) or full period const topCustomers = useMemo(() => { - const rows = selectedMonth ? (recordsByMonth.get(selectedMonth) ?? []) : []; + 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]); + return Array.from(map.entries()).sort(([, a], [, b]) => b - a).slice(0, 8).map(([name, value]) => ({ name, value })); + }, [selectedMonth, recordsByMonth, periodRecords]); - // ── Records table for selected month ────────────────────────────────────── + // Records for the table — drill-down month OR full period + const baseRows = selectedMonth ? (recordsByMonth.get(selectedMonth) ?? []) : periodRecords; const tableRows = useMemo(() => { - const rows = selectedMonth ? (recordsByMonth.get(selectedMonth) ?? []) : []; const s = search.toLowerCase(); const filtered = s - ? rows.filter(r => r.CustomerName.toLowerCase().includes(s) || r.ProductName.toLowerCase().includes(s)) - : rows; + ? 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); @@ -205,24 +381,44 @@ export const CostTimeline: React.FC = () => { const bv = String((b as any)[sort.key] ?? ''); return sort.dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); }); - }, [selectedMonth, recordsByMonth, search, sort]); + }, [baseRows, search, sort]); - // ── Summary stats ────────────────────────────────────────────────────────── + // Period summary stats const stats = useMemo(() => { - if (!months.length) return null; - const last = months[months.length - 1]; - const prev = months.length > 1 ? months[months.length - 2] : null; - const mom = prev && prev.total > 0 ? ((last.total - prev.total) / prev.total) * 100 : null; - const avg = months.reduce((s, m) => s + m.total, 0) / months.length; - const peak = months.reduce((best, m) => m.total > best.total ? m : best, months[0]); - return { last, prev, mom, avg, peak }; - }, [months]); - - const toggleSort = (key: typeof sort.key) => { + 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: typeof sort.key }) => + const SortIcon = ({ col }: { col: string }) => sort.key === col ? sort.dir === 'asc' ? : : ; @@ -235,222 +431,324 @@ export const CostTimeline: React.FC = () => {
); - const selectedData = selectedMonth ? months.find(m => m.month === selectedMonth) : null; - const hasDailyData = dailyData.length > 1; + 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 (
- {/* Summary cards */} + {/* ── 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 && ( -
+
} + label={selectedMonth ? `${selectedData?.label ?? ''}` : `Period Total (${periodMonths.length}mo)`} + value={fmt(displayTotal, currency)} + sub={selectedMonth ? `${fmt(periodTotal, currency)} for full period` : undefined} + color="var(--text-primary)" icon={} /> } + /> + = 0 ? '+' : ''}${stats.mom.toFixed(1)}%` : '—'} - sub={stats.prev ? `vs ${stats.prev.label}` : 'No previous month'} + 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 ? : } /> - 1 ? 's' : ''}`} - icon={} - /> } + sub={stats.peak.label} color="#F59E0B" icon={} />
)} - {/* Monthly stacked bar chart */} + {/* ── Chart (month = stacked bar / day = area) ─────────────────── */}
-
Monthly Cost Breakdown
-
Click a bar to drill into that month
-
- {/* Legend */} -
- {CATEGORIES.map(cat => ( -
- - {cat} -
- ))} +
+ {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} +
+ ))} +
+ )}
- - { - if (e?.activeLabel) { - const m = months.find(x => x.label === e.activeLabel); - if (m) setSelectedMonth(selectedMonth === m.month ? null : m.month); - } - }}> - - - fmtShort(v, currency)} tick={axisStyle} width={65} /> - [fmt(v, currency), name]} - /> - {CATEGORIES.map(cat => ( - - {months.map((m, i) => ( - - ))} - - ))} - - -
- {/* Selected month drill-down */} - {selectedMonth && selectedData && ( -
+ {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']} /> + + + + )} +
- {/* Month header */} -
-
- {selectedData.label} - - {fmt(selectedData.total, currency)} · {(recordsByMonth.get(selectedMonth) ?? []).length} records - + {/* ── 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}%)
- -
+ ); + })} +
- {/* Category breakdown pills */} -
- {CATEGORIES.filter(cat => selectedData[cat] > 0).map(cat => ( -
- - {cat}: {fmt(selectedData[cat], currency)} - ({((selectedData[cat] / selectedData.total) * 100).toFixed(0)}%) -
- ))} + {/* ── Drill-down header (month mode only) ─────────────────────── */} + {granularity === 'month' && selectedMonth && selectedData && ( +
+
+ {selectedData.label} + + {fmt(selectedData.total, currency)} · {(recordsByMonth.get(selectedMonth) ?? []).length} records +
+ +
+ )} - {/* Daily chart + top customers side by side */} -
- - {hasDailyData && ( -
-
Daily Costs — {selectedData.label}
- - - - - - - - - - - fmtShort(v, currency)} tick={axisStyle} width={65} /> - [fmt(v, currency), 'Cost']} /> - - - -
- )} - - {/* Top customers */} - {topCustomers.length > 0 && ( -
-
Top Customers
-
- {topCustomers.map((c, i) => { - const pct = selectedData.total > 0 ? (c.value / selectedData.total) * 100 : 0; - return ( -
-
- {c.name} - {fmt(c.value, currency)} -
-
-
-
-
- ); - })} -
-
- )} + {/* ── 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']} /> + + +
- - {/* Records table */} -
-
- 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' : ''} - + )} + {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)} +
+
+
+
+
+ ); + })}
+
+ )} +
- {/* Table header */} -
- {([ - ['CustomerName', 'Customer'], - ['ProductName', 'Product'], - ['ChargeType', 'Type'], - ['ChargeStartDate', 'Date'], - ['value', 'Amount'], - ] as const).map(([key, label]) => ( -
toggleSort(key as any)} - style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.25rem', userSelect: 'none', - justifyContent: key === 'value' || key === 'ChargeType' || key === 'ChargeStartDate' ? 'flex-end' : 'flex-start' }}> - {label} -
- ))} + {/* ── 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}
+ ))} +
- {/* Table body — capped at 200 rows for performance */} -
- {tableRows.slice(0, 200).map((row, i) => ( -
+ {/* 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.ChargeType ?? '—'}
+
{row.ChargeStartDate ? new Date(row.ChargeStartDate).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) : '—'}
{fmt(rowValue(row), currency)}
- ))} - {tableRows.length > 200 && ( -
- Showing first 200 of {tableRows.length} records — use the filter to narrow down -
- )} + {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. +
+ )}
- )} +
); };