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:
139
src/App.css
139
src/App.css
@@ -271,38 +271,6 @@ html[dir="rtl"] {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-label-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.nav-label-toggle:hover {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-label-toggle.active {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.nav-label-toggle svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.nav-link.active svg {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -341,6 +309,58 @@ html[dir="rtl"] .nav-lang-toggle {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* Offline Badge */
|
||||
.offline-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
.offline-badge svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Refresh Button */
|
||||
.nav-refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-refresh-btn:hover {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-refresh-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.nav-refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-refresh-btn.refreshing svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.dashboard,
|
||||
.comparison {
|
||||
@@ -394,6 +414,18 @@ html[dir="rtl"] .nav-lang-toggle {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Header Toggles Container (for multiple toggles side by side) */
|
||||
.header-toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.header-toggles .toggle-with-label {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Filters - now uses .controls for consistency */
|
||||
|
||||
/* Stats Grid */
|
||||
@@ -870,33 +902,6 @@ table tbody tr:hover {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Data Labels Toggle */
|
||||
.label-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.label-toggle:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.label-toggle.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-metric-selector button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -926,13 +931,6 @@ table tbody tr:hover {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.chart-selectors-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Inline selectors inside chart cards (mobile) */
|
||||
.chart-selectors-inline {
|
||||
display: flex;
|
||||
@@ -1305,11 +1303,6 @@ table tbody tr:hover {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Chart selectors bar */
|
||||
.chart-selectors-bar {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Carousel - overlay arrows on mobile */
|
||||
.carousel-arrow {
|
||||
width: 28px;
|
||||
@@ -1484,10 +1477,6 @@ table tbody tr:hover {
|
||||
.carousel-dot .dot-label {
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.chart-selectors-bar {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Slides Builder ========== */
|
||||
|
||||
68
src/App.js
68
src/App.js
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } 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 Slides from './components/Slides';
|
||||
import { fetchData } from './services/dataService';
|
||||
import { fetchData, getCacheStatus, refreshData } from './services/dataService';
|
||||
import { useLanguage } from './contexts/LanguageContext';
|
||||
import './App.css';
|
||||
|
||||
@@ -21,8 +21,12 @@ function App() {
|
||||
const { t, dir, switchLanguage } = useLanguage();
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
const [cacheInfo, setCacheInfo] = useState(null);
|
||||
const [showDataLabels, setShowDataLabels] = useState(false);
|
||||
const [includeVAT, setIncludeVAT] = useState(true);
|
||||
const [dataSource, setDataSource] = useState('museums');
|
||||
|
||||
const dataSources = [
|
||||
@@ -31,23 +35,43 @@ function App() {
|
||||
{ id: 'ecommerce', labelKey: 'dataSources.ecommerce', enabled: false }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
const loadData = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await fetchData();
|
||||
setLoading(!forceRefresh);
|
||||
setRefreshing(forceRefresh);
|
||||
|
||||
const result = forceRefresh ? await refreshData() : await fetchData();
|
||||
setData(result);
|
||||
setError(null);
|
||||
setIsOffline(false);
|
||||
|
||||
// Update cache info
|
||||
const status = getCacheStatus();
|
||||
setCacheInfo(status);
|
||||
} catch (err) {
|
||||
// Check if we got data from cache despite the error
|
||||
const status = getCacheStatus();
|
||||
if (status.available && data.length > 0) {
|
||||
setIsOffline(true);
|
||||
setCacheInfo(status);
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [data.length]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container" dir={dir}>
|
||||
@@ -114,6 +138,32 @@ function App() {
|
||||
</svg>
|
||||
{t('nav.comparison')}
|
||||
</NavLink>
|
||||
{isOffline && (
|
||||
<span className="offline-badge" title={cacheInfo ? `Cached: ${new Date(cacheInfo.timestamp).toLocaleString()}` : ''}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
|
||||
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
|
||||
<path d="M10.71 5.05A16 16 0 0 1 22.58 9"/>
|
||||
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||
</svg>
|
||||
{t('app.offline') || 'Offline'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={`nav-refresh-btn ${refreshing ? 'refreshing' : ''}`}
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title={t('app.refresh') || 'Refresh data'}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="nav-lang-toggle"
|
||||
onClick={switchLanguage}
|
||||
@@ -131,8 +181,8 @@ function App() {
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} />} />
|
||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
|
||||
<Route path="/slides" element={<Slides data={data} />} />
|
||||
</Routes>
|
||||
|
||||
|
||||
@@ -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,6 +449,14 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
||||
<h1>{t('comparison.title')}</h1>
|
||||
<p>{t('comparison.subtitle')}</p>
|
||||
</div>
|
||||
<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">
|
||||
@@ -455,6 +465,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterControls title={t('comparison.selectPeriod')} onReset={resetFilters}>
|
||||
<FilterControls.Row>
|
||||
|
||||
@@ -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,6 +325,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
<h1>{t('dashboard.title')}</h1>
|
||||
<p>{t('dashboard.subtitle')}</p>
|
||||
</div>
|
||||
<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">
|
||||
@@ -330,6 +341,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterControls title={t('filters.title')} onReset={resetFilters}>
|
||||
<FilterControls.Row>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"name": "هاي هلا داتا",
|
||||
"loading": "جارٍ تحميل البيانات...",
|
||||
"error": "تعذر تحميل البيانات",
|
||||
"retry": "إعادة المحاولة"
|
||||
"retry": "إعادة المحاولة",
|
||||
"offline": "غير متصل",
|
||||
"refresh": "تحديث البيانات"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "لوحة التحكم",
|
||||
@@ -13,11 +15,14 @@
|
||||
"labels": "التسميات",
|
||||
"labelsOn": "التسميات مفعّلة",
|
||||
"labelsOff": "التسميات معطّلة",
|
||||
"labelsTooltip": "عرض القيم على الرسوم البيانية"
|
||||
"labelsTooltip": "عرض القيم على الرسوم البيانية",
|
||||
"vat": "الضريبة"
|
||||
},
|
||||
"toggle": {
|
||||
"on": "تشغيل",
|
||||
"off": "إيقاف"
|
||||
"off": "إيقاف",
|
||||
"incl": "شامل",
|
||||
"excl": "بدون"
|
||||
},
|
||||
"dataSources": {
|
||||
"museums": "المتاحف",
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"name": "HiHala Data",
|
||||
"loading": "Loading data...",
|
||||
"error": "Unable to load data",
|
||||
"retry": "Retry"
|
||||
"retry": "Retry",
|
||||
"offline": "Offline",
|
||||
"refresh": "Refresh data"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -13,11 +15,14 @@
|
||||
"labels": "Labels",
|
||||
"labelsOn": "Labels On",
|
||||
"labelsOff": "Labels Off",
|
||||
"labelsTooltip": "Show values on charts"
|
||||
"labelsTooltip": "Show values on charts",
|
||||
"vat": "VAT"
|
||||
},
|
||||
"toggle": {
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
"off": "Off",
|
||||
"incl": "Incl",
|
||||
"excl": "Excl"
|
||||
},
|
||||
"dataSources": {
|
||||
"museums": "Museums",
|
||||
|
||||
@@ -1,101 +1,96 @@
|
||||
// Data source configuration - all from environment variables
|
||||
// Set these in .env.local (never commit .env.local to git)
|
||||
// Data source: NocoDB only
|
||||
// Offline mode: caches data to localStorage for resilience
|
||||
|
||||
// NocoDB (primary/default)
|
||||
const NOCODB_URL = process.env.REACT_APP_NOCODB_URL || '';
|
||||
const NOCODB_TOKEN = process.env.REACT_APP_NOCODB_TOKEN || '';
|
||||
|
||||
// Google Sheets (fallback)
|
||||
const SPREADSHEET_ID = process.env.REACT_APP_SHEETS_ID || '';
|
||||
const SHEET_NAME = process.env.REACT_APP_SHEETS_NAME || 'Consolidated Data';
|
||||
const SHEET_URL = SPREADSHEET_ID
|
||||
? `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`
|
||||
: '';
|
||||
|
||||
// Table IDs (not sensitive - just identifiers)
|
||||
// Table IDs
|
||||
const NOCODB_TABLES = {
|
||||
districts: 'm8cup7lesbet0sa',
|
||||
museums: 'm1c7od7mdirffvu',
|
||||
dailyStats: 'mc7qhbdh3mjjwl8'
|
||||
};
|
||||
|
||||
// Cache keys
|
||||
const CACHE_KEY = 'hihala_data_cache';
|
||||
const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
|
||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
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;
|
||||
// ============================================
|
||||
// Offline Cache Functions
|
||||
// ============================================
|
||||
|
||||
// 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() {
|
||||
function saveToCache(data) {
|
||||
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;
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
||||
localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
|
||||
console.log(`Cached ${data.length} rows to localStorage`);
|
||||
} catch (err) {
|
||||
console.error('Fetch error:', err);
|
||||
throw new Error(`Failed to load data: ${err.message}`);
|
||||
console.warn('Failed to save to cache:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromCache() {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||
|
||||
if (!cached) return null;
|
||||
|
||||
const data = JSON.parse(cached);
|
||||
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
|
||||
const isStale = age > CACHE_MAX_AGE_MS;
|
||||
|
||||
console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
|
||||
|
||||
return { data, isStale, timestamp: parseInt(timestamp) };
|
||||
} catch (err) {
|
||||
console.warn('Failed to load from cache:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheAge() {
|
||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||
if (!timestamp) return null;
|
||||
return Date.now() - parseInt(timestamp);
|
||||
}
|
||||
|
||||
export function getCacheStatus() {
|
||||
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
|
||||
if (!cached || !timestamp) {
|
||||
return { available: false, timestamp: null, age: null, rows: 0 };
|
||||
}
|
||||
|
||||
const ts = parseInt(timestamp);
|
||||
const data = JSON.parse(cached);
|
||||
|
||||
return {
|
||||
available: true,
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
age: Date.now() - ts,
|
||||
rows: data.length,
|
||||
isStale: (Date.now() - ts) > CACHE_MAX_AGE_MS
|
||||
};
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
localStorage.removeItem(CACHE_TIMESTAMP_KEY);
|
||||
console.log('Cache cleared');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NocoDB Data Fetching
|
||||
// ============================================
|
||||
|
||||
async function fetchNocoDBTable(tableId, limit = 1000) {
|
||||
let allRecords = [];
|
||||
let offset = 0;
|
||||
@@ -119,9 +114,8 @@ async function fetchNocoDBTable(tableId, limit = 1000) {
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
export async function fetchNocoDBData() {
|
||||
try {
|
||||
console.log('Fetching from NocoDB (normalized)...');
|
||||
async function fetchFromNocoDB() {
|
||||
console.log('Fetching from NocoDB...');
|
||||
|
||||
// Fetch all three tables in parallel
|
||||
const [districts, museums, dailyStats] = await Promise.all([
|
||||
@@ -143,7 +137,7 @@ export async function fetchNocoDBData() {
|
||||
};
|
||||
});
|
||||
|
||||
// Join data into flat structure for dashboard
|
||||
// Join data into flat structure
|
||||
const data = dailyStats.map(row => {
|
||||
const museum = museumMap[row['nc_epk____Museums_id']] || {};
|
||||
const date = row.Date;
|
||||
@@ -151,6 +145,10 @@ export async function fetchNocoDBData() {
|
||||
const month = date ? parseInt(date.substring(5, 7)) : 0;
|
||||
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
|
||||
|
||||
// GrossRevenue = including VAT, NetRevenue = excluding VAT
|
||||
const grossRevenue = row.GrossRevenue || 0;
|
||||
const netRevenue = row.NetRevenue || (grossRevenue / 1.15);
|
||||
|
||||
return {
|
||||
date: date,
|
||||
museum_code: museum.code,
|
||||
@@ -158,39 +156,71 @@ export async function fetchNocoDBData() {
|
||||
district: museum.district,
|
||||
visits: row.Visits,
|
||||
tickets: row.Tickets,
|
||||
revenue_incl_tax: row.Revenue,
|
||||
revenue_gross: grossRevenue,
|
||||
revenue_net: netRevenue,
|
||||
revenue_incl_tax: grossRevenue, // Legacy compatibility
|
||||
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)`);
|
||||
console.log(`Loaded ${data.length} rows from NocoDB`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Data Fetcher (with offline fallback)
|
||||
// ============================================
|
||||
|
||||
export async function fetchData() {
|
||||
// Check if NocoDB is configured
|
||||
if (!NOCODB_URL || !NOCODB_TOKEN) {
|
||||
// Try cache
|
||||
const cached = loadFromCache();
|
||||
if (cached) {
|
||||
console.warn('NocoDB not configured, using cached data');
|
||||
return cached.data;
|
||||
}
|
||||
throw new Error('NocoDB not configured and no cached data available. Set REACT_APP_NOCODB_URL and REACT_APP_NOCODB_TOKEN in .env.local');
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to fetch fresh data
|
||||
const data = await fetchFromNocoDB();
|
||||
|
||||
// Save to cache on success
|
||||
saveToCache(data);
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('NocoDB fetch error:', err);
|
||||
throw new Error(`Failed to load from NocoDB: ${err.message}`);
|
||||
console.error('NocoDB fetch failed:', err.message);
|
||||
|
||||
// Try to load from cache
|
||||
const cached = loadFromCache();
|
||||
if (cached) {
|
||||
console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
throw new Error(`Database unavailable and no cached data: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Main data fetcher - tries NocoDB first, falls back to Sheets
|
||||
export async function fetchData() {
|
||||
// Try NocoDB if configured
|
||||
if (NOCODB_URL && NOCODB_TOKEN) {
|
||||
try {
|
||||
return await fetchNocoDBData();
|
||||
} catch (err) {
|
||||
console.warn('NocoDB failed, trying Google Sheets fallback...', err.message);
|
||||
}
|
||||
// Force refresh (bypass cache read, but still write to cache)
|
||||
export async function refreshData() {
|
||||
if (!NOCODB_URL || !NOCODB_TOKEN) {
|
||||
throw new Error('NocoDB not configured');
|
||||
}
|
||||
|
||||
// Fallback to Google Sheets if configured
|
||||
if (SHEET_URL) {
|
||||
return await fetchSheetData();
|
||||
}
|
||||
|
||||
throw new Error('No data source configured. Set REACT_APP_NOCODB_URL + REACT_APP_NOCODB_TOKEN, or REACT_APP_SHEETS_ID in .env.local');
|
||||
const data = await fetchFromNocoDB();
|
||||
saveToCache(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Data Filtering & Metrics
|
||||
// ============================================
|
||||
|
||||
export function filterData(data, filters) {
|
||||
return data.filter(row => {
|
||||
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
|
||||
@@ -211,14 +241,19 @@ export function filterDataByDateRange(data, startDate, endDate, filters = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateMetrics(data) {
|
||||
const revenue = data.reduce((sum, row) => sum + parseFloat(row.revenue_incl_tax || 0), 0);
|
||||
export function calculateMetrics(data, includeVAT = true) {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const revenue = data.reduce((sum, row) => sum + parseFloat(row[revenueField] || 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 };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Formatting Functions
|
||||
// ============================================
|
||||
|
||||
export function formatCurrency(num) {
|
||||
if (isNaN(num)) return 'SAR 0';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
@@ -249,6 +284,10 @@ export function formatCompactCurrency(num) {
|
||||
return formatCurrency(num);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Grouping Functions
|
||||
// ============================================
|
||||
|
||||
export function getWeekStart(dateStr) {
|
||||
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
|
||||
|
||||
@@ -265,45 +304,51 @@ export function getWeekStart(dateStr) {
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function groupByWeek(data) {
|
||||
export function groupByWeek(data, includeVAT = true) {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
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].revenue += parseFloat(row[revenueField] || 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) {
|
||||
export function groupByMuseum(data, includeVAT = true) {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
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].revenue += parseFloat(row[revenueField] || 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) {
|
||||
export function groupByDistrict(data, includeVAT = true) {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
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].revenue += parseFloat(row[revenueField] || 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
|
||||
// ============================================
|
||||
// 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));
|
||||
@@ -320,7 +365,6 @@ export function getDistrictMuseumMap(data) {
|
||||
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();
|
||||
});
|
||||
|
||||
81
src/services/dataService.legacy.js
Normal file
81
src/services/dataService.legacy.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// ============================================
|
||||
// ARCHIVED - Google Sheets Data Fetching
|
||||
// ============================================
|
||||
// Kept for reference only - NOT used in the app
|
||||
// The app now uses NocoDB exclusively with offline caching
|
||||
|
||||
const SPREADSHEET_ID = process.env.REACT_APP_SHEETS_ID || '';
|
||||
const SHEET_NAME = process.env.REACT_APP_SHEETS_NAME || 'Consolidated Data';
|
||||
const SHEET_URL = SPREADSHEET_ID
|
||||
? `https://docs.google.com/spreadsheets/d/${SPREADSHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(SHEET_NAME)}`
|
||||
: '';
|
||||
|
||||
// Convert Excel serial date to YYYY-MM-DD
|
||||
function excelDateToYMD(serial) {
|
||||
const num = parseInt(serial);
|
||||
if (isNaN(num) || num < 1) return null;
|
||||
|
||||
const utcDays = Math.floor(num - 25569);
|
||||
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] || '';
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user