feat: VAT toggle + offline mode
- Rename Revenue to GrossRevenue, add NetRevenue (excl. VAT) - Add VAT toggle (Incl/Excl) on Dashboard and Comparison pages - Add offline mode with localStorage caching (24h validity) - Add refresh button and offline indicator in nav - Remove Google Sheets fallback (archived to dataService.legacy.js) - Add AR/EN translations for new UI elements
This commit is contained in:
@@ -40,7 +40,7 @@ const generatePresetDates = (year) => ({
|
||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||
});
|
||||
|
||||
function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
||||
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) {
|
||||
const { t } = useLanguage();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -172,8 +172,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
||||
{ value: 'month', label: t('time.monthly') }
|
||||
];
|
||||
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
|
||||
const metricOptions = [
|
||||
{ value: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax', format: 'currency' },
|
||||
{ value: 'revenue', label: t('metrics.revenue'), field: revenueField, format: 'currency' },
|
||||
{ value: 'visitors', label: t('metrics.visitors'), field: 'visits', format: 'number' },
|
||||
{ value: 'tickets', label: t('metrics.tickets'), field: 'tickets', format: 'number' },
|
||||
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
|
||||
@@ -181,14 +183,14 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
if (metric === 'avgRevenue') {
|
||||
const revenue = rows.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
|
||||
const revenue = rows.reduce((s, r) => s + parseFloat(r[revenueField] || 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 fieldMap = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[metric];
|
||||
return rows.reduce((s, r) => s + parseFloat(r[field] || 0), 0);
|
||||
}, []);
|
||||
return rows.reduce((s, r) => s + parseFloat(r[field] || r.revenue_incl_tax || 0), 0);
|
||||
}, [revenueField]);
|
||||
|
||||
// Dynamic lists from data
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
@@ -214,8 +216,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
||||
[data, ranges.curr, filters]
|
||||
);
|
||||
|
||||
const prevMetrics = useMemo(() => calculateMetrics(prevData), [prevData]);
|
||||
const currMetrics = useMemo(() => calculateMetrics(currData), [currData]);
|
||||
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
||||
const currMetrics = useMemo(() => calculateMetrics(currData, includeVAT), [currData, includeVAT]);
|
||||
|
||||
const hasData = prevData.length > 0 || currData.length > 0;
|
||||
const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
|
||||
@@ -447,11 +449,20 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
||||
<h1>{t('comparison.title')}</h1>
|
||||
<p>{t('comparison.subtitle')}</p>
|
||||
</div>
|
||||
<div className="toggle-with-label">
|
||||
<span className="toggle-text">{t('nav.labels')}</span>
|
||||
<div className="toggle-switch">
|
||||
<button className={!showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(false)}>{t('toggle.off')}</button>
|
||||
<button className={showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(true)}>{t('toggle.on')}</button>
|
||||
<div className="header-toggles">
|
||||
<div className="toggle-with-label">
|
||||
<span className="toggle-text">{t('nav.vat') || 'VAT'}</span>
|
||||
<div className="toggle-switch">
|
||||
<button className={!includeVAT ? 'active' : ''} onClick={() => setIncludeVAT(false)}>{t('toggle.excl') || 'Excl'}</button>
|
||||
<button className={includeVAT ? 'active' : ''} onClick={() => setIncludeVAT(true)}>{t('toggle.incl') || 'Incl'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="toggle-with-label">
|
||||
<span className="toggle-text">{t('nav.labels')}</span>
|
||||
<div className="toggle-switch">
|
||||
<button className={!showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(false)}>{t('toggle.off')}</button>
|
||||
<button className={showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(true)}>{t('toggle.on')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ const defaultFilters = {
|
||||
|
||||
const filterKeys = ['year', 'district', 'museum', 'quarter'];
|
||||
|
||||
function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) {
|
||||
const { t } = useLanguage();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -62,7 +62,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
const [trendGranularity, setTrendGranularity] = useState('week');
|
||||
|
||||
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
||||
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
|
||||
const hasData = filteredData.length > 0;
|
||||
|
||||
const resetFilters = () => setFilters(defaultFilters);
|
||||
@@ -92,12 +92,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
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);
|
||||
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
||||
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
||||
}, [data, filters.year, metrics.revenue]);
|
||||
}, [data, filters.year, metrics.revenue, includeVAT]);
|
||||
|
||||
// Revenue trend data (weekly or daily)
|
||||
const trendData = useMemo(() => {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const formatLabel = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
@@ -106,12 +107,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
};
|
||||
|
||||
if (trendGranularity === 'week') {
|
||||
const grouped = groupByWeek(filteredData);
|
||||
const grouped = groupByWeek(filteredData, includeVAT);
|
||||
const weeks = Object.keys(grouped).filter(w => w).sort();
|
||||
return {
|
||||
labels: weeks.map(formatLabel),
|
||||
datasets: [{
|
||||
label: 'Revenue',
|
||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||
data: weeks.map(w => grouped[w].revenue),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
@@ -128,13 +129,13 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
filteredData.forEach(row => {
|
||||
const date = row.date;
|
||||
if (!dailyData[date]) dailyData[date] = 0;
|
||||
dailyData[date] += parseFloat(row.revenue_incl_tax || 0);
|
||||
dailyData[date] += parseFloat(row[revenueField] || row.revenue_incl_tax || 0);
|
||||
});
|
||||
const days = Object.keys(dailyData).sort();
|
||||
return {
|
||||
labels: days.map(formatLabel),
|
||||
datasets: [{
|
||||
label: 'Revenue',
|
||||
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
|
||||
data: days.map(d => dailyData[d]),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
@@ -146,11 +147,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
}]
|
||||
};
|
||||
}
|
||||
}, [filteredData, trendGranularity]);
|
||||
}, [filteredData, trendGranularity, includeVAT]);
|
||||
|
||||
// Museum data
|
||||
const museumData = useMemo(() => {
|
||||
const grouped = groupByMuseum(filteredData);
|
||||
const grouped = groupByMuseum(filteredData, includeVAT);
|
||||
const museums = Object.keys(grouped);
|
||||
return {
|
||||
visitors: {
|
||||
@@ -170,11 +171,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
}]
|
||||
}
|
||||
};
|
||||
}, [filteredData]);
|
||||
}, [filteredData, includeVAT]);
|
||||
|
||||
// District data
|
||||
const districtData = useMemo(() => {
|
||||
const grouped = groupByDistrict(filteredData);
|
||||
const grouped = groupByDistrict(filteredData, includeVAT);
|
||||
const districts = Object.keys(grouped);
|
||||
return {
|
||||
labels: districts,
|
||||
@@ -184,10 +185,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
borderRadius: 4
|
||||
}]
|
||||
};
|
||||
}, [filteredData]);
|
||||
}, [filteredData, includeVAT]);
|
||||
|
||||
// Quarterly YoY
|
||||
const quarterlyYoYData = useMemo(() => {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const d2024 = data.filter(row => row.year === '2024');
|
||||
const d2025 = data.filter(row => row.year === '2025');
|
||||
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
@@ -196,19 +198,19 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
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)),
|
||||
data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || 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)),
|
||||
data: quarters.map(q => d2025.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0)),
|
||||
backgroundColor: chartColors.primary,
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data]);
|
||||
}, [data, includeVAT]);
|
||||
|
||||
// Capture rate
|
||||
const captureRateData = useMemo(() => {
|
||||
@@ -288,6 +290,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
|
||||
// Quarterly table
|
||||
const quarterlyTable = useMemo(() => {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const d2024 = data.filter(row => row.year === '2024');
|
||||
const d2025 = data.filter(row => row.year === '2025');
|
||||
return [1, 2, 3, 4].map(q => {
|
||||
@@ -301,8 +304,8 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
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 rev24 = q2024.reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0);
|
||||
const rev25 = q2025.reduce((s, r) => s + parseFloat(r[revenueField] || 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;
|
||||
@@ -311,7 +314,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
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]);
|
||||
}, [data, filters.district, filters.museum, includeVAT]);
|
||||
|
||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||
|
||||
@@ -322,11 +325,20 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
<h1>{t('dashboard.title')}</h1>
|
||||
<p>{t('dashboard.subtitle')}</p>
|
||||
</div>
|
||||
<div className="toggle-with-label">
|
||||
<span className="toggle-text">{t('nav.labels')}</span>
|
||||
<div className="toggle-switch">
|
||||
<button className={!showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(false)}>{t('toggle.off')}</button>
|
||||
<button className={showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(true)}>{t('toggle.on')}</button>
|
||||
<div className="header-toggles">
|
||||
<div className="toggle-with-label">
|
||||
<span className="toggle-text">{t('nav.vat') || 'VAT'}</span>
|
||||
<div className="toggle-switch">
|
||||
<button className={!includeVAT ? 'active' : ''} onClick={() => setIncludeVAT(false)}>{t('toggle.excl') || 'Excl'}</button>
|
||||
<button className={includeVAT ? 'active' : ''} onClick={() => setIncludeVAT(true)}>{t('toggle.incl') || 'Incl'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="toggle-with-label">
|
||||
<span className="toggle-text">{t('nav.labels')}</span>
|
||||
<div className="toggle-switch">
|
||||
<button className={!showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(false)}>{t('toggle.off')}</button>
|
||||
<button className={showDataLabels ? 'active' : ''} onClick={() => setShowDataLabels(true)}>{t('toggle.on')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user