refactor: major architecture improvements
Security: - Remove exposed NocoDB token from client code - Add .env.example for environment variables Shared Components: - Carousel: touch/keyboard navigation, accessibility - ChartCard: reusable chart container - EmptyState: for no-data scenarios - FilterControls: collapsible filter panel with reset button - StatCard: metric display with change indicator - ToggleSwitch: accessible radio-style toggle Architecture: - Create src/config/chartConfig.js for shared chart options - Extract ChartJS registration to single location - Reduce code duplication in Dashboard and Comparison UX Improvements: - Add empty state when filters return no data - Add Reset Filters button to filter controls - Add skeleton loader CSS utilities - Improve focus states for accessibility - Use shared components in Dashboard and Comparison
This commit is contained in:
124
src/App.css
124
src/App.css
@@ -72,6 +72,78 @@ body {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 300px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-state-action {
|
||||
padding: 10px 20px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.empty-state-action:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Skeleton Loader */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.skeleton-text.lg { height: 2em; width: 60%; }
|
||||
.skeleton-text.sm { height: 0.75em; width: 40%; }
|
||||
|
||||
/* Navigation */
|
||||
.nav-bar {
|
||||
background: var(--surface);
|
||||
@@ -280,6 +352,25 @@ body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-card-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 280px;
|
||||
position: relative;
|
||||
@@ -349,6 +440,12 @@ table tbody tr:hover {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.controls-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls h3 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -357,6 +454,23 @@ table tbody tr:hover {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.controls-reset {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.controls-reset:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.controls-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
@@ -661,6 +775,16 @@ table tbody tr:hover {
|
||||
}
|
||||
|
||||
/* Carousel - elegant card slider */
|
||||
.carousel {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.carousel:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
position: relative;
|
||||
margin: 0 -6px;
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
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 { EmptyState, FilterControls } from './shared';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import {
|
||||
filterDataByDateRange,
|
||||
calculateMetrics,
|
||||
formatCurrency,
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
umrahData,
|
||||
@@ -26,17 +14,6 @@ import {
|
||||
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` },
|
||||
@@ -70,7 +47,6 @@ function Comparison({ data, showDataLabels }) {
|
||||
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);
|
||||
|
||||
@@ -159,6 +135,9 @@ function Comparison({ data, showDataLabels }) {
|
||||
|
||||
const prevMetrics = useMemo(() => calculateMetrics(prevData), [prevData]);
|
||||
const currMetrics = useMemo(() => calculateMetrics(currData), [currData]);
|
||||
|
||||
const hasData = prevData.length > 0 || currData.length > 0;
|
||||
const resetFilters = () => setFilters({ district: 'all', museum: 'all' });
|
||||
|
||||
const calcChange = (prev, curr) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
|
||||
@@ -204,31 +183,29 @@ function Comparison({ data, showDataLabels }) {
|
||||
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 revenueChange = calcChange(prevMetrics.revenue, currMetrics.revenue);
|
||||
const visitorsChange = calcChange(prevMetrics.visitors, currMetrics.visitors);
|
||||
const ticketsChange = calcChange(prevMetrics.tickets, currMetrics.tickets);
|
||||
const avgRevChange = calcChange(prevMetrics.avgRevPerVisitor, currMetrics.avgRevPerVisitor);
|
||||
const pilgrimsChange = pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null;
|
||||
const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null;
|
||||
|
||||
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 }
|
||||
{ title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
|
||||
{ title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
|
||||
{ title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
|
||||
{ title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
|
||||
];
|
||||
if (pilgrimCounts) {
|
||||
cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: changes.pilgrims, pendingMessage: 'Data not published yet' });
|
||||
cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, 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' });
|
||||
cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: 'Data not published yet' });
|
||||
}
|
||||
return cards;
|
||||
}, [prevMetrics, currMetrics, changes, pilgrimCounts, captureRates]);
|
||||
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates]);
|
||||
|
||||
const handleCardTouchStart = (e) => {
|
||||
touchStartCard.current = e.touches[0].clientX;
|
||||
@@ -345,34 +322,12 @@ function Comparison({ data, showDataLabels }) {
|
||||
};
|
||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue]);
|
||||
|
||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
...baseOptions,
|
||||
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 } }
|
||||
...baseOptions.plugins,
|
||||
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -383,17 +338,9 @@ function Comparison({ data, showDataLabels }) {
|
||||
<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>
|
||||
<FilterControls title="Select Period" onReset={resetFilters}>
|
||||
<FilterControls.Row>
|
||||
<FilterControls.Group label="Preset">
|
||||
<select value={preset} onChange={e => handlePresetChange(e.target.value)}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="jan">January</option>
|
||||
@@ -416,38 +363,30 @@ function Comparison({ data, showDataLabels }) {
|
||||
<option value="h2">H2</option>
|
||||
<option value="full">Full Year</option>
|
||||
</select>
|
||||
</div>
|
||||
</FilterControls.Group>
|
||||
{preset === 'custom' && (
|
||||
<>
|
||||
<div className="control-group">
|
||||
<label>From</label>
|
||||
<FilterControls.Group label="From">
|
||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>To</label>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="To">
|
||||
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
</FilterControls.Group>
|
||||
</>
|
||||
)}
|
||||
<div className="control-group">
|
||||
<label>District</label>
|
||||
<FilterControls.Group label="District">
|
||||
<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>
|
||||
))}
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>Museum</label>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="Museum">
|
||||
<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>
|
||||
))}
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</FilterControls.Group>
|
||||
</FilterControls.Row>
|
||||
<div className="period-display">
|
||||
<div className="period-box">
|
||||
<div className="label">{ranges.prev.start.substring(0, 4)}</div>
|
||||
@@ -458,9 +397,18 @@ function Comparison({ data, showDataLabels }) {
|
||||
<div className="dates">{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FilterControls>
|
||||
|
||||
{!hasData ? (
|
||||
<EmptyState
|
||||
icon="📈"
|
||||
title="No data for this period"
|
||||
message="No records found for the selected date range and filters."
|
||||
action={resetFilters}
|
||||
actionLabel="Reset Filters"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop: Grid layout */}
|
||||
<div className="comparison-grid desktop-only">
|
||||
{metricCards.map((card, i) => (
|
||||
@@ -661,6 +609,8 @@ function Comparison({ data, showDataLabels }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import React, { useState, useMemo } 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 { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import {
|
||||
filterData,
|
||||
calculateMetrics,
|
||||
@@ -29,38 +17,24 @@ import {
|
||||
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'
|
||||
const defaultFilters = {
|
||||
year: 'all',
|
||||
district: 'all',
|
||||
museum: 'all',
|
||||
quarter: 'all'
|
||||
};
|
||||
|
||||
function Dashboard({ data, showDataLabels }) {
|
||||
const [filters, setFilters] = useState({
|
||||
year: 'all',
|
||||
district: 'all',
|
||||
museum: 'all',
|
||||
quarter: 'all'
|
||||
});
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(true);
|
||||
const [filters, setFilters] = useState(defaultFilters);
|
||||
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]);
|
||||
const hasData = filteredData.length > 0;
|
||||
|
||||
const resetFilters = () => setFilters(defaultFilters);
|
||||
|
||||
// Stat cards for carousel
|
||||
const statCards = useMemo(() => [
|
||||
@@ -70,38 +44,11 @@ function Dashboard({ data, showDataLabels }) {
|
||||
{ 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;
|
||||
};
|
||||
// Chart carousel labels
|
||||
const chartLabels = useMemo(() => {
|
||||
const labels = ['Revenue Trend', 'Visitors', 'Revenue', 'Quarterly', 'District', 'Capture Rate'];
|
||||
return filters.museum === 'all' ? labels : labels.filter((_, i) => i !== 1 && i !== 2);
|
||||
}, [filters.museum]);
|
||||
|
||||
// Dynamic lists from data
|
||||
const years = useMemo(() => getUniqueYears(data), [data]);
|
||||
@@ -306,7 +253,7 @@ function Dashboard({ data, showDataLabels }) {
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data, filters.district, filters.museum]);
|
||||
}, [data, filters.district, filters.museum, showDataLabels]);
|
||||
|
||||
// Quarterly table
|
||||
const quarterlyTable = useMemo(() => {
|
||||
@@ -335,38 +282,7 @@ function Dashboard({ data, showDataLabels }) {
|
||||
});
|
||||
}, [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 } }
|
||||
}
|
||||
};
|
||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
@@ -375,114 +291,74 @@ function Dashboard({ data, showDataLabels }) {
|
||||
<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>
|
||||
<FilterControls title="Filters" onReset={resetFilters}>
|
||||
<FilterControls.Row>
|
||||
<FilterControls.Group label="Year">
|
||||
<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>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="District">
|
||||
<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>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="Museum">
|
||||
<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>
|
||||
</FilterControls.Group>
|
||||
<FilterControls.Group label="Quarter">
|
||||
<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>
|
||||
</FilterControls.Group>
|
||||
</FilterControls.Row>
|
||||
</FilterControls>
|
||||
|
||||
{/* 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>
|
||||
<StatCard title="Total Revenue" value={formatCurrency(metrics.revenue)} change={yoyChange} />
|
||||
<StatCard title="Total Visitors" value={formatNumber(metrics.visitors)} />
|
||||
<StatCard title="Total Tickets" value={formatNumber(metrics.tickets)} />
|
||||
<StatCard title="Avg Revenue/Visitor" value={formatCurrency(metrics.avgRevPerVisitor)} />
|
||||
</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">
|
||||
<Carousel
|
||||
activeIndex={activeStatCard}
|
||||
setActiveIndex={setActiveStatCard}
|
||||
labels={statCards.map(c => c.title.replace('Total ', '').replace('Avg ', ''))}
|
||||
>
|
||||
{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>
|
||||
<StatCard
|
||||
key={i}
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
change={card.hasYoy ? yoyChange : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title="No data found"
|
||||
message="No records match your current filters. Try adjusting your selection."
|
||||
action={resetFilters}
|
||||
actionLabel="Reset Filters"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="chart-card full-width" style={{marginBottom: '16px'}}>
|
||||
<h2>Quarterly Comparison: 2024 vs 2025</h2>
|
||||
<div className="table-container">
|
||||
@@ -619,8 +495,6 @@ function Dashboard({ data, showDataLabels }) {
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeChart * 100}%)` }}
|
||||
onTouchStart={handleChartTouchStart}
|
||||
onTouchEnd={handleChartTouchEnd}
|
||||
>
|
||||
<div className="carousel-slide">
|
||||
<div className="chart-card">
|
||||
@@ -722,17 +596,19 @@ function Dashboard({ data, showDataLabels }) {
|
||||
</div>
|
||||
|
||||
<div className="carousel-dots">
|
||||
{(filters.museum === 'all' ? dashboardCharts : dashboardCharts.filter(c => !['visitors-museum', 'revenue-museum'].includes(c.id))).map((chart, i) => (
|
||||
{chartLabels.map((label, i) => (
|
||||
<button
|
||||
key={chart.id}
|
||||
key={label}
|
||||
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveChart(i)}
|
||||
>
|
||||
<span className="dot-label">{chart.label}</span>
|
||||
<span className="dot-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
78
src/components/shared/Carousel.jsx
Normal file
78
src/components/shared/Carousel.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
|
||||
function Carousel({
|
||||
children,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
labels = [],
|
||||
showLabels = true,
|
||||
className = ''
|
||||
}) {
|
||||
const touchStart = useRef(null);
|
||||
const itemCount = React.Children.count(children);
|
||||
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
touchStart.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
if (!touchStart.current) return;
|
||||
const diff = touchStart.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeIndex < itemCount - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
} else if (diff < 0 && activeIndex > 0) {
|
||||
setActiveIndex(activeIndex - 1);
|
||||
}
|
||||
}
|
||||
touchStart.current = null;
|
||||
}, [activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'ArrowLeft' && activeIndex > 0) {
|
||||
setActiveIndex(activeIndex - 1);
|
||||
} else if (e.key === 'ArrowRight' && activeIndex < itemCount - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
}
|
||||
}, [activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
return (
|
||||
<div className={`carousel ${className}`} onKeyDown={handleKeyDown} tabIndex={0}>
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{React.Children.map(children, (child, i) => (
|
||||
<div className="carousel-slide" key={i}>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`carousel-dots ${showLabels ? 'labeled' : ''}`} role="tablist">
|
||||
{Array.from({ length: itemCount }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`carousel-dot ${activeIndex === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
role="tab"
|
||||
aria-selected={activeIndex === i}
|
||||
aria-label={labels[i] || `Slide ${i + 1}`}
|
||||
>
|
||||
{showLabels && labels[i] && (
|
||||
<span className="dot-label">{labels[i]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Carousel;
|
||||
28
src/components/shared/ChartCard.jsx
Normal file
28
src/components/shared/ChartCard.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
headerRight = null,
|
||||
fullWidth = false,
|
||||
halfWidth = false
|
||||
}) {
|
||||
const sizeClass = fullWidth ? 'full-width' : halfWidth ? 'half-width' : '';
|
||||
|
||||
return (
|
||||
<div className={`chart-card ${sizeClass} ${className}`}>
|
||||
{(title || headerRight) && (
|
||||
<div className="chart-card-header">
|
||||
{title && <h2>{title}</h2>}
|
||||
{headerRight && <div className="chart-card-actions">{headerRight}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="chart-container">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChartCard;
|
||||
24
src/components/shared/EmptyState.jsx
Normal file
24
src/components/shared/EmptyState.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
function EmptyState({
|
||||
icon = '📊',
|
||||
title = 'No data found',
|
||||
message = 'Try adjusting your filters',
|
||||
action,
|
||||
actionLabel = 'Reset Filters'
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">{icon}</div>
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
<p className="empty-state-message">{message}</p>
|
||||
{action && (
|
||||
<button className="empty-state-action" onClick={action}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmptyState;
|
||||
53
src/components/shared/FilterControls.jsx
Normal file
53
src/components/shared/FilterControls.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
function FilterControls({
|
||||
children,
|
||||
title = 'Filters',
|
||||
defaultExpanded = true,
|
||||
onReset = null,
|
||||
className = ''
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
|
||||
<div className="controls-header" onClick={() => setExpanded(!expanded)}>
|
||||
<h3>{title}</h3>
|
||||
<div className="controls-header-actions">
|
||||
{onReset && expanded && (
|
||||
<button
|
||||
className="controls-reset"
|
||||
onClick={(e) => { e.stopPropagation(); onReset(); }}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button className="controls-toggle">
|
||||
{expanded ? '▲ Hide' : '▼ Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="controls-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterGroup({ label, children }) {
|
||||
return (
|
||||
<div className="control-group">
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({ children }) {
|
||||
return <div className="control-row">{children}</div>;
|
||||
}
|
||||
|
||||
FilterControls.Group = FilterGroup;
|
||||
FilterControls.Row = FilterRow;
|
||||
|
||||
export default FilterControls;
|
||||
19
src/components/shared/StatCard.jsx
Normal file
19
src/components/shared/StatCard.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
function StatCard({ title, value, change = null, changeLabel = 'YoY' }) {
|
||||
const isPositive = change !== null && change >= 0;
|
||||
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<h3>{title}</h3>
|
||||
<div className="stat-value">{value}</div>
|
||||
{change !== null && (
|
||||
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}>
|
||||
{isPositive ? '↑' : '↓'} {Math.abs(change).toFixed(1)}% {changeLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatCard;
|
||||
21
src/components/shared/ToggleSwitch.jsx
Normal file
21
src/components/shared/ToggleSwitch.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
function ToggleSwitch({ options, value, onChange, className = '' }) {
|
||||
return (
|
||||
<div className={`toggle-switch ${className}`} role="radiogroup">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={value === option.value ? 'active' : ''}
|
||||
onClick={() => onChange(option.value)}
|
||||
role="radio"
|
||||
aria-checked={value === option.value}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToggleSwitch;
|
||||
6
src/components/shared/index.js
Normal file
6
src/components/shared/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as Carousel } from './Carousel';
|
||||
export { default as ChartCard } from './ChartCard';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as FilterControls } from './FilterControls';
|
||||
export { default as StatCard } from './StatCard';
|
||||
export { default as ToggleSwitch } from './ToggleSwitch';
|
||||
97
src/config/chartConfig.js
Normal file
97
src/config/chartConfig.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
|
||||
// Register ChartJS components once
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
ChartDataLabels
|
||||
);
|
||||
|
||||
export const chartColors = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#7c3aed',
|
||||
tertiary: '#0891b2',
|
||||
success: '#059669',
|
||||
danger: '#dc2626',
|
||||
muted: '#94a3b8',
|
||||
grid: '#f1f5f9'
|
||||
};
|
||||
|
||||
export const createDataLabelConfig = (showDataLabels) => ({
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
export const createBaseOptions = (showDataLabels) => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#1e293b',
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 11 }
|
||||
},
|
||||
datalabels: createDataLabelConfig(showDataLabels)
|
||||
},
|
||||
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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const lineDatasetDefaults = {
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
};
|
||||
|
||||
export const barDatasetDefaults = {
|
||||
borderRadius: 4
|
||||
};
|
||||
@@ -3,15 +3,12 @@ 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';
|
||||
// NocoDB configuration - uses environment variables for security
|
||||
// Set REACT_APP_NOCODB_URL and REACT_APP_NOCODB_TOKEN in .env.local
|
||||
const NOCODB_URL = process.env.REACT_APP_NOCODB_URL || '';
|
||||
const NOCODB_TOKEN = process.env.REACT_APP_NOCODB_TOKEN || '';
|
||||
|
||||
// Old flat table (for backwards compatibility)
|
||||
const NOCODB_TABLE_ID = 'mzcz8ktjybcjc79';
|
||||
|
||||
// New normalized tables (Samaya Museums Statistics base)
|
||||
// Table IDs (not sensitive - just identifiers)
|
||||
const NOCODB_TABLES = {
|
||||
districts: 'm8cup7lesbet0sa',
|
||||
museums: 'm1c7od7mdirffvu',
|
||||
|
||||
Reference in New Issue
Block a user