feat(dashboard): add data labels toggle, dual-axis capture rate chart, mobile bottom nav
- Global data labels toggle in header (works on Dashboard & Comparison pages) - Labels show formatted values (K/M suffix, max 2 decimals) with white pill background - Capture Rate chart now shows pilgrims as curved line on right Y-axis - Revenue Trends toggle moved to top-right corner of chart container - Mobile: bottom navigation bar with Dashboard, Compare, Labels toggle - Mobile: top nav simplified to brand only, bottom nav is thumb-friendly
This commit is contained in:
1176
src/App.css
Normal file
1176
src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
137
src/App.js
Normal file
137
src/App.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Comparison from './components/Comparison';
|
||||
import { fetchSheetData } from './services/dataService';
|
||||
import './App.css';
|
||||
|
||||
function NavLink({ to, children }) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link to={to} className={`nav-link ${isActive ? 'active' : ''}`}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showDataLabels, setShowDataLabels] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await fetchSheetData();
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h2>Unable to load data</h2>
|
||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
|
||||
<button onClick={() => window.location.reload()}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<nav className="nav-bar">
|
||||
<div className="nav-brand">Hi<span>Hala</span> Museums</div>
|
||||
<div className="nav-links">
|
||||
<NavLink to="/">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/comparison">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
<polyline points="18 14 22 10 18 6"/>
|
||||
<polyline points="6 10 2 14 6 18"/>
|
||||
</svg>
|
||||
Comparison
|
||||
</NavLink>
|
||||
<button
|
||||
className={`nav-label-toggle ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
title="Show values on charts"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
{showDataLabels ? 'Labels On' : 'Labels Off'}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} />} />
|
||||
</Routes>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<nav className="mobile-nav">
|
||||
<NavLink to="/" className="mobile-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</NavLink>
|
||||
<NavLink to="/comparison" className="mobile-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>Compare</span>
|
||||
</NavLink>
|
||||
<button
|
||||
className={`mobile-nav-item ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
<span>Labels</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
712
src/components/Comparison.js
Normal file
712
src/components/Comparison.js
Normal file
@@ -0,0 +1,712 @@
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Line, Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import {
|
||||
filterDataByDateRange,
|
||||
calculateMetrics,
|
||||
formatCurrency,
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
umrahData,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict,
|
||||
getLatestYear
|
||||
} from '../services/dataService';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale, LinearScale, PointElement, LineElement,
|
||||
BarElement, Title, Tooltip, Legend, Filler, ChartDataLabels
|
||||
);
|
||||
|
||||
const chartColors = {
|
||||
primary: '#2563eb',
|
||||
muted: '#94a3b8',
|
||||
grid: '#f1f5f9'
|
||||
};
|
||||
|
||||
// Generate preset dates for a given year
|
||||
const generatePresetDates = (year) => ({
|
||||
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
|
||||
'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
|
||||
'mar': { start: `${year}-03-01`, end: `${year}-03-31` },
|
||||
'apr': { start: `${year}-04-01`, end: `${year}-04-30` },
|
||||
'may': { start: `${year}-05-01`, end: `${year}-05-31` },
|
||||
'jun': { start: `${year}-06-01`, end: `${year}-06-30` },
|
||||
'jul': { start: `${year}-07-01`, end: `${year}-07-31` },
|
||||
'aug': { start: `${year}-08-01`, end: `${year}-08-31` },
|
||||
'sep': { start: `${year}-09-01`, end: `${year}-09-30` },
|
||||
'oct': { start: `${year}-10-01`, end: `${year}-10-31` },
|
||||
'nov': { start: `${year}-11-01`, end: `${year}-11-30` },
|
||||
'dec': { start: `${year}-12-01`, end: `${year}-12-31` },
|
||||
'q1': { start: `${year}-01-01`, end: `${year}-03-31` },
|
||||
'q2': { start: `${year}-04-01`, end: `${year}-06-30` },
|
||||
'q3': { start: `${year}-07-01`, end: `${year}-09-30` },
|
||||
'q4': { start: `${year}-10-01`, end: `${year}-12-31` },
|
||||
'h1': { start: `${year}-01-01`, end: `${year}-06-30` },
|
||||
'h2': { start: `${year}-07-01`, end: `${year}-12-31` },
|
||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||
});
|
||||
|
||||
function Comparison({ data, showDataLabels }) {
|
||||
// Get latest year from data for default presets
|
||||
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
||||
|
||||
const [preset, setPreset] = useState('jan');
|
||||
const [startDate, setStartDate] = useState(`${latestYear}-01-01`);
|
||||
const [endDate, setEndDate] = useState(`${latestYear}-01-31`);
|
||||
const [filters, setFilters] = useState({ district: 'all', museum: 'all' });
|
||||
const [chartMetric, setChartMetric] = useState('revenue');
|
||||
const [chartGranularity, setChartGranularity] = useState('week');
|
||||
const [controlsExpanded, setControlsExpanded] = useState(true);
|
||||
const [activeChart, setActiveChart] = useState(0);
|
||||
const [activeCard, setActiveCard] = useState(0);
|
||||
|
||||
const charts = [
|
||||
{ id: 'timeseries', label: 'Trend' },
|
||||
{ id: 'museum', label: 'By Museum' }
|
||||
];
|
||||
|
||||
// Touch swipe handlers
|
||||
const touchStartChart = useRef(null);
|
||||
const touchStartCard = useRef(null);
|
||||
|
||||
const handleChartTouchStart = (e) => {
|
||||
touchStartChart.current = e.touches[0].clientX;
|
||||
};
|
||||
const handleChartTouchEnd = (e) => {
|
||||
if (!touchStartChart.current) return;
|
||||
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeChart < charts.length - 1) {
|
||||
setActiveChart(activeChart + 1);
|
||||
} else if (diff < 0 && activeChart > 0) {
|
||||
setActiveChart(activeChart - 1);
|
||||
}
|
||||
}
|
||||
touchStartChart.current = null;
|
||||
};
|
||||
|
||||
const granularityOptions = [
|
||||
{ value: 'day', label: 'Daily' },
|
||||
{ value: 'week', label: 'Weekly' }
|
||||
];
|
||||
|
||||
const metricOptions = [
|
||||
{ value: 'revenue', label: 'Revenue', field: 'revenue_incl_tax', format: 'currency' },
|
||||
{ value: 'visitors', label: 'Visitors', field: 'visits', format: 'number' },
|
||||
{ value: 'tickets', label: 'Tickets', field: 'tickets', format: 'number' },
|
||||
{ value: 'avgRevenue', label: 'Avg Rev/Visitor', field: null, format: 'currency' }
|
||||
];
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
if (metric === 'avgRevenue') {
|
||||
const revenue = rows.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
|
||||
const visitors = rows.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
return visitors > 0 ? revenue / visitors : 0;
|
||||
}
|
||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[metric];
|
||||
return rows.reduce((s, r) => s + parseFloat(r[field] || 0), 0);
|
||||
}, []);
|
||||
|
||||
// Dynamic lists from data
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
||||
|
||||
// Generate presets based on latest year
|
||||
const presetDates = useMemo(() => generatePresetDates(latestYear), [latestYear]);
|
||||
|
||||
const handlePresetChange = (newPreset) => {
|
||||
setPreset(newPreset);
|
||||
if (newPreset !== 'custom' && presetDates[newPreset]) {
|
||||
setStartDate(presetDates[newPreset].start);
|
||||
setEndDate(presetDates[newPreset].end);
|
||||
}
|
||||
};
|
||||
|
||||
// Year-over-year comparison: same dates, previous year
|
||||
const ranges = useMemo(() => ({
|
||||
curr: { start: startDate, end: endDate },
|
||||
prev: {
|
||||
start: startDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1),
|
||||
end: endDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1)
|
||||
}
|
||||
}), [startDate, endDate]);
|
||||
|
||||
const prevData = useMemo(() =>
|
||||
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
||||
[data, ranges.prev, filters]
|
||||
);
|
||||
|
||||
const currData = useMemo(() =>
|
||||
filterDataByDateRange(data, ranges.curr.start, ranges.curr.end, filters),
|
||||
[data, ranges.curr, filters]
|
||||
);
|
||||
|
||||
const prevMetrics = useMemo(() => calculateMetrics(prevData), [prevData]);
|
||||
const currMetrics = useMemo(() => calculateMetrics(currData), [currData]);
|
||||
|
||||
const calcChange = (prev, curr) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
|
||||
// Get quarter from date range (returns null if not a clean quarter)
|
||||
const getQuarterFromRange = (start, end) => {
|
||||
const quarterRanges = {
|
||||
1: { start: '-01-01', end: '-03-31' },
|
||||
2: { start: '-04-01', end: '-06-30' },
|
||||
3: { start: '-07-01', end: '-09-30' },
|
||||
4: { start: '-10-01', end: '-12-31' }
|
||||
};
|
||||
for (let q = 1; q <= 4; q++) {
|
||||
if (start.endsWith(quarterRanges[q].start) && end.endsWith(quarterRanges[q].end)) {
|
||||
return q;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Calculate capture rate and pilgrim data for quarters
|
||||
const quarterData = useMemo(() => {
|
||||
const prevYear = parseInt(ranges.prev.start.substring(0, 4));
|
||||
const currYear = parseInt(ranges.curr.start.substring(0, 4));
|
||||
const prevQ = getQuarterFromRange(ranges.prev.start, ranges.prev.end);
|
||||
const currQ = getQuarterFromRange(ranges.curr.start, ranges.curr.end);
|
||||
|
||||
if (!prevQ || !currQ) return null; // Only show for quarter comparisons
|
||||
|
||||
const prevPilgrims = umrahData[prevYear]?.[prevQ];
|
||||
const currPilgrims = umrahData[currYear]?.[currQ];
|
||||
|
||||
if (!prevPilgrims && !currPilgrims) return null;
|
||||
|
||||
const prevRate = prevPilgrims ? (prevMetrics.visitors / prevPilgrims * 100) : null;
|
||||
const currRate = currPilgrims ? (currMetrics.visitors / currPilgrims * 100) : null;
|
||||
|
||||
return {
|
||||
pilgrims: { prev: prevPilgrims, curr: currPilgrims },
|
||||
captureRate: { prev: prevRate, curr: currRate }
|
||||
};
|
||||
}, [ranges, prevMetrics.visitors, currMetrics.visitors]);
|
||||
|
||||
const captureRates = quarterData?.captureRate || null;
|
||||
const pilgrimCounts = quarterData?.pilgrims || null;
|
||||
|
||||
const changes = {
|
||||
revenue: calcChange(prevMetrics.revenue, currMetrics.revenue),
|
||||
visitors: calcChange(prevMetrics.visitors, currMetrics.visitors),
|
||||
tickets: calcChange(prevMetrics.tickets, currMetrics.tickets),
|
||||
avgRev: calcChange(prevMetrics.avgRevPerVisitor, currMetrics.avgRevPerVisitor),
|
||||
pilgrims: pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null,
|
||||
captureRate: captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null
|
||||
};
|
||||
|
||||
// Build cards array dynamically
|
||||
const metricCards = useMemo(() => {
|
||||
const cards = [
|
||||
{ title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: changes.revenue, isCurrency: true },
|
||||
{ title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: changes.visitors },
|
||||
{ title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: changes.tickets },
|
||||
{ title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: changes.avgRev, isCurrency: true }
|
||||
];
|
||||
if (pilgrimCounts) {
|
||||
cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: changes.pilgrims, pendingMessage: 'Data not published yet' });
|
||||
}
|
||||
if (captureRates) {
|
||||
cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: changes.captureRate, isPercent: true, pendingMessage: 'Data not published yet' });
|
||||
}
|
||||
return cards;
|
||||
}, [prevMetrics, currMetrics, changes, pilgrimCounts, captureRates]);
|
||||
|
||||
const handleCardTouchStart = (e) => {
|
||||
touchStartCard.current = e.touches[0].clientX;
|
||||
};
|
||||
const handleCardTouchEnd = (e) => {
|
||||
if (!touchStartCard.current) return;
|
||||
const diff = touchStartCard.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeCard < metricCards.length - 1) {
|
||||
setActiveCard(activeCard + 1);
|
||||
} else if (diff < 0 && activeCard > 0) {
|
||||
setActiveCard(activeCard - 1);
|
||||
}
|
||||
}
|
||||
touchStartCard.current = null;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const d = new Date(year, month - 1, day);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Time series chart (daily or weekly)
|
||||
const timeSeriesChart = useMemo(() => {
|
||||
const groupByPeriod = (periodData, periodStart, metric, granularity) => {
|
||||
const start = new Date(periodStart);
|
||||
const groupedRows = {};
|
||||
|
||||
periodData.forEach(row => {
|
||||
if (!row.date) return;
|
||||
const rowDate = new Date(row.date);
|
||||
const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let key;
|
||||
if (granularity === 'week') {
|
||||
key = Math.floor(daysDiff / 7) + 1;
|
||||
} else {
|
||||
key = daysDiff + 1; // day number from start
|
||||
}
|
||||
|
||||
if (!groupedRows[key]) groupedRows[key] = [];
|
||||
groupedRows[key].push(row);
|
||||
});
|
||||
|
||||
const result = {};
|
||||
Object.keys(groupedRows).forEach(key => {
|
||||
result[key] = getMetricValue(groupedRows[key], metric);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const prevGrouped = groupByPeriod(prevData, ranges.prev.start, chartMetric, chartGranularity);
|
||||
const currGrouped = groupByPeriod(currData, ranges.curr.start, chartMetric, chartGranularity);
|
||||
const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1);
|
||||
|
||||
const labels = Array.from({ length: maxKey }, (_, i) =>
|
||||
chartGranularity === 'week' ? `W${i + 1}` : `D${i + 1}`
|
||||
);
|
||||
|
||||
const prevYear = ranges.prev.start.substring(0, 4);
|
||||
const currYear = ranges.curr.start.substring(0, 4);
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: prevYear,
|
||||
data: labels.map((_, i) => prevGrouped[i + 1] || 0),
|
||||
borderColor: chartColors.muted,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointRadius: chartGranularity === 'week' ? 3 : 1,
|
||||
pointBackgroundColor: chartColors.muted
|
||||
},
|
||||
{
|
||||
label: currYear,
|
||||
data: labels.map((_, i) => currGrouped[i + 1] || 0),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: chartGranularity === 'week' ? 4 : 2,
|
||||
pointBackgroundColor: chartColors.primary
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [prevData, currData, ranges, chartMetric, chartGranularity, getMetricValue]);
|
||||
|
||||
// Museum chart - only show museums with data
|
||||
const museumChart = useMemo(() => {
|
||||
const prevYear = ranges.prev.start.substring(0, 4);
|
||||
const currYear = ranges.curr.start.substring(0, 4);
|
||||
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean);
|
||||
const prevByMuseum = {};
|
||||
const currByMuseum = {};
|
||||
allMuseums.forEach(m => {
|
||||
const prevRows = prevData.filter(r => r.museum_name === m);
|
||||
const currRows = currData.filter(r => r.museum_name === m);
|
||||
prevByMuseum[m] = getMetricValue(prevRows, chartMetric);
|
||||
currByMuseum[m] = getMetricValue(currRows, chartMetric);
|
||||
});
|
||||
// Only include museums that have data in either period
|
||||
const museums = allMuseums.filter(m => prevByMuseum[m] > 0 || currByMuseum[m] > 0);
|
||||
return {
|
||||
labels: museums,
|
||||
datasets: [
|
||||
{ label: prevYear, data: museums.map(m => prevByMuseum[m]), backgroundColor: chartColors.muted, borderRadius: 4 },
|
||||
{ label: currYear, data: museums.map(m => currByMuseum[m]), backgroundColor: chartColors.primary, borderRadius: 4 }
|
||||
]
|
||||
};
|
||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue]);
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } },
|
||||
tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8 },
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
color: '#1e293b',
|
||||
font: { size: 10, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'end',
|
||||
offset: 4,
|
||||
padding: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: 3,
|
||||
formatter: (value) => {
|
||||
if (value == null) return '';
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
|
||||
if (value < 100 && value > 0) return value.toFixed(2);
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
|
||||
y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="comparison">
|
||||
<div className="page-title">
|
||||
<h1>Period Comparison</h1>
|
||||
<p>Year-over-year analysis — same period, different years</p>
|
||||
</div>
|
||||
|
||||
<div className={`controls ${controlsExpanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div className="controls-header" onClick={() => setControlsExpanded(!controlsExpanded)}>
|
||||
<h3>Select Period</h3>
|
||||
<button className="controls-toggle">
|
||||
{controlsExpanded ? '▲ Hide' : '▼ Show'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="controls-body">
|
||||
<div className="control-row">
|
||||
<div className="control-group">
|
||||
<label>Preset</label>
|
||||
<select value={preset} onChange={e => handlePresetChange(e.target.value)}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="jan">January</option>
|
||||
<option value="feb">February</option>
|
||||
<option value="mar">March</option>
|
||||
<option value="apr">April</option>
|
||||
<option value="may">May</option>
|
||||
<option value="jun">June</option>
|
||||
<option value="jul">July</option>
|
||||
<option value="aug">August</option>
|
||||
<option value="sep">September</option>
|
||||
<option value="oct">October</option>
|
||||
<option value="nov">November</option>
|
||||
<option value="dec">December</option>
|
||||
<option value="q1">Q1</option>
|
||||
<option value="q2">Q2</option>
|
||||
<option value="q3">Q3</option>
|
||||
<option value="q4">Q4</option>
|
||||
<option value="h1">H1</option>
|
||||
<option value="h2">H2</option>
|
||||
<option value="full">Full Year</option>
|
||||
</select>
|
||||
</div>
|
||||
{preset === 'custom' && (
|
||||
<>
|
||||
<div className="control-group">
|
||||
<label>From</label>
|
||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>To</label>
|
||||
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="control-group">
|
||||
<label>District</label>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">All Districts</option>
|
||||
{districts.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>Museum</label>
|
||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
||||
<option value="all">All Museums</option>
|
||||
{availableMuseums.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="period-display">
|
||||
<div className="period-box">
|
||||
<div className="label">{ranges.prev.start.substring(0, 4)}</div>
|
||||
<div className="dates">{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}</div>
|
||||
</div>
|
||||
<div className="period-box">
|
||||
<div className="label">{ranges.curr.start.substring(0, 4)}</div>
|
||||
<div className="dates">{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Grid layout */}
|
||||
<div className="comparison-grid desktop-only">
|
||||
{metricCards.map((card, i) => (
|
||||
<MetricCard
|
||||
key={i}
|
||||
title={card.title}
|
||||
prev={card.prev}
|
||||
curr={card.curr}
|
||||
change={card.change}
|
||||
isCurrency={card.isCurrency}
|
||||
isPercent={card.isPercent}
|
||||
pendingMessage={card.pendingMessage}
|
||||
prevYear={ranges.prev.start.substring(0, 4)}
|
||||
currYear={ranges.curr.start.substring(0, 4)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Carousel layout */}
|
||||
<div className="cards-carousel mobile-only">
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeCard * 100}%)` }}
|
||||
onTouchStart={handleCardTouchStart}
|
||||
onTouchEnd={handleCardTouchEnd}
|
||||
>
|
||||
{metricCards.map((card, i) => (
|
||||
<div className="carousel-slide" key={i}>
|
||||
<MetricCard
|
||||
title={card.title}
|
||||
prev={card.prev}
|
||||
curr={card.curr}
|
||||
change={card.change}
|
||||
isCurrency={card.isCurrency}
|
||||
isPercent={card.isPercent}
|
||||
pendingMessage={card.pendingMessage}
|
||||
prevYear={ranges.prev.start.substring(0, 4)}
|
||||
currYear={ranges.curr.start.substring(0, 4)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-dots labeled">
|
||||
{metricCards.map((card, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`carousel-dot ${activeCard === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveCard(i)}
|
||||
>
|
||||
<span className="dot-label">{card.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Show both charts */}
|
||||
<div className="charts-grid desktop-only">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
||||
<div className="chart-selectors">
|
||||
<div className="toggle-switch">
|
||||
{granularityOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartGranularity === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartGranularity(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={timeSeriesChart} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
|
||||
<div className="chart-selectors">
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumChart} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Carousel */}
|
||||
<div className="charts-carousel mobile-only">
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeChart * 100}%)` }}
|
||||
onTouchStart={handleChartTouchStart}
|
||||
onTouchEnd={handleChartTouchEnd}
|
||||
>
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
||||
<div className="toggle-switch">
|
||||
{granularityOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartGranularity === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartGranularity(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-selectors-inline">
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={timeSeriesChart} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-section">
|
||||
<div className="chart-header">
|
||||
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
|
||||
</div>
|
||||
<div className="chart-selectors-inline">
|
||||
<div className="chart-metric-selector">
|
||||
{metricOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={chartMetric === opt.value ? 'active' : ''}
|
||||
onClick={() => setChartMetric(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumChart} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-dots labeled">
|
||||
{charts.map((chart, i) => (
|
||||
<button
|
||||
key={chart.id}
|
||||
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveChart(i)}
|
||||
>
|
||||
<span className="dot-label">{chart.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }) {
|
||||
const hasPending = prev === null || curr === null;
|
||||
const isPositive = change >= 0;
|
||||
const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`);
|
||||
|
||||
const formatValue = (val) => {
|
||||
if (val === null || val === undefined) return '—';
|
||||
if (isPercent) return val.toFixed(2) + '%';
|
||||
if (isCurrency) return formatCompactCurrency(val);
|
||||
return formatCompact(val);
|
||||
};
|
||||
|
||||
const diff = (curr || 0) - (prev || 0);
|
||||
const diffText = (hasPending && pendingMessage) ? pendingMessage : (isPercent
|
||||
? (diff >= 0 ? '+' : '') + diff.toFixed(2) + 'pp'
|
||||
: (isCurrency ? formatCompactCurrency(diff) : formatCompact(diff)));
|
||||
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<h4>{title}</h4>
|
||||
<div className="metric-values">
|
||||
<div className="metric-period previous">
|
||||
<div className="year">{prevYear}</div>
|
||||
<div className="value">{formatValue(prev)}</div>
|
||||
</div>
|
||||
<div className={`metric-change ${hasPending && pendingMessage ? 'pending' : (isPositive ? 'positive' : 'negative')}`}>
|
||||
{hasPending && pendingMessage ? (
|
||||
<div className="pending-msg">{pendingMessage}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pct">{changeText}</div>
|
||||
<div className="abs">{diff >= 0 ? '+' : ''}{diffText}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="metric-period current">
|
||||
<div className="year">{currYear}</div>
|
||||
<div className="value">{formatValue(curr)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Comparison;
|
||||
740
src/components/Dashboard.js
Normal file
740
src/components/Dashboard.js
Normal file
@@ -0,0 +1,740 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import {
|
||||
filterData,
|
||||
calculateMetrics,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
groupByWeek,
|
||||
groupByMuseum,
|
||||
groupByDistrict,
|
||||
umrahData,
|
||||
getUniqueYears,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict
|
||||
} from '../services/dataService';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale, LinearScale, PointElement, LineElement,
|
||||
BarElement, ArcElement, Title, Tooltip, Legend, Filler,
|
||||
ChartDataLabels
|
||||
);
|
||||
|
||||
const chartColors = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#7c3aed',
|
||||
tertiary: '#0891b2',
|
||||
muted: '#cbd5e1',
|
||||
grid: '#f1f5f9'
|
||||
};
|
||||
|
||||
function Dashboard({ data, showDataLabels }) {
|
||||
const [filters, setFilters] = useState({
|
||||
year: 'all',
|
||||
district: 'all',
|
||||
museum: 'all',
|
||||
quarter: 'all'
|
||||
});
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(true);
|
||||
const [activeStatCard, setActiveStatCard] = useState(0);
|
||||
const [activeChart, setActiveChart] = useState(0);
|
||||
const [trendGranularity, setTrendGranularity] = useState('week');
|
||||
|
||||
// Touch handlers for carousels
|
||||
const touchStartStat = useRef(null);
|
||||
const touchStartChart = useRef(null);
|
||||
|
||||
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
||||
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
|
||||
// Stat cards for carousel
|
||||
const statCards = useMemo(() => [
|
||||
{ title: 'Total Revenue', value: formatCurrency(metrics.revenue), hasYoy: true },
|
||||
{ title: 'Total Visitors', value: formatNumber(metrics.visitors) },
|
||||
{ title: 'Total Tickets', value: formatNumber(metrics.tickets) },
|
||||
{ title: 'Avg Rev/Visitor', value: formatCurrency(metrics.avgRevPerVisitor) }
|
||||
], [metrics]);
|
||||
|
||||
const handleStatTouchStart = (e) => { touchStartStat.current = e.touches[0].clientX; };
|
||||
const handleStatTouchEnd = (e) => {
|
||||
if (!touchStartStat.current) return;
|
||||
const diff = touchStartStat.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeStatCard < statCards.length - 1) setActiveStatCard(activeStatCard + 1);
|
||||
else if (diff < 0 && activeStatCard > 0) setActiveStatCard(activeStatCard - 1);
|
||||
}
|
||||
touchStartStat.current = null;
|
||||
};
|
||||
|
||||
// Chart carousel - define charts array
|
||||
const dashboardCharts = useMemo(() => [
|
||||
{ id: 'revenue-trend', label: 'Revenue Trend' },
|
||||
{ id: 'visitors-museum', label: 'Visitors' },
|
||||
{ id: 'revenue-museum', label: 'Revenue' },
|
||||
{ id: 'quarterly-yoy', label: 'Quarterly' },
|
||||
{ id: 'district', label: 'District' },
|
||||
{ id: 'capture-rate', label: 'Capture Rate' }
|
||||
], []);
|
||||
|
||||
const handleChartTouchStart = (e) => { touchStartChart.current = e.touches[0].clientX; };
|
||||
const handleChartTouchEnd = (e) => {
|
||||
if (!touchStartChart.current) return;
|
||||
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
||||
const maxCharts = filters.museum === 'all' ? dashboardCharts.length : dashboardCharts.length - 2;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeChart < maxCharts - 1) setActiveChart(activeChart + 1);
|
||||
else if (diff < 0 && activeChart > 0) setActiveChart(activeChart - 1);
|
||||
}
|
||||
touchStartChart.current = null;
|
||||
};
|
||||
|
||||
// Dynamic lists from data
|
||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
||||
|
||||
const yoyChange = useMemo(() => {
|
||||
if (filters.year === 'all') return null;
|
||||
const prevYear = String(parseInt(filters.year) - 1);
|
||||
const prevData = data.filter(row => row.year === prevYear);
|
||||
if (prevData.length === 0) return null;
|
||||
const prevMetrics = calculateMetrics(prevData);
|
||||
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
||||
}, [data, filters.year, metrics.revenue]);
|
||||
|
||||
// Revenue trend data (weekly or daily)
|
||||
const trendData = useMemo(() => {
|
||||
const formatLabel = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const d = new Date(year, month - 1, day);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
if (trendGranularity === 'week') {
|
||||
const grouped = groupByWeek(filteredData);
|
||||
const weeks = Object.keys(grouped).filter(w => w).sort();
|
||||
return {
|
||||
labels: weeks.map(formatLabel),
|
||||
datasets: [{
|
||||
label: 'Revenue',
|
||||
data: weeks.map(w => grouped[w].revenue),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
}]
|
||||
};
|
||||
} else {
|
||||
// Daily granularity
|
||||
const dailyData = {};
|
||||
filteredData.forEach(row => {
|
||||
const date = row.date;
|
||||
if (!dailyData[date]) dailyData[date] = 0;
|
||||
dailyData[date] += parseFloat(row.revenue_incl_tax || 0);
|
||||
});
|
||||
const days = Object.keys(dailyData).sort();
|
||||
return {
|
||||
labels: days.map(formatLabel),
|
||||
datasets: [{
|
||||
label: 'Revenue',
|
||||
data: days.map(d => dailyData[d]),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
borderWidth: 1.5,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3
|
||||
}]
|
||||
};
|
||||
}
|
||||
}, [filteredData, trendGranularity]);
|
||||
|
||||
// Museum data
|
||||
const museumData = useMemo(() => {
|
||||
const grouped = groupByMuseum(filteredData);
|
||||
const museums = Object.keys(grouped);
|
||||
return {
|
||||
visitors: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
data: museums.map(m => grouped[m].visitors),
|
||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
revenue: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
data: museums.map(m => grouped[m].revenue),
|
||||
backgroundColor: [chartColors.primary + 'cc', chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
borderRadius: 4
|
||||
}]
|
||||
}
|
||||
};
|
||||
}, [filteredData]);
|
||||
|
||||
// District data
|
||||
const districtData = useMemo(() => {
|
||||
const grouped = groupByDistrict(filteredData);
|
||||
const districts = Object.keys(grouped);
|
||||
return {
|
||||
labels: districts,
|
||||
datasets: [{
|
||||
data: districts.map(d => grouped[d].revenue),
|
||||
backgroundColor: [chartColors.secondary + 'cc', chartColors.tertiary + 'cc'],
|
||||
borderRadius: 4
|
||||
}]
|
||||
};
|
||||
}, [filteredData]);
|
||||
|
||||
// Quarterly YoY
|
||||
const quarterlyYoYData = useMemo(() => {
|
||||
const d2024 = data.filter(row => row.year === '2024');
|
||||
const d2025 = data.filter(row => row.year === '2025');
|
||||
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
return {
|
||||
labels: quarters,
|
||||
datasets: [
|
||||
{
|
||||
label: '2024',
|
||||
data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0)),
|
||||
backgroundColor: chartColors.muted,
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: '2025',
|
||||
data: quarters.map(q => d2025.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0)),
|
||||
backgroundColor: chartColors.primary,
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// Capture rate
|
||||
const captureRateData = useMemo(() => {
|
||||
const labels = [];
|
||||
const rates = [];
|
||||
const pilgrimCounts = [];
|
||||
[2024, 2025].forEach(year => {
|
||||
[1, 2, 3, 4].forEach(q => {
|
||||
const pilgrims = umrahData[year]?.[q];
|
||||
if (!pilgrims) return;
|
||||
let qData = data.filter(r => r.year === String(year) && r.quarter === String(q));
|
||||
if (filters.district !== 'all') qData = qData.filter(r => r.district === filters.district);
|
||||
if (filters.museum !== 'all') qData = qData.filter(r => r.museum_name === filters.museum);
|
||||
const visitors = qData.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
labels.push(`Q${q} ${year}`);
|
||||
rates.push((visitors / pilgrims * 100));
|
||||
pilgrimCounts.push(pilgrims);
|
||||
});
|
||||
});
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Capture Rate (%)',
|
||||
data: rates,
|
||||
borderColor: chartColors.secondary,
|
||||
backgroundColor: chartColors.secondary + '10',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#fff',
|
||||
pointBorderColor: chartColors.secondary,
|
||||
pointBorderWidth: 2,
|
||||
yAxisID: 'y',
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
formatter: (value) => value.toFixed(2) + '%',
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
font: { size: 9, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'top',
|
||||
offset: 6
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Pilgrims',
|
||||
data: pilgrimCounts,
|
||||
borderColor: chartColors.tertiary,
|
||||
backgroundColor: chartColors.tertiary + '10',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#fff',
|
||||
pointBorderColor: chartColors.tertiary,
|
||||
pointBorderWidth: 2,
|
||||
yAxisID: 'y1',
|
||||
order: 1,
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
formatter: (value) => (value / 1000000).toFixed(2) + 'M',
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
font: { size: 9, weight: 600 },
|
||||
anchor: 'start',
|
||||
align: 'bottom',
|
||||
offset: 6
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, filters.district, filters.museum]);
|
||||
|
||||
// Quarterly table
|
||||
const quarterlyTable = useMemo(() => {
|
||||
const d2024 = data.filter(row => row.year === '2024');
|
||||
const d2025 = data.filter(row => row.year === '2025');
|
||||
return [1, 2, 3, 4].map(q => {
|
||||
let q2024 = d2024.filter(r => r.quarter === String(q));
|
||||
let q2025 = d2025.filter(r => r.quarter === String(q));
|
||||
if (filters.district !== 'all') {
|
||||
q2024 = q2024.filter(r => r.district === filters.district);
|
||||
q2025 = q2025.filter(r => r.district === filters.district);
|
||||
}
|
||||
if (filters.museum !== 'all') {
|
||||
q2024 = q2024.filter(r => r.museum_name === filters.museum);
|
||||
q2025 = q2025.filter(r => r.museum_name === filters.museum);
|
||||
}
|
||||
const rev24 = q2024.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
|
||||
const rev25 = q2025.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
|
||||
const vis24 = q2024.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
const vis25 = q2025.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0;
|
||||
const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0;
|
||||
const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null;
|
||||
const cap25 = umrahData[2025][q] ? (vis25 / umrahData[2025][q] * 100) : null;
|
||||
return { q, rev24, rev25, revChg, vis24, vis25, visChg, cap24, cap25 };
|
||||
});
|
||||
}, [data, filters.district, filters.museum]);
|
||||
|
||||
const dataLabelDefaults = {
|
||||
display: showDataLabels,
|
||||
color: '#1e293b',
|
||||
font: { size: 10, weight: 600 },
|
||||
anchor: 'end',
|
||||
align: 'end',
|
||||
offset: 4,
|
||||
padding: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: 3,
|
||||
formatter: (value) => {
|
||||
if (value == null) return '';
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
|
||||
if (value < 100 && value > 0) return value.toFixed(2);
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
};
|
||||
|
||||
const baseOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8, titleFont: { size: 12 }, bodyFont: { size: 11 } },
|
||||
datalabels: dataLabelDefaults
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
|
||||
y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="page-title">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Real-time museum analytics from Google Sheets</p>
|
||||
</div>
|
||||
|
||||
<div className={`controls ${filtersExpanded ? 'expanded' : 'collapsed'}`}>
|
||||
<div className="controls-header" onClick={() => setFiltersExpanded(!filtersExpanded)}>
|
||||
<h3>Filters</h3>
|
||||
<button className="controls-toggle">{filtersExpanded ? '▲ Hide' : '▼ Show'}</button>
|
||||
</div>
|
||||
<div className="controls-body">
|
||||
<div className="control-row">
|
||||
<div className="control-group">
|
||||
<label>Year</label>
|
||||
<select value={filters.year} onChange={e => setFilters({...filters, year: e.target.value})}>
|
||||
<option value="all">All Years</option>
|
||||
{years.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>District</label>
|
||||
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
||||
<option value="all">All Districts</option>
|
||||
{districts.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>Museum</label>
|
||||
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
||||
<option value="all">All Museums</option>
|
||||
{availableMuseums.map(m => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>Quarter</label>
|
||||
<select value={filters.quarter} onChange={e => setFilters({...filters, quarter: e.target.value})}>
|
||||
<option value="all">All Quarters</option>
|
||||
<option value="1">Q1</option>
|
||||
<option value="2">Q2</option>
|
||||
<option value="3">Q3</option>
|
||||
<option value="4">Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Grid */}
|
||||
<div className="stats-grid desktop-only">
|
||||
<div className="stat-card">
|
||||
<h3>Total Revenue</h3>
|
||||
<div className="stat-value">{formatCurrency(metrics.revenue)}</div>
|
||||
{yoyChange !== null && (
|
||||
<div className={`stat-change ${yoyChange >= 0 ? 'positive' : 'negative'}`}>
|
||||
{yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>Total Visitors</h3>
|
||||
<div className="stat-value">{formatNumber(metrics.visitors)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>Total Tickets</h3>
|
||||
<div className="stat-value">{formatNumber(metrics.tickets)}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>Avg Revenue/Visitor</h3>
|
||||
<div className="stat-value">{formatCurrency(metrics.avgRevPerVisitor)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Stats Carousel */}
|
||||
<div className="stats-carousel mobile-only">
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeStatCard * 100}%)` }}
|
||||
onTouchStart={handleStatTouchStart}
|
||||
onTouchEnd={handleStatTouchEnd}
|
||||
>
|
||||
{statCards.map((card, i) => (
|
||||
<div className="carousel-slide" key={i}>
|
||||
<div className="stat-card">
|
||||
<h3>{card.title}</h3>
|
||||
<div className="stat-value">{card.value}</div>
|
||||
{card.hasYoy && yoyChange !== null && (
|
||||
<div className={`stat-change ${yoyChange >= 0 ? 'positive' : 'negative'}`}>
|
||||
{yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carousel-dots labeled">
|
||||
{statCards.map((card, i) => (
|
||||
<button key={i} className={`carousel-dot ${activeStatCard === i ? 'active' : ''}`} onClick={() => setActiveStatCard(i)}>
|
||||
<span className="dot-label">{card.title.replace('Total ', '').replace('Avg ', '')}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card full-width" style={{marginBottom: '16px'}}>
|
||||
<h2>Quarterly Comparison: 2024 vs 2025</h2>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th>Rev 2024</th>
|
||||
<th>Rev 2025</th>
|
||||
<th>Change</th>
|
||||
<th>Visitors 2024</th>
|
||||
<th>Visitors 2025</th>
|
||||
<th>Change</th>
|
||||
<th>Capture 2024</th>
|
||||
<th>Capture 2025</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quarterlyTable.map(row => (
|
||||
<tr key={row.q}>
|
||||
<td className="bold">Q{row.q}</td>
|
||||
<td className="muted">{formatCurrency(row.rev24)}</td>
|
||||
<td className="bold">{formatCurrency(row.rev25)}</td>
|
||||
<td className={row.revChg >= 0 ? 'positive' : 'negative'}>
|
||||
{row.revChg >= 0 ? '+' : ''}{row.revChg.toFixed(1)}%
|
||||
</td>
|
||||
<td className="muted">{formatNumber(row.vis24)}</td>
|
||||
<td className="bold">{formatNumber(row.vis25)}</td>
|
||||
<td className={row.visChg >= 0 ? 'positive' : 'negative'}>
|
||||
{row.visChg >= 0 ? '+' : ''}{row.visChg.toFixed(1)}%
|
||||
</td>
|
||||
<td className="muted">{row.cap24 ? row.cap24.toFixed(2) + '%' : '—'}</td>
|
||||
<td className="purple bold">{row.cap25 ? row.cap25.toFixed(2) + '%' : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Charts Grid */}
|
||||
<div className="charts-grid desktop-only">
|
||||
<div className="chart-card full-width">
|
||||
<h2>Revenue Trends</h2>
|
||||
<div className="toggle-switch toggle-corner">
|
||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="chart-card half-width">
|
||||
<h2>Visitors by Museum</h2>
|
||||
<div className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 11}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="chart-card half-width">
|
||||
<h2>Revenue by Museum</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumData.revenue} options={baseOptions} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<h2>Quarterly Revenue (YoY)</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 11}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card half-width">
|
||||
<h2>District Performance</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-card full-width">
|
||||
<h2>Capture Rate vs Umrah Pilgrims</h2>
|
||||
<div className="chart-container">
|
||||
<Line data={captureRateData} options={{
|
||||
...baseOptions,
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } },
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||
}
|
||||
return `Pilgrims: ${ctx.parsed.y.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: baseOptions.scales.x,
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
border: { display: false },
|
||||
title: { display: true, text: 'Capture Rate (%)', font: { size: 10 }, color: chartColors.secondary }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 10 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
border: { display: false },
|
||||
title: { display: true, text: 'Pilgrims', font: { size: 10 }, color: chartColors.tertiary }
|
||||
}
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Charts Carousel */}
|
||||
<div className="charts-carousel mobile-only">
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeChart * 100}%)` }}
|
||||
onTouchStart={handleChartTouchStart}
|
||||
onTouchEnd={handleChartTouchEnd}
|
||||
>
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Revenue Trends</h2>
|
||||
<div className="toggle-switch toggle-corner">
|
||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Visitors by Museum</h2>
|
||||
<div className="chart-container">
|
||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 12, font: {size: 10}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.museum === 'all' && (
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Revenue by Museum</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={museumData.revenue} options={baseOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Quarterly Revenue (YoY)</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 8, font: {size: 10}}}}}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>District Performance</h2>
|
||||
<div className="chart-container">
|
||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
<h2>Capture Rate vs Umrah Pilgrims</h2>
|
||||
<div className="chart-container">
|
||||
<Line data={captureRateData} options={{
|
||||
...baseOptions,
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
legend: { display: true, position: 'top', align: 'end', labels: { boxWidth: 10, padding: 8, font: { size: 9 } } },
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||
}
|
||||
return `Pilgrims: ${ctx.parsed.y.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: baseOptions.scales.x,
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 9 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
border: { display: false }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 9 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
border: { display: false }
|
||||
}
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="carousel-dots">
|
||||
{(filters.museum === 'all' ? dashboardCharts : dashboardCharts.filter(c => !['visitors-museum', 'revenue-museum'].includes(c.id))).map((chart, i) => (
|
||||
<button
|
||||
key={chart.id}
|
||||
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveChart(i)}
|
||||
>
|
||||
<span className="dot-label">{chart.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
10
src/index.js
Normal file
10
src/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
324
src/services/dataService.js
Normal file
324
src/services/dataService.js
Normal file
@@ -0,0 +1,324 @@
|
||||
// Google Sheets configuration
|
||||
const SPREADSHEET_ID = '1rdK1e7jmfu-es4Ql0YwDYNBY2OvVihBjYaXTM-MHHqg';
|
||||
const SHEET_NAME = 'Consolidated Data';
|
||||
const SHEET_URL = `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`;
|
||||
|
||||
// NocoDB configuration
|
||||
// Use relative URL for dev proxy, full URL for production
|
||||
const NOCODB_URL = process.env.NODE_ENV === 'production' ? 'http://localhost:8090' : '';
|
||||
const NOCODB_TOKEN = 'By-wCdkUm6N9JdfmNpGH2jd6LqEejwOXER7FMkgr';
|
||||
|
||||
// Old flat table (for backwards compatibility)
|
||||
const NOCODB_TABLE_ID = 'mzcz8ktjybcjc79';
|
||||
|
||||
// New normalized tables (Samaya Museums Statistics base)
|
||||
const NOCODB_TABLES = {
|
||||
districts: 'm8cup7lesbet0sa',
|
||||
museums: 'm1c7od7mdirffvu',
|
||||
dailyStats: 'mc7qhbdh3mjjwl8'
|
||||
};
|
||||
|
||||
export const umrahData = {
|
||||
2024: { 1: 11574494, 2: 10521465, 3: 3364627, 4: 7435625 },
|
||||
2025: { 1: 15222497, 2: 5443393, 3: null, 4: null }
|
||||
};
|
||||
|
||||
// Convert Excel serial date to YYYY-MM-DD
|
||||
function excelDateToYMD(serial) {
|
||||
const num = parseInt(serial);
|
||||
if (isNaN(num) || num < 1) return null;
|
||||
|
||||
// Excel epoch is Dec 30, 1899
|
||||
const utcDays = Math.floor(num - 25569); // 25569 = days from 1899-12-30 to 1970-01-01
|
||||
const date = new Date(utcDays * 86400 * 1000);
|
||||
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function parseCSV(text) {
|
||||
const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const lines = normalizedText.trim().split('\n');
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
|
||||
|
||||
return lines.slice(1).map(line => {
|
||||
const values = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let char of line) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||
|
||||
const obj = {};
|
||||
headers.forEach((header, i) => {
|
||||
let val = values[i] || '';
|
||||
// Convert date serial to YYYY-MM-DD
|
||||
if (header === 'date' && /^\d+$/.test(val)) {
|
||||
val = excelDateToYMD(val);
|
||||
}
|
||||
obj[header] = val;
|
||||
});
|
||||
return obj;
|
||||
}).filter(row => row.date);
|
||||
}
|
||||
|
||||
export async function fetchSheetData() {
|
||||
try {
|
||||
console.log('Fetching from Google Sheets...');
|
||||
const response = await fetch(SHEET_URL);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const text = await response.text();
|
||||
if (text.includes('<!DOCTYPE') || text.includes('<html')) {
|
||||
throw new Error('Sheet is not public');
|
||||
}
|
||||
|
||||
const data = parseCSV(text);
|
||||
console.log(`Loaded ${data.length} rows from Google Sheets`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Fetch error:', err);
|
||||
throw new Error(`Failed to load data: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNocoDBTable(tableId, limit = 1000) {
|
||||
let allRecords = [];
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
|
||||
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const json = await response.json();
|
||||
const records = json.list || [];
|
||||
allRecords = allRecords.concat(records);
|
||||
|
||||
if (records.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
export async function fetchNocoDBData() {
|
||||
try {
|
||||
console.log('Fetching from NocoDB (normalized)...');
|
||||
|
||||
// Fetch all three tables in parallel
|
||||
const [districts, museums, dailyStats] = await Promise.all([
|
||||
fetchNocoDBTable(NOCODB_TABLES.districts),
|
||||
fetchNocoDBTable(NOCODB_TABLES.museums),
|
||||
fetchNocoDBTable(NOCODB_TABLES.dailyStats)
|
||||
]);
|
||||
|
||||
// Build lookup maps
|
||||
const districtMap = {};
|
||||
districts.forEach(d => { districtMap[d.Id] = d.Name; });
|
||||
|
||||
const museumMap = {};
|
||||
museums.forEach(m => {
|
||||
museumMap[m.Id] = {
|
||||
code: m.Code,
|
||||
name: m.Name,
|
||||
district: districtMap[m['nc_epk____Districts_id']] || 'Unknown'
|
||||
};
|
||||
});
|
||||
|
||||
// Join data into flat structure for dashboard
|
||||
const data = dailyStats.map(row => {
|
||||
const museum = museumMap[row['nc_epk____Museums_id']] || {};
|
||||
const date = row.Date;
|
||||
const year = date ? date.substring(0, 4) : '';
|
||||
const month = date ? parseInt(date.substring(5, 7)) : 0;
|
||||
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
|
||||
|
||||
return {
|
||||
date: date,
|
||||
museum_code: museum.code,
|
||||
museum_name: museum.name,
|
||||
district: museum.district,
|
||||
visits: row.Visits,
|
||||
tickets: row.Tickets,
|
||||
revenue_incl_tax: row.Revenue,
|
||||
year: year,
|
||||
quarter: quarter
|
||||
};
|
||||
}).filter(r => r.date && r.museum_name);
|
||||
|
||||
console.log(`Loaded ${data.length} rows from NocoDB (joined from ${districts.length} districts, ${museums.length} museums, ${dailyStats.length} stats)`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('NocoDB fetch error:', err);
|
||||
throw new Error(`Failed to load from NocoDB: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchData(source = 'sheets') {
|
||||
return source === 'nocodb' ? fetchNocoDBData() : fetchSheetData();
|
||||
}
|
||||
|
||||
export function filterData(data, filters) {
|
||||
return data.filter(row => {
|
||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
||||
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterDataByDateRange(data, startDate, endDate, filters = {}) {
|
||||
return data.filter(row => {
|
||||
if (!row.date) return false;
|
||||
if (row.date < startDate || row.date > endDate) return false;
|
||||
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
|
||||
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateMetrics(data) {
|
||||
const revenue = data.reduce((sum, row) => sum + parseFloat(row.revenue_incl_tax || 0), 0);
|
||||
const visitors = data.reduce((sum, row) => sum + parseInt(row.visits || 0), 0);
|
||||
const tickets = data.reduce((sum, row) => sum + parseInt(row.tickets || 0), 0);
|
||||
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
|
||||
return { revenue, visitors, tickets, avgRevPerVisitor };
|
||||
}
|
||||
|
||||
export function formatCurrency(num) {
|
||||
if (isNaN(num)) return 'SAR 0';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'SAR',
|
||||
maximumFractionDigits: 0
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export function formatNumber(num) {
|
||||
if (isNaN(num)) return '0';
|
||||
return new Intl.NumberFormat('en-US').format(Math.round(num));
|
||||
}
|
||||
|
||||
export function formatCompact(num) {
|
||||
if (isNaN(num)) return '0';
|
||||
const absNum = Math.abs(num);
|
||||
if (absNum >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (absNum >= 1000) return (num / 1000).toFixed(0) + 'K';
|
||||
return formatNumber(num);
|
||||
}
|
||||
|
||||
export function formatCompactCurrency(num) {
|
||||
if (isNaN(num)) return 'SAR 0';
|
||||
const absNum = Math.abs(num);
|
||||
if (absNum >= 1000000) return 'SAR ' + (num / 1000000).toFixed(1) + 'M';
|
||||
if (absNum >= 1000) return 'SAR ' + (num / 1000).toFixed(0) + 'K';
|
||||
return formatCurrency(num);
|
||||
}
|
||||
|
||||
export function getWeekStart(dateStr) {
|
||||
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
||||
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
const dayOfWeek = date.getDay();
|
||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
|
||||
const monday = new Date(year, month - 1, day + diff);
|
||||
const y = monday.getFullYear();
|
||||
const m = String(monday.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(monday.getDate()).padStart(2, '0');
|
||||
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function groupByWeek(data) {
|
||||
const grouped = {};
|
||||
data.forEach(row => {
|
||||
if (!row.date) return;
|
||||
const weekStart = getWeekStart(row.date);
|
||||
if (!weekStart) return;
|
||||
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||
grouped[weekStart].revenue += parseFloat(row.revenue_incl_tax || 0);
|
||||
grouped[weekStart].visitors += parseInt(row.visits || 0);
|
||||
grouped[weekStart].tickets += parseInt(row.tickets || 0);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function groupByMuseum(data) {
|
||||
const grouped = {};
|
||||
data.forEach(row => {
|
||||
if (!row.museum_name) return;
|
||||
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||
grouped[row.museum_name].revenue += parseFloat(row.revenue_incl_tax || 0);
|
||||
grouped[row.museum_name].visitors += parseInt(row.visits || 0);
|
||||
grouped[row.museum_name].tickets += parseInt(row.tickets || 0);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function groupByDistrict(data) {
|
||||
const grouped = {};
|
||||
data.forEach(row => {
|
||||
if (!row.district) return;
|
||||
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
|
||||
grouped[row.district].revenue += parseFloat(row.revenue_incl_tax || 0);
|
||||
grouped[row.district].visitors += parseInt(row.visits || 0);
|
||||
grouped[row.district].tickets += parseInt(row.tickets || 0);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Dynamic data extraction helpers
|
||||
export function getUniqueYears(data) {
|
||||
const years = [...new Set(data.map(r => r.year).filter(Boolean))];
|
||||
return years.sort((a, b) => parseInt(a) - parseInt(b));
|
||||
}
|
||||
|
||||
export function getUniqueDistricts(data) {
|
||||
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
export function getDistrictMuseumMap(data) {
|
||||
const map = {};
|
||||
data.forEach(row => {
|
||||
if (!row.district || !row.museum_name) return;
|
||||
if (!map[row.district]) map[row.district] = new Set();
|
||||
map[row.district].add(row.museum_name);
|
||||
});
|
||||
// Convert sets to sorted arrays
|
||||
Object.keys(map).forEach(d => {
|
||||
map[d] = [...map[d]].sort();
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getMuseumsForDistrict(districtMuseumMap, district) {
|
||||
if (district === 'all') {
|
||||
return Object.values(districtMuseumMap).flat().sort();
|
||||
}
|
||||
return districtMuseumMap[district] || [];
|
||||
}
|
||||
|
||||
export function getLatestYear(data) {
|
||||
const years = getUniqueYears(data);
|
||||
return years.length > 0 ? years[years.length - 1] : '2025';
|
||||
}
|
||||
11
src/setupProxy.js
Normal file
11
src/setupProxy.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function(app) {
|
||||
app.use(
|
||||
'/api',
|
||||
createProxyMiddleware({
|
||||
target: 'http://localhost:8090',
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user