From 8a3b6a8d2e3fe06160a75c52079437fd39a241ad Mon Sep 17 00:00:00 2001 From: fahed Date: Mon, 2 Feb 2026 13:50:23 +0300 Subject: [PATCH] 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 --- .env.example | 3 + src/App.css | 124 ++++++++++ src/components/Comparison.js | 152 ++++-------- src/components/Dashboard.js | 286 +++++++---------------- src/components/shared/Carousel.jsx | 78 +++++++ src/components/shared/ChartCard.jsx | 28 +++ src/components/shared/EmptyState.jsx | 24 ++ src/components/shared/FilterControls.jsx | 53 +++++ src/components/shared/StatCard.jsx | 19 ++ src/components/shared/ToggleSwitch.jsx | 21 ++ src/components/shared/index.js | 6 + src/config/chartConfig.js | 97 ++++++++ src/services/dataService.js | 13 +- 13 files changed, 590 insertions(+), 314 deletions(-) create mode 100644 .env.example create mode 100644 src/components/shared/Carousel.jsx create mode 100644 src/components/shared/ChartCard.jsx create mode 100644 src/components/shared/EmptyState.jsx create mode 100644 src/components/shared/FilterControls.jsx create mode 100644 src/components/shared/StatCard.jsx create mode 100644 src/components/shared/ToggleSwitch.jsx create mode 100644 src/components/shared/index.js create mode 100644 src/config/chartConfig.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec47392 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# NocoDB Configuration (optional - only needed if using NocoDB as data source) +REACT_APP_NOCODB_URL=http://localhost:8090 +REACT_APP_NOCODB_TOKEN=your_token_here diff --git a/src/App.css b/src/App.css index e770738..e6e7190 100644 --- a/src/App.css +++ b/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; diff --git a/src/components/Comparison.js b/src/components/Comparison.js index 2f64434..171924e 100644 --- a/src/components/Comparison.js +++ b/src/components/Comparison.js @@ -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 }) {

Year-over-year analysis — same period, different years

-
-
setControlsExpanded(!controlsExpanded)}> -

Select Period

- -
-
-
-
- + + + -
+ {preset === 'custom' && ( <> -
- + setStartDate(e.target.value)} /> -
-
- + + setEndDate(e.target.value)} /> -
+ )} -
- + -
-
- + + -
-
+ +
{ranges.prev.start.substring(0, 4)}
@@ -458,9 +397,18 @@ function Comparison({ data, showDataLabels }) {
{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}
-
-
+ + {!hasData ? ( + + ) : ( + <> {/* Desktop: Grid layout */}
{metricCards.map((card, i) => ( @@ -661,6 +609,8 @@ function Comparison({ data, showDataLabels }) { ))}
+ + )} ); } diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js index b0b34e9..48c86f4 100644 --- a/src/components/Dashboard.js +++ b/src/components/Dashboard.js @@ -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 (
@@ -375,114 +291,74 @@ function Dashboard({ data, showDataLabels }) {

Real-time museum analytics from Google Sheets

-
-
setFiltersExpanded(!filtersExpanded)}> -

Filters

- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
+ + + + + + + + + + + + + + + + {/* Desktop: Grid */}
-
-

Total Revenue

-
{formatCurrency(metrics.revenue)}
- {yoyChange !== null && ( -
= 0 ? 'positive' : 'negative'}`}> - {yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY -
- )} -
-
-

Total Visitors

-
{formatNumber(metrics.visitors)}
-
-
-

Total Tickets

-
{formatNumber(metrics.tickets)}
-
-
-

Avg Revenue/Visitor

-
{formatCurrency(metrics.avgRevPerVisitor)}
-
+ + + +
{/* Mobile: Stats Carousel */}
-
-
-
- {statCards.map((card, i) => ( -
-
-

{card.title}

-
{card.value}
- {card.hasYoy && yoyChange !== null && ( -
= 0 ? 'positive' : 'negative'}`}> - {yoyChange >= 0 ? '↑' : '↓'} {Math.abs(yoyChange).toFixed(1)}% YoY -
- )} -
-
- ))} -
-
-
-
+ c.title.replace('Total ', '').replace('Avg ', ''))} + > {statCards.map((card, i) => ( - + ))} -
+
+ {!hasData ? ( + + ) : ( + <>

Quarterly Comparison: 2024 vs 2025

@@ -619,8 +495,6 @@ function Dashboard({ data, showDataLabels }) {
@@ -722,17 +596,19 @@ function Dashboard({ data, showDataLabels }) {
- {(filters.museum === 'all' ? dashboardCharts : dashboardCharts.filter(c => !['visitors-museum', 'revenue-museum'].includes(c.id))).map((chart, i) => ( + {chartLabels.map((label, i) => ( ))}
+ + )}
); } diff --git a/src/components/shared/Carousel.jsx b/src/components/shared/Carousel.jsx new file mode 100644 index 0000000..7d6637c --- /dev/null +++ b/src/components/shared/Carousel.jsx @@ -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 ( +
+
+
+
+ {React.Children.map(children, (child, i) => ( +
+ {child} +
+ ))} +
+
+
+ +
+ {Array.from({ length: itemCount }).map((_, i) => ( + + ))} +
+
+ ); +} + +export default Carousel; diff --git a/src/components/shared/ChartCard.jsx b/src/components/shared/ChartCard.jsx new file mode 100644 index 0000000..a9fa93b --- /dev/null +++ b/src/components/shared/ChartCard.jsx @@ -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 ( +
+ {(title || headerRight) && ( +
+ {title &&

{title}

} + {headerRight &&
{headerRight}
} +
+ )} +
+ {children} +
+
+ ); +} + +export default ChartCard; diff --git a/src/components/shared/EmptyState.jsx b/src/components/shared/EmptyState.jsx new file mode 100644 index 0000000..20dab2e --- /dev/null +++ b/src/components/shared/EmptyState.jsx @@ -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 ( +
+
{icon}
+

{title}

+

{message}

+ {action && ( + + )} +
+ ); +} + +export default EmptyState; diff --git a/src/components/shared/FilterControls.jsx b/src/components/shared/FilterControls.jsx new file mode 100644 index 0000000..e2fbbd1 --- /dev/null +++ b/src/components/shared/FilterControls.jsx @@ -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 ( +
+
setExpanded(!expanded)}> +

{title}

+
+ {onReset && expanded && ( + + )} + +
+
+
+ {children} +
+
+ ); +} + +function FilterGroup({ label, children }) { + return ( +
+ + {children} +
+ ); +} + +function FilterRow({ children }) { + return
{children}
; +} + +FilterControls.Group = FilterGroup; +FilterControls.Row = FilterRow; + +export default FilterControls; diff --git a/src/components/shared/StatCard.jsx b/src/components/shared/StatCard.jsx new file mode 100644 index 0000000..a8ea393 --- /dev/null +++ b/src/components/shared/StatCard.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +function StatCard({ title, value, change = null, changeLabel = 'YoY' }) { + const isPositive = change !== null && change >= 0; + + return ( +
+

{title}

+
{value}
+ {change !== null && ( +
+ {isPositive ? '↑' : '↓'} {Math.abs(change).toFixed(1)}% {changeLabel} +
+ )} +
+ ); +} + +export default StatCard; diff --git a/src/components/shared/ToggleSwitch.jsx b/src/components/shared/ToggleSwitch.jsx new file mode 100644 index 0000000..529520d --- /dev/null +++ b/src/components/shared/ToggleSwitch.jsx @@ -0,0 +1,21 @@ +import React from 'react'; + +function ToggleSwitch({ options, value, onChange, className = '' }) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +export default ToggleSwitch; diff --git a/src/components/shared/index.js b/src/components/shared/index.js new file mode 100644 index 0000000..d10bbb4 --- /dev/null +++ b/src/components/shared/index.js @@ -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'; diff --git a/src/config/chartConfig.js b/src/config/chartConfig.js new file mode 100644 index 0000000..902fcb1 --- /dev/null +++ b/src/config/chartConfig.js @@ -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 +}; diff --git a/src/services/dataService.js b/src/services/dataService.js index 8ab3511..b0a3b9f 100644 --- a/src/services/dataService.js +++ b/src/services/dataService.js @@ -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',