Compare commits
4 Commits
ed29e7c22c
...
c8567da75f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8567da75f | ||
|
|
30ea4b6ecb | ||
|
|
cd1e395ffa | ||
|
|
8934ba1e51 |
@@ -0,0 +1,79 @@
|
||||
# Dashboard Quick & Medium Improvements
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Improve reliability, performance, and code quality of the HiHala Dashboard.
|
||||
|
||||
**Architecture:** Focused improvements across data layer (timeout, retry), UI (error handling, loading skeletons, code splitting), config (VAT rate), and DX (TypeScript strict, dead code removal).
|
||||
|
||||
**Tech Stack:** React 19, Vite 7, TypeScript 5, Chart.js
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fetch Timeout + Retry Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/dataService.ts`
|
||||
|
||||
- [ ] Add `fetchWithTimeout` wrapper (10s timeout) around all fetch calls
|
||||
- [ ] Add retry with exponential backoff (3 attempts, 1s/2s/4s) to `fetchNocoDBTable` and `discoverTableIds`
|
||||
- [ ] Commit
|
||||
|
||||
### Task 2: Friendly Error Handling
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx` (error display)
|
||||
- Modify: `src/services/dataService.ts` (error classification)
|
||||
|
||||
- [ ] Add error classification in dataService (network, auth, config, unknown)
|
||||
- [ ] Replace raw error message in App.tsx with user-friendly messages using i18n keys
|
||||
- [ ] Add error keys to `src/locales/en.json` and `src/locales/ar.json`
|
||||
- [ ] Commit
|
||||
|
||||
### Task 3: Remove Dead Code
|
||||
|
||||
**Files:**
|
||||
- Delete: `src/hooks/useUrlState.ts`
|
||||
- Delete: `src/services/sallaService.ts`
|
||||
|
||||
- [ ] Delete unused files
|
||||
- [ ] Verify no imports reference them
|
||||
- [ ] Commit
|
||||
|
||||
### Task 4: Route-Based Code Splitting
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/App.tsx`
|
||||
|
||||
- [ ] Lazy-load Dashboard, Comparison, Slides with `React.lazy` + `Suspense`
|
||||
- [ ] Commit
|
||||
|
||||
### Task 5: Loading Skeletons
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/shared/LoadingSkeleton.tsx`
|
||||
- Modify: `src/App.tsx` (replace spinner with skeleton)
|
||||
- Modify: `src/App.css` (skeleton styles)
|
||||
|
||||
- [ ] Create skeleton component (stat cards + chart placeholders)
|
||||
- [ ] Use as Suspense fallback and initial loading state
|
||||
- [ ] Commit
|
||||
|
||||
### Task 6: VAT Rate from Config
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/dataService.ts`
|
||||
|
||||
- [ ] Extract VAT_RATE to a named constant at top of file
|
||||
- [ ] Commit
|
||||
|
||||
### Task 7: TypeScript Strict Mode
|
||||
|
||||
**Files:**
|
||||
- Modify: `tsconfig.json`
|
||||
- Modify: various files as needed to fix type errors
|
||||
|
||||
- [ ] Enable `strict: true`, `noImplicitAny: true`, `strictNullChecks: true`
|
||||
- [ ] Fix all resulting type errors
|
||||
- [ ] Verify build passes
|
||||
- [ ] Commit
|
||||
71
src/App.css
71
src/App.css
@@ -1987,3 +1987,74 @@ html[dir="rtl"] .chart-export-btn.visible {
|
||||
direction: ltr !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Loading Skeleton
|
||||
======================================== */
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.skeleton-container {
|
||||
padding: 80px 24px 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-card-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: #e2e8f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton-line-short {
|
||||
width: 40%;
|
||||
height: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skeleton-line-tall {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.skeleton-card-wide .skeleton-line-tall {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.skeleton-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.skeleton-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.skeleton-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.skeleton-card-wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
41
src/App.tsx
41
src/App.tsx
@@ -1,11 +1,14 @@
|
||||
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import React, { useState, useEffect, useCallback, ReactNode, lazy, Suspense } 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';
|
||||
|
||||
const Dashboard = lazy(() => import('./components/Dashboard'));
|
||||
const Comparison = lazy(() => import('./components/Comparison'));
|
||||
const Slides = lazy(() => import('./components/Slides'));
|
||||
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
||||
import { fetchData, getCacheStatus, refreshData } from './services/dataService';
|
||||
import { useLanguage } from './contexts/LanguageContext';
|
||||
import type { MuseumRecord, CacheStatus } from './types';
|
||||
import type { MuseumRecord, CacheStatus, DataErrorType } from './types';
|
||||
import { DataError } from './types';
|
||||
import './App.css';
|
||||
|
||||
interface NavLinkProps {
|
||||
@@ -35,7 +38,7 @@ function App() {
|
||||
const [data, setData] = useState<MuseumRecord[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
|
||||
const [isOffline, setIsOffline] = useState<boolean>(false);
|
||||
const [cacheInfo, setCacheInfo] = useState<CacheStatus | null>(null);
|
||||
const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
|
||||
@@ -62,7 +65,8 @@ function App() {
|
||||
const status = getCacheStatus();
|
||||
setCacheInfo(status);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
const type = err instanceof DataError ? err.type : 'unknown';
|
||||
setError({ message: (err as Error).message, type });
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -81,9 +85,8 @@ function App() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container" dir={dir}>
|
||||
<div className="loading-spinner"></div>
|
||||
<p>{t('app.loading')}</p>
|
||||
<div className="app" dir={dir}>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,8 +95,10 @@ function App() {
|
||||
return (
|
||||
<div className="error-container" dir={dir}>
|
||||
<h2>{t('app.error')}</h2>
|
||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>{error}</p>
|
||||
<button onClick={() => window.location.reload()}>{t('app.retry')}</button>
|
||||
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}>
|
||||
{t(`errors.${error.type}`)}
|
||||
</p>
|
||||
<button onClick={() => loadData()}>{t('app.retry')}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -187,11 +192,13 @@ function App() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<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>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<Routes>
|
||||
<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>
|
||||
</Suspense>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<nav className="mobile-nav">
|
||||
|
||||
@@ -16,9 +16,31 @@ import {
|
||||
getMuseumsForDistrict,
|
||||
getLatestYear
|
||||
} from '../services/dataService';
|
||||
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types';
|
||||
|
||||
interface PresetDateRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
interface PresetDates {
|
||||
[key: string]: PresetDateRange;
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
prev: number | null;
|
||||
curr: number | null;
|
||||
change: number | null;
|
||||
isCurrency?: boolean;
|
||||
isPercent?: boolean;
|
||||
pendingMessage?: string;
|
||||
prevYear: string;
|
||||
currYear: string;
|
||||
}
|
||||
|
||||
// Generate preset dates for a given year
|
||||
const generatePresetDates = (year) => ({
|
||||
const generatePresetDates = (year: number): PresetDates => ({
|
||||
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
|
||||
'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
|
||||
'mar': { start: `${year}-03-01`, end: `${year}-03-31` },
|
||||
@@ -40,15 +62,15 @@ const generatePresetDates = (year) => ({
|
||||
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
||||
});
|
||||
|
||||
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) {
|
||||
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
|
||||
const { t } = useLanguage();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Get available years from data
|
||||
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
||||
const latestYear = useMemo(() => parseInt(getLatestYear(data)), [data]);
|
||||
const availableYears = useMemo((): number[] => {
|
||||
const yearsSet = new Set<number>();
|
||||
data.forEach(r => {
|
||||
data.forEach((r: MuseumRecord) => {
|
||||
const d = r.date || (r as any).Date;
|
||||
if (d) yearsSet.add(new Date(d).getFullYear());
|
||||
});
|
||||
@@ -57,7 +79,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
}, [data]);
|
||||
|
||||
// Initialize state from URL or defaults
|
||||
const [selectedYear, setSelectedYearState] = useState(() => {
|
||||
const [selectedYear, setSelectedYearState] = useState<number>(() => {
|
||||
const urlYear = searchParams.get('year');
|
||||
return urlYear ? parseInt(urlYear) : latestYear;
|
||||
});
|
||||
@@ -66,7 +88,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
const [preset, setPresetState] = useState(() => searchParams.get('preset') || 'jan');
|
||||
const [startDate, setStartDateState] = useState(() => {
|
||||
const urlPreset = searchParams.get('preset');
|
||||
const year = searchParams.get('year') ? parseInt(searchParams.get('year')) : latestYear;
|
||||
const yearParam = searchParams.get('year');
|
||||
const year = yearParam ? parseInt(yearParam) : latestYear;
|
||||
const dates = generatePresetDates(year);
|
||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||
return dates[urlPreset].start;
|
||||
@@ -75,7 +98,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
});
|
||||
const [endDate, setEndDateState] = useState(() => {
|
||||
const urlPreset = searchParams.get('preset');
|
||||
const year = searchParams.get('year') ? parseInt(searchParams.get('year')) : latestYear;
|
||||
const yearParam = searchParams.get('year');
|
||||
const year = yearParam ? parseInt(yearParam) : latestYear;
|
||||
const dates = generatePresetDates(year);
|
||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||
return dates[urlPreset].end;
|
||||
@@ -93,7 +117,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
const [activeCard, setActiveCard] = useState(0);
|
||||
|
||||
// Update URL with current state
|
||||
const updateUrl = useCallback((newPreset, newFrom, newTo, newFilters, newYear) => {
|
||||
const updateUrl = useCallback((newPreset: string, newFrom: string | null, newTo: string | null, newFilters: DateRangeFilters | null, newYear: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
||||
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
||||
@@ -106,7 +130,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [setSearchParams, latestYear]);
|
||||
|
||||
const setSelectedYear = (year) => {
|
||||
const setSelectedYear = (year: number) => {
|
||||
setSelectedYearState(year);
|
||||
const newDates = generatePresetDates(year);
|
||||
if (preset !== 'custom' && newDates[preset]) {
|
||||
@@ -116,7 +140,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
updateUrl(preset, null, null, filters, year);
|
||||
};
|
||||
|
||||
const setPreset = (value) => {
|
||||
const setPreset = (value: string) => {
|
||||
setPresetState(value);
|
||||
if (value !== 'custom' && presetDates[value]) {
|
||||
setStartDateState(presetDates[value].start);
|
||||
@@ -125,19 +149,19 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
}
|
||||
};
|
||||
|
||||
const setStartDate = (value) => {
|
||||
const setStartDate = (value: string) => {
|
||||
setStartDateState(value);
|
||||
setPresetState('custom');
|
||||
updateUrl('custom', value, endDate, filters, selectedYear);
|
||||
};
|
||||
|
||||
const setEndDate = (value) => {
|
||||
const setEndDate = (value: string) => {
|
||||
setEndDateState(value);
|
||||
setPresetState('custom');
|
||||
updateUrl('custom', startDate, value, filters, selectedYear);
|
||||
};
|
||||
|
||||
const setFilters = (newFilters) => {
|
||||
const setFilters = (newFilters: DateRangeFilters | ((prev: DateRangeFilters) => DateRangeFilters)) => {
|
||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||
setFiltersState(updated);
|
||||
updateUrl(preset, startDate, endDate, updated, selectedYear);
|
||||
@@ -149,13 +173,13 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
];
|
||||
|
||||
// Touch swipe handlers
|
||||
const touchStartChart = useRef(null);
|
||||
const touchStartCard = useRef(null);
|
||||
const touchStartChart = useRef<number | null>(null);
|
||||
const touchStartCard = useRef<number | null>(null);
|
||||
|
||||
const handleChartTouchStart = (e) => {
|
||||
const handleChartTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartChart.current = e.touches[0].clientX;
|
||||
};
|
||||
const handleChartTouchEnd = (e) => {
|
||||
const handleChartTouchEnd = (e: React.TouchEvent) => {
|
||||
if (!touchStartChart.current) return;
|
||||
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
@@ -183,15 +207,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
{ value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
|
||||
];
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||
if (metric === 'avgRevenue') {
|
||||
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);
|
||||
const revenue = rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[revenueField] || r.revenue_incl_tax || 0)), 0);
|
||||
const visitors = rows.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
return visitors > 0 ? revenue / visitors : 0;
|
||||
}
|
||||
const fieldMap = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||
const fieldMap: Record<string, string> = { revenue: revenueField, visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[metric];
|
||||
return rows.reduce((s, r) => s + parseFloat(r[field] || r.revenue_incl_tax || 0), 0);
|
||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as any)[field] || r.revenue_incl_tax || 0)), 0);
|
||||
}, [revenueField]);
|
||||
|
||||
// Dynamic lists from data
|
||||
@@ -203,8 +227,8 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
const ranges = useMemo(() => ({
|
||||
curr: { start: startDate, end: endDate },
|
||||
prev: {
|
||||
start: startDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1),
|
||||
end: endDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1)
|
||||
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1)),
|
||||
end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1))
|
||||
}
|
||||
}), [startDate, endDate]);
|
||||
|
||||
@@ -224,11 +248,11 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
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);
|
||||
const calcChange = (prev: number, curr: number) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
||||
|
||||
// Get quarter from date range (returns null if not a clean quarter)
|
||||
const getQuarterFromRange = (start, end) => {
|
||||
const quarterRanges = {
|
||||
const getQuarterFromRange = (start: string, end: string) => {
|
||||
const quarterRanges: Record<number, { start: string; end: string }> = {
|
||||
1: { start: '-01-01', end: '-03-31' },
|
||||
2: { start: '-04-01', end: '-06-30' },
|
||||
3: { start: '-07-01', end: '-09-30' },
|
||||
@@ -331,10 +355,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
return cards;
|
||||
}, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
|
||||
|
||||
const handleCardTouchStart = (e) => {
|
||||
const handleCardTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartCard.current = e.touches[0].clientX;
|
||||
};
|
||||
const handleCardTouchEnd = (e) => {
|
||||
const handleCardTouchEnd = (e: React.TouchEvent) => {
|
||||
if (!touchStartCard.current) return;
|
||||
const diff = touchStartCard.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
@@ -347,7 +371,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
touchStartCard.current = null;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const d = new Date(year, month - 1, day);
|
||||
@@ -355,7 +379,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
};
|
||||
|
||||
// Generate period label - shows year if same year, or "MMM YY–MMM YY" if spans years
|
||||
const getPeriodLabel = useCallback((startDate, endDate) => {
|
||||
const getPeriodLabel = useCallback((startDate: string, endDate: string) => {
|
||||
if (!startDate || !endDate) return '';
|
||||
const startYear = startDate.substring(0, 4);
|
||||
const endYear = endDate.substring(0, 4);
|
||||
@@ -374,11 +398,11 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
|
||||
// Time series chart (daily or weekly)
|
||||
const timeSeriesChart = useMemo(() => {
|
||||
const groupByPeriod = (periodData, periodStart, metric, granularity) => {
|
||||
const groupByPeriod = (periodData: MuseumRecord[], periodStart: string, metric: string, granularity: string) => {
|
||||
const start = new Date(periodStart);
|
||||
const groupedRows = {};
|
||||
|
||||
periodData.forEach(row => {
|
||||
const groupedRows: Record<number, MuseumRecord[]> = {};
|
||||
|
||||
periodData.forEach((row: MuseumRecord) => {
|
||||
if (!row.date) return;
|
||||
const rowDate = new Date(row.date);
|
||||
const daysDiff = Math.floor((rowDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
@@ -398,9 +422,9 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
groupedRows[key].push(row);
|
||||
});
|
||||
|
||||
const result = {};
|
||||
const result: Record<number, number> = {};
|
||||
Object.keys(groupedRows).forEach(key => {
|
||||
result[key] = getMetricValue(groupedRows[key], metric);
|
||||
result[Number(key)] = getMetricValue(groupedRows[Number(key)], metric);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -454,7 +478,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
const museumChart = useMemo(() => {
|
||||
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
||||
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
||||
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
||||
const allMuseums = [...new Set(data.map((r: MuseumRecord) => r.museum_name))].filter(Boolean) as string[];
|
||||
const prevByMuseum: Record<string, number> = {};
|
||||
const currByMuseum: Record<string, number> = {};
|
||||
allMuseums.forEach(m => {
|
||||
@@ -802,12 +826,12 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }) {
|
||||
function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }: MetricCardProps) {
|
||||
const hasPending = prev === null || curr === null;
|
||||
const isPositive = change >= 0;
|
||||
const isPositive = (change ?? 0) >= 0;
|
||||
const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`);
|
||||
|
||||
const formatValue = (val) => {
|
||||
const formatValue = (val: number | null | undefined) => {
|
||||
if (val === null || val === undefined) return '—';
|
||||
if (isPercent) return val.toFixed(2) + '%';
|
||||
if (isCurrency) return formatCompactCurrency(val);
|
||||
|
||||
@@ -20,17 +20,18 @@ import {
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict
|
||||
} from '../services/dataService';
|
||||
import type { DashboardProps, Filters, MuseumRecord } from '../types';
|
||||
|
||||
const defaultFilters = {
|
||||
const defaultFilters: Filters = {
|
||||
year: 'all',
|
||||
district: 'all',
|
||||
museum: 'all',
|
||||
quarter: 'all'
|
||||
};
|
||||
|
||||
const filterKeys = ['year', 'district', 'museum', 'quarter'];
|
||||
const filterKeys: (keyof Filters)[] = ['year', 'district', 'museum', 'quarter'];
|
||||
|
||||
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }) {
|
||||
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
|
||||
const { t } = useLanguage();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
|
||||
@@ -51,7 +52,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
});
|
||||
|
||||
// Update both state and URL
|
||||
const setFilters = (newFilters) => {
|
||||
const setFilters = (newFilters: Filters | ((prev: Filters) => Filters)) => {
|
||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||
setFiltersState(updated);
|
||||
|
||||
@@ -97,7 +98,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
const yoyChange = useMemo(() => {
|
||||
if (filters.year === 'all') return null;
|
||||
const prevYear = String(parseInt(filters.year) - 1);
|
||||
const prevData = data.filter(row => row.year === prevYear);
|
||||
const prevData = data.filter((row: MuseumRecord) => row.year === prevYear);
|
||||
if (prevData.length === 0) return null;
|
||||
const prevMetrics = calculateMetrics(prevData, includeVAT);
|
||||
return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null;
|
||||
@@ -106,7 +107,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
// Revenue trend data (weekly or daily)
|
||||
const trendData = useMemo(() => {
|
||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
||||
const formatLabel = (dateStr) => {
|
||||
const formatLabel = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const d = new Date(year, month - 1, day);
|
||||
@@ -166,7 +167,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
filteredData.forEach(row => {
|
||||
const date = row.date;
|
||||
if (!dailyData[date]) dailyData[date] = 0;
|
||||
dailyData[date] += Number(row[revenueField] || row.revenue_incl_tax || 0);
|
||||
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || row.revenue_incl_tax || 0);
|
||||
});
|
||||
const days = Object.keys(dailyData).sort();
|
||||
const revenueValues = days.map(d => dailyData[d]);
|
||||
@@ -228,21 +229,21 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
// 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 d2024 = data.filter((row: MuseumRecord) => row.year === '2024');
|
||||
const d2025 = data.filter((row: MuseumRecord) => row.year === '2025');
|
||||
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
|
||||
return {
|
||||
labels: quarters,
|
||||
datasets: [
|
||||
{
|
||||
label: '2024',
|
||||
data: quarters.map(q => d2024.filter(r => r.quarter === q.slice(1)).reduce((s, r) => s + parseFloat(r[revenueField] || r.revenue_incl_tax || 0), 0)),
|
||||
data: quarters.map(q => d2024.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || 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[revenueField] || r.revenue_incl_tax || 0), 0)),
|
||||
data: quarters.map(q => d2025.filter((r: MuseumRecord) => r.quarter === q.slice(1)).reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0)),
|
||||
backgroundColor: chartColors.primary,
|
||||
borderRadius: 4
|
||||
}
|
||||
@@ -252,17 +253,17 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
|
||||
// Capture rate
|
||||
const captureRateData = useMemo(() => {
|
||||
const labels = [];
|
||||
const rates = [];
|
||||
const pilgrimCounts = [];
|
||||
const labels: string[] = [];
|
||||
const rates: number[] = [];
|
||||
const pilgrimCounts: number[] = [];
|
||||
[2024, 2025].forEach(year => {
|
||||
[1, 2, 3, 4].forEach(q => {
|
||||
const pilgrims = umrahData[year]?.[q];
|
||||
if (!pilgrims) return;
|
||||
let qData = data.filter(r => r.year === String(year) && r.quarter === String(q));
|
||||
if (filters.district !== 'all') qData = qData.filter(r => r.district === filters.district);
|
||||
if (filters.museum !== 'all') qData = qData.filter(r => r.museum_name === filters.museum);
|
||||
const visitors = qData.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
||||
let qData = data.filter((r: MuseumRecord) => r.year === String(year) && r.quarter === String(q));
|
||||
if (filters.district !== 'all') qData = qData.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
if (filters.museum !== 'all') qData = qData.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
||||
const visitors = qData.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
labels.push(`Q${q} ${year}`);
|
||||
rates.push((visitors / pilgrims * 100));
|
||||
pilgrimCounts.push(pilgrims);
|
||||
@@ -286,7 +287,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
yAxisID: 'y',
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
formatter: (value) => value.toFixed(2) + '%',
|
||||
formatter: (value: number) => value.toFixed(2) + '%',
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
@@ -312,7 +313,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
order: 1,
|
||||
datalabels: {
|
||||
display: showDataLabels,
|
||||
formatter: (value) => (value / 1000000).toFixed(2) + 'M',
|
||||
formatter: (value: number) => (value / 1000000).toFixed(2) + 'M',
|
||||
color: '#1e293b',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 3,
|
||||
@@ -329,23 +330,23 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
// 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');
|
||||
const d2024 = data.filter((row: MuseumRecord) => row.year === '2024');
|
||||
const d2025 = data.filter((row: MuseumRecord) => row.year === '2025');
|
||||
return [1, 2, 3, 4].map(q => {
|
||||
let q2024 = d2024.filter(r => r.quarter === String(q));
|
||||
let q2025 = d2025.filter(r => r.quarter === String(q));
|
||||
let q2024 = d2024.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||
let q2025 = d2025.filter((r: MuseumRecord) => r.quarter === String(q));
|
||||
if (filters.district !== 'all') {
|
||||
q2024 = q2024.filter(r => r.district === filters.district);
|
||||
q2025 = q2025.filter(r => r.district === filters.district);
|
||||
q2024 = q2024.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
q2025 = q2025.filter((r: MuseumRecord) => r.district === filters.district);
|
||||
}
|
||||
if (filters.museum !== 'all') {
|
||||
q2024 = q2024.filter(r => r.museum_name === filters.museum);
|
||||
q2025 = q2025.filter(r => r.museum_name === filters.museum);
|
||||
q2024 = q2024.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
||||
q2025 = q2025.filter((r: MuseumRecord) => r.museum_name === filters.museum);
|
||||
}
|
||||
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 rev24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0);
|
||||
const rev25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseFloat(String(r[revenueField as keyof MuseumRecord] || r.revenue_incl_tax || 0)), 0);
|
||||
const vis24 = q2024.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
const vis25 = q2025.reduce((s: number, r: MuseumRecord) => s + parseInt(String(r.visits || 0)), 0);
|
||||
const revChg = rev24 > 0 ? ((rev25 - rev24) / rev24 * 100) : 0;
|
||||
const visChg = vis24 > 0 ? ((vis25 - vis24) / vis24 * 100) : 0;
|
||||
const cap24 = umrahData[2024][q] ? (vis24 / umrahData[2024][q] * 100) : null;
|
||||
@@ -545,7 +546,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
|
||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||
}
|
||||
@@ -560,7 +561,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' },
|
||||
border: { display: false },
|
||||
title: { display: true, text: 'Capture Rate (%)', font: { size: 12 }, color: chartColors.secondary }
|
||||
},
|
||||
@@ -568,7 +569,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
ticks: { font: { size: 12 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' },
|
||||
border: { display: false },
|
||||
title: { display: true, text: 'Pilgrims', font: { size: 12 }, color: chartColors.tertiary }
|
||||
}
|
||||
@@ -651,7 +652,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
tooltip: {
|
||||
...baseOptions.plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) => {
|
||||
if (ctx.dataset.label === 'Capture Rate (%)') {
|
||||
return `Capture Rate: ${ctx.parsed.y.toFixed(2)}%`;
|
||||
}
|
||||
@@ -666,14 +667,14 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: { color: chartColors.grid },
|
||||
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => v.toFixed(1) + '%' },
|
||||
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => Number(v).toFixed(1) + '%' },
|
||||
border: { display: false }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v) => (v / 1000000).toFixed(0) + 'M' },
|
||||
ticks: { font: { size: 13 }, color: '#94a3b8', callback: (v: number | string) => (Number(v) / 1000000).toFixed(0) + 'M' },
|
||||
border: { display: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,31 +12,69 @@ import {
|
||||
getMuseumsForDistrict
|
||||
} from '../services/dataService';
|
||||
import JSZip from 'jszip';
|
||||
import type {
|
||||
MuseumRecord,
|
||||
DistrictMuseumMap,
|
||||
SlideConfig,
|
||||
ChartTypeOption,
|
||||
MetricOption,
|
||||
MetricFieldInfo,
|
||||
SlidesProps
|
||||
} from '../types';
|
||||
|
||||
function Slides({ data }) {
|
||||
interface SlideEditorProps {
|
||||
slide: SlideConfig;
|
||||
onUpdate: (updates: Partial<SlideConfig>) => void;
|
||||
districts: string[];
|
||||
districtMuseumMap: DistrictMuseumMap;
|
||||
data: MuseumRecord[];
|
||||
chartTypes: ChartTypeOption[];
|
||||
metrics: MetricOption[];
|
||||
}
|
||||
|
||||
interface SlidePreviewProps {
|
||||
slide: SlideConfig;
|
||||
data: MuseumRecord[];
|
||||
districts: string[];
|
||||
districtMuseumMap: DistrictMuseumMap;
|
||||
metrics: MetricOption[];
|
||||
}
|
||||
|
||||
interface PreviewModeProps {
|
||||
slides: SlideConfig[];
|
||||
data: MuseumRecord[];
|
||||
districts: string[];
|
||||
districtMuseumMap: DistrictMuseumMap;
|
||||
currentSlide: number;
|
||||
setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
|
||||
onExit: () => void;
|
||||
metrics: MetricOption[];
|
||||
}
|
||||
|
||||
function Slides({ data }: SlidesProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const CHART_TYPES = useMemo(() => [
|
||||
|
||||
const CHART_TYPES: ChartTypeOption[] = useMemo(() => [
|
||||
{ id: 'trend', label: t('slides.revenueTrend'), icon: '📈' },
|
||||
{ id: 'museum-bar', label: t('slides.byMuseum'), icon: '📊' },
|
||||
{ id: 'kpi-cards', label: t('slides.kpiSummary'), icon: '🎯' },
|
||||
{ id: 'comparison', label: t('slides.yoyComparison'), icon: '⚖️' }
|
||||
], [t]);
|
||||
|
||||
const METRICS = useMemo(() => [
|
||||
const METRICS: MetricOption[] = useMemo(() => [
|
||||
{ id: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax' },
|
||||
{ id: 'visitors', label: t('metrics.visitors'), field: 'visits' },
|
||||
{ id: 'tickets', label: t('metrics.tickets'), field: 'tickets' }
|
||||
], [t]);
|
||||
const [slides, setSlides] = useState([]);
|
||||
const [editingSlide, setEditingSlide] = useState(null);
|
||||
const [slides, setSlides] = useState<SlideConfig[]>([]);
|
||||
const [editingSlide, setEditingSlide] = useState<number | null>(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
||||
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
|
||||
const defaultSlideConfig = {
|
||||
const defaultSlideConfig: Omit<SlideConfig, 'id'> = {
|
||||
title: 'Slide Title',
|
||||
chartType: 'trend',
|
||||
metric: 'revenue',
|
||||
@@ -48,7 +86,7 @@ function Slides({ data }) {
|
||||
};
|
||||
|
||||
const addSlide = () => {
|
||||
const newSlide = {
|
||||
const newSlide: SlideConfig = {
|
||||
id: Date.now(),
|
||||
...defaultSlideConfig,
|
||||
title: `Slide ${slides.length + 1}`
|
||||
@@ -57,16 +95,16 @@ function Slides({ data }) {
|
||||
setEditingSlide(newSlide.id);
|
||||
};
|
||||
|
||||
const updateSlide = (id, updates) => {
|
||||
const updateSlide = (id: number, updates: Partial<SlideConfig>) => {
|
||||
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
|
||||
};
|
||||
|
||||
const removeSlide = (id) => {
|
||||
const removeSlide = (id: number) => {
|
||||
setSlides(slides.filter(s => s.id !== id));
|
||||
if (editingSlide === id) setEditingSlide(null);
|
||||
};
|
||||
|
||||
const moveSlide = (id, direction) => {
|
||||
const moveSlide = (id: number, direction: number) => {
|
||||
const index = slides.findIndex(s => s.id === id);
|
||||
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
|
||||
const newSlides = [...slides];
|
||||
@@ -74,10 +112,10 @@ function Slides({ data }) {
|
||||
setSlides(newSlides);
|
||||
};
|
||||
|
||||
const duplicateSlide = (id) => {
|
||||
const duplicateSlide = (id: number) => {
|
||||
const slide = slides.find(s => s.id === id);
|
||||
if (slide) {
|
||||
const newSlide = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
|
||||
const newSlide: SlideConfig = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
|
||||
const index = slides.findIndex(s => s.id === id);
|
||||
const newSlides = [...slides];
|
||||
newSlides.splice(index + 1, 0, newSlide);
|
||||
@@ -87,7 +125,7 @@ function Slides({ data }) {
|
||||
|
||||
const exportAsHTML = async () => {
|
||||
const zip = new JSZip();
|
||||
|
||||
|
||||
// Generate HTML for each slide
|
||||
const slidesHTML = slides.map((slide, index) => {
|
||||
return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
|
||||
@@ -103,21 +141,21 @@ function Slides({ data }) {
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; }
|
||||
.slide {
|
||||
width: 100vw; height: 100vh;
|
||||
.slide {
|
||||
width: 100vw; height: 100vh;
|
||||
display: flex; flex-direction: column;
|
||||
justify-content: center; align-items: center;
|
||||
padding: 60px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
page-break-after: always;
|
||||
}
|
||||
.slide-title {
|
||||
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
|
||||
.slide-title {
|
||||
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
|
||||
margin-bottom: 40px; text-align: center;
|
||||
}
|
||||
.slide-subtitle {
|
||||
color: #94a3b8; font-size: 1.1rem; margin-bottom: 30px;
|
||||
}
|
||||
.chart-container {
|
||||
.chart-container {
|
||||
width: 100%; max-width: 900px; height: 400px;
|
||||
background: rgba(255,255,255,0.03); border-radius: 16px;
|
||||
padding: 30px;
|
||||
@@ -134,8 +172,8 @@ function Slides({ data }) {
|
||||
.kpi-label { color: #94a3b8; font-size: 1rem; margin-top: 8px; }
|
||||
.logo { position: absolute; bottom: 30px; right: 40px; opacity: 0.6; }
|
||||
.logo svg { height: 30px; }
|
||||
.slide-number {
|
||||
position: absolute; bottom: 30px; left: 40px;
|
||||
.slide-number {
|
||||
position: absolute; bottom: 30px; left: 40px;
|
||||
color: #475569; font-size: 0.9rem;
|
||||
}
|
||||
@media print {
|
||||
@@ -153,7 +191,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
</html>`;
|
||||
|
||||
zip.file('presentation.html', fullHTML);
|
||||
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -165,7 +203,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
|
||||
if (previewMode) {
|
||||
return (
|
||||
<PreviewMode
|
||||
<PreviewMode
|
||||
slides={slides}
|
||||
data={data}
|
||||
districts={districts}
|
||||
@@ -221,8 +259,8 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
) : (
|
||||
<div className="slides-thumbnails">
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`slide-thumbnail ${editingSlide === slide.id ? 'active' : ''}`}
|
||||
onClick={() => setEditingSlide(slide.id)}
|
||||
>
|
||||
@@ -243,7 +281,7 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
|
||||
{editingSlide && (
|
||||
<SlideEditor
|
||||
slide={slides.find(s => s.id === editingSlide)}
|
||||
slide={slides.find(s => s.id === editingSlide)!}
|
||||
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
||||
districts={districts}
|
||||
districtMuseumMap={districtMuseumMap}
|
||||
@@ -257,9 +295,9 @@ ${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
);
|
||||
}
|
||||
|
||||
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }) {
|
||||
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, chartTypes, metrics }: SlideEditorProps) {
|
||||
const { t } = useLanguage();
|
||||
const availableMuseums = useMemo(() =>
|
||||
const availableMuseums = useMemo(() =>
|
||||
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
||||
[districtMuseumMap, slide.district]
|
||||
);
|
||||
@@ -268,9 +306,9 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
<div className="slide-editor">
|
||||
<div className="editor-section">
|
||||
<label>{t('slides.slideTitle')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slide.title}
|
||||
<input
|
||||
type="text"
|
||||
value={slide.title}
|
||||
onChange={e => onUpdate({ title: e.target.value })}
|
||||
placeholder={t('slides.slideTitle')}
|
||||
/>
|
||||
@@ -279,7 +317,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
<div className="editor-section">
|
||||
<label>{t('slides.chartType')}</label>
|
||||
<div className="chart-type-grid">
|
||||
{chartTypes.map(type => (
|
||||
{chartTypes.map((type: ChartTypeOption) => (
|
||||
<button
|
||||
key={type.id}
|
||||
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
|
||||
@@ -295,7 +333,7 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
<div className="editor-section">
|
||||
<label>{t('slides.metric')}</label>
|
||||
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
|
||||
{metrics.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
{metrics.map((m: MetricOption) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -315,14 +353,14 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
<label>{t('filters.district')}</label>
|
||||
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
|
||||
<option value="all">{t('filters.allDistricts')}</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
{districts.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-section">
|
||||
<label>{t('filters.museum')}</label>
|
||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
||||
<option value="all">{t('filters.allMuseums')}</option>
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
{availableMuseums.map((m: string) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,9 +368,9 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
{slide.chartType === 'comparison' && (
|
||||
<div className="editor-section">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slide.showComparison}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slide.showComparison}
|
||||
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
||||
/>
|
||||
{t('slides.showYoY')}
|
||||
@@ -349,15 +387,15 @@ function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data, char
|
||||
}
|
||||
|
||||
// Static field mapping for charts (Chart.js labels don't need i18n)
|
||||
const METRIC_FIELDS = {
|
||||
const METRIC_FIELDS: Record<string, MetricFieldInfo> = {
|
||||
revenue: { field: 'revenue_incl_tax', label: 'Revenue' },
|
||||
visitors: { field: 'visits', label: 'Visitors' },
|
||||
tickets: { field: 'tickets', label: 'Tickets' }
|
||||
};
|
||||
|
||||
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
||||
function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }: SlidePreviewProps) {
|
||||
const { t } = useLanguage();
|
||||
const filteredData = useMemo(() =>
|
||||
const filteredData = useMemo(() =>
|
||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
museum: slide.museum
|
||||
@@ -368,22 +406,22 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
||||
const metricsData = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
return rows.reduce((s, r) => s + parseFloat(r[fieldMap[metric]] || 0), 0);
|
||||
const getMetricValue = useCallback((rows: MuseumRecord[], metric: string) => {
|
||||
const fieldMap: Record<string, string> = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
return rows.reduce((s: number, r: MuseumRecord) => s + parseFloat(String((r as unknown as Record<string, unknown>)[fieldMap[metric]] || 0)), 0);
|
||||
}, []);
|
||||
|
||||
const trendData = useMemo(() => {
|
||||
const grouped = {};
|
||||
const grouped: Record<string, MuseumRecord[]> = {};
|
||||
filteredData.forEach(row => {
|
||||
if (!row.date) return;
|
||||
const weekStart = row.date.substring(0, 10);
|
||||
if (!grouped[weekStart]) grouped[weekStart] = [];
|
||||
grouped[weekStart].push(row);
|
||||
});
|
||||
|
||||
|
||||
const sortedDates = Object.keys(grouped).sort();
|
||||
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
||||
const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
||||
return {
|
||||
labels: sortedDates.map(d => d.substring(5)),
|
||||
datasets: [{
|
||||
@@ -398,15 +436,15 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
||||
}, [filteredData, slide.metric, getMetricValue, metrics]);
|
||||
|
||||
const museumData = useMemo(() => {
|
||||
const byMuseum = {};
|
||||
const byMuseum: Record<string, MuseumRecord[]> = {};
|
||||
filteredData.forEach(row => {
|
||||
if (!row.museum_name) return;
|
||||
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
|
||||
byMuseum[row.museum_name].push(row);
|
||||
});
|
||||
|
||||
|
||||
const museums = Object.keys(byMuseum).sort();
|
||||
const metricLabel = metrics?.find(m => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
||||
const metricLabel = metrics?.find((m: MetricOption) => m.id === slide.metric)?.label || METRIC_FIELDS[slide.metric]?.label || slide.metric;
|
||||
return {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
@@ -452,13 +490,13 @@ function SlidePreview({ slide, data, districts, districtMuseumMap, metrics }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }) {
|
||||
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit, metrics }: PreviewModeProps) {
|
||||
const { t } = useLanguage();
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
|
||||
setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1));
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
setCurrentSlide(prev => Math.max(prev - 1, 0));
|
||||
setCurrentSlide((prev: number) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Escape') {
|
||||
onExit();
|
||||
}
|
||||
@@ -483,8 +521,8 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-controls">
|
||||
<button onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))} disabled={currentSlide === 0}>←</button>
|
||||
<button onClick={() => setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}>→</button>
|
||||
<button onClick={() => setCurrentSlide((prev: number) => Math.max(prev - 1, 0))} disabled={currentSlide === 0}>←</button>
|
||||
<button onClick={() => setCurrentSlide((prev: number) => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}>→</button>
|
||||
<button onClick={onExit}>{t('slides.exit')}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -492,10 +530,10 @@ function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide,
|
||||
}
|
||||
|
||||
// Helper functions for HTML export
|
||||
function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
|
||||
function generateSlideHTML(slide: SlideConfig, index: number, data: MuseumRecord[], districts: string[], districtMuseumMap: DistrictMuseumMap): string {
|
||||
const chartType = slide.chartType;
|
||||
const canvasId = `chart-${index}`;
|
||||
|
||||
|
||||
return `
|
||||
<div class="slide" id="slide-${index}">
|
||||
<h1 class="slide-title">${slide.title}</h1>
|
||||
@@ -510,13 +548,13 @@ function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function generateKPIHTML(slide, data) {
|
||||
function generateKPIHTML(slide: SlideConfig, data: MuseumRecord[]): string {
|
||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
museum: slide.museum
|
||||
});
|
||||
const metrics = calculateMetrics(filtered);
|
||||
|
||||
|
||||
return `
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card">
|
||||
@@ -534,40 +572,40 @@ function generateKPIHTML(slide, data) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function generateChartScripts(slides, data, districts, districtMuseumMap) {
|
||||
return slides.map((slide, index) => {
|
||||
function generateChartScripts(slides: SlideConfig[], data: MuseumRecord[], districts: string[], districtMuseumMap: DistrictMuseumMap): string {
|
||||
return slides.map((slide: SlideConfig, index: number) => {
|
||||
if (slide.chartType === 'kpi-cards') return '';
|
||||
|
||||
|
||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
museum: slide.museum
|
||||
});
|
||||
|
||||
|
||||
const chartConfig = generateChartConfig(slide, filtered);
|
||||
|
||||
|
||||
return `
|
||||
new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)});
|
||||
`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function generateChartConfig(slide, data) {
|
||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
function generateChartConfig(slide: SlideConfig, data: MuseumRecord[]): object {
|
||||
const fieldMap: Record<string, keyof MuseumRecord> = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[slide.metric];
|
||||
|
||||
|
||||
if (slide.chartType === 'museum-bar') {
|
||||
const byMuseum = {};
|
||||
data.forEach(row => {
|
||||
const byMuseum: Record<string, number> = {};
|
||||
data.forEach((row: MuseumRecord) => {
|
||||
if (!row.museum_name) return;
|
||||
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(row[field] || 0);
|
||||
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(String(row[field] || 0));
|
||||
});
|
||||
const museums = Object.keys(byMuseum).sort();
|
||||
|
||||
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
datasets: [{
|
||||
data: museums.map(m => byMuseum[m]),
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 6
|
||||
@@ -576,15 +614,15 @@ function generateChartConfig(slide, data) {
|
||||
options: { indexAxis: 'y', plugins: { legend: { display: false } } }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Default: trend line
|
||||
const grouped = {};
|
||||
data.forEach(row => {
|
||||
const grouped: Record<string, number> = {};
|
||||
data.forEach((row: MuseumRecord) => {
|
||||
if (!row.date) return;
|
||||
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(row[field] || 0);
|
||||
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(String(row[field] || 0));
|
||||
});
|
||||
const dates = Object.keys(grouped).sort();
|
||||
|
||||
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
|
||||
27
src/components/shared/LoadingSkeleton.tsx
Normal file
27
src/components/shared/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
function SkeletonCard({ wide = false }: { wide?: boolean }) {
|
||||
return (
|
||||
<div className={`skeleton-card ${wide ? 'skeleton-card-wide' : ''}`}>
|
||||
<div className="skeleton-line skeleton-line-short" />
|
||||
<div className="skeleton-line skeleton-line-tall" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="skeleton-container">
|
||||
<div className="skeleton-stats">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
<div className="skeleton-charts">
|
||||
<SkeletonCard wide />
|
||||
<SkeletonCard wide />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
borderRadius: 3,
|
||||
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
|
||||
formatter: (value) => {
|
||||
formatter: (value: number | null) => {
|
||||
if (value == null) return '';
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { useUrlState } from './useUrlState';
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Sync state with URL search params
|
||||
* @param {Object} state - Current state object
|
||||
* @param {Function} setState - State setter function
|
||||
* @param {Object} defaultState - Default state values
|
||||
* @param {Array<string>} keys - Keys to sync with URL
|
||||
*/
|
||||
export function useUrlState(state, setState, defaultState, keys) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Initialize state from URL on mount
|
||||
useEffect(() => {
|
||||
const urlState = {};
|
||||
let hasUrlParams = false;
|
||||
|
||||
keys.forEach(key => {
|
||||
const value = searchParams.get(key);
|
||||
if (value !== null) {
|
||||
urlState[key] = value;
|
||||
hasUrlParams = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasUrlParams) {
|
||||
setState(prev => ({ ...prev, ...urlState }));
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Update URL when state changes
|
||||
const updateUrl = useCallback((newState) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
keys.forEach(key => {
|
||||
const value = newState[key];
|
||||
if (value && value !== defaultState[key]) {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [keys, defaultState, setSearchParams]);
|
||||
|
||||
// Wrap setState to also update URL
|
||||
const setStateWithUrl = useCallback((updater) => {
|
||||
setState(prev => {
|
||||
const newState = typeof updater === 'function' ? updater(prev) : updater;
|
||||
updateUrl(newState);
|
||||
return newState;
|
||||
});
|
||||
}, [setState, updateUrl]);
|
||||
|
||||
return setStateWithUrl;
|
||||
}
|
||||
|
||||
export default useUrlState;
|
||||
@@ -149,6 +149,13 @@
|
||||
"district": "المنطقة",
|
||||
"captureRate": "نسبة الاستقطاب"
|
||||
},
|
||||
"errors": {
|
||||
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال NocoDB.",
|
||||
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
||||
"auth": "تم رفض الوصول. قد يكون رمز API غير صالح أو منتهي الصلاحية.",
|
||||
"timeout": "خادم قاعدة البيانات يستغرق وقتاً طويلاً للاستجابة. يرجى المحاولة مرة أخرى.",
|
||||
"unknown": "حدث خطأ أثناء تحميل البيانات. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"language": {
|
||||
"switch": "EN"
|
||||
},
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"district": "District",
|
||||
"captureRate": "Capture Rate"
|
||||
},
|
||||
"errors": {
|
||||
"config": "The dashboard is not configured. Please set up the NocoDB connection.",
|
||||
"network": "Cannot reach the database server. Please check your internet connection.",
|
||||
"auth": "Access denied. The API token may be invalid or expired.",
|
||||
"timeout": "The database server is taking too long to respond. Please try again.",
|
||||
"unknown": "Something went wrong while loading data. Please try again."
|
||||
},
|
||||
"language": {
|
||||
"switch": "عربي"
|
||||
},
|
||||
|
||||
@@ -14,26 +14,70 @@ import type {
|
||||
UmrahData,
|
||||
NocoDBDistrict,
|
||||
NocoDBMuseum,
|
||||
NocoDBDailyStat
|
||||
NocoDBDailyStat,
|
||||
DataErrorType
|
||||
} from '../types';
|
||||
import { DataError } from '../types';
|
||||
|
||||
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
|
||||
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
|
||||
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || '';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_RETRIES = 3;
|
||||
const VAT_RATE = 1.15;
|
||||
|
||||
// Table IDs discovered dynamically from NocoDB meta API
|
||||
let discoveredTables: Record<string, string> | null = null;
|
||||
|
||||
// ============================================
|
||||
// Fetch Helpers (timeout + retry)
|
||||
// ============================================
|
||||
|
||||
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = FETCH_TIMEOUT_MS): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { ...options, signal: controller.signal });
|
||||
return res;
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
throw new Error(`Request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, options: RequestInit = {}, retries: number = MAX_RETRIES): Promise<Response> {
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, options);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
if (attempt < retries - 1) {
|
||||
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
|
||||
console.warn(`Fetch attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function discoverTableIds(): Promise<Record<string, string>> {
|
||||
if (discoveredTables) return discoveredTables;
|
||||
|
||||
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured');
|
||||
|
||||
const res = await fetch(
|
||||
const res = await fetchWithRetry(
|
||||
`${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`,
|
||||
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to discover tables: HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
const tables: Record<string, string> = {};
|
||||
@@ -71,8 +115,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||
return umrahData;
|
||||
}
|
||||
const url = `${NOCODB_URL}/api/v2/tables/${tables['PilgrimStats']}/records?limit=50`;
|
||||
const res = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||
const json = await res.json();
|
||||
const records = json.list || [];
|
||||
|
||||
@@ -165,15 +208,13 @@ export function clearCache(): void {
|
||||
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
|
||||
let allRecords: T[] = [];
|
||||
let offset = 0;
|
||||
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
const response = await fetchWithRetry(
|
||||
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
|
||||
{ headers: { 'xc-token': NOCODB_TOKEN } }
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const json = await response.json();
|
||||
const records: T[] = json.list || [];
|
||||
allRecords = allRecords.concat(records);
|
||||
@@ -212,13 +253,13 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
|
||||
museumMap[m.Id] = {
|
||||
code: m.Code,
|
||||
name: m.Name,
|
||||
district: districtMap[m.DistrictId || m['nc_epk____Districts_id']] || 'Unknown'
|
||||
district: districtMap[m.DistrictId ?? m['nc_epk____Districts_id'] ?? 0] || 'Unknown'
|
||||
};
|
||||
});
|
||||
|
||||
// Join data into flat structure
|
||||
const data: MuseumRecord[] = dailyStats.map(row => {
|
||||
const museum = museumMap[row.MuseumId || row['nc_epk____Museums_id']] || { code: '', name: '', district: '' };
|
||||
const museum = museumMap[row.MuseumId ?? row['nc_epk____Museums_id'] ?? 0] || { code: '', name: '', district: '' };
|
||||
const date = row.Date;
|
||||
const year = date ? date.substring(0, 4) : '';
|
||||
const month = date ? parseInt(date.substring(5, 7)) : 0;
|
||||
@@ -226,7 +267,7 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
|
||||
|
||||
// GrossRevenue = including VAT, NetRevenue = excluding VAT
|
||||
const grossRevenue = row.GrossRevenue || 0;
|
||||
const netRevenue = row.NetRevenue || (grossRevenue / 1.15);
|
||||
const netRevenue = row.NetRevenue || (grossRevenue / VAT_RATE);
|
||||
|
||||
return {
|
||||
date: date,
|
||||
@@ -247,6 +288,19 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Error Classification
|
||||
// ============================================
|
||||
|
||||
function classifyError(err: Error): DataErrorType {
|
||||
const msg = err.message.toLowerCase();
|
||||
if (msg.includes('not configured')) return 'config';
|
||||
if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout';
|
||||
if (msg.includes('401') || msg.includes('403') || msg.includes('authentication') || msg.includes('unauthorized')) return 'auth';
|
||||
if (msg.includes('failed to fetch') || msg.includes('network') || msg.includes('econnrefused') || msg.includes('err_connection')) return 'network';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Data Fetcher (with offline fallback)
|
||||
// ============================================
|
||||
@@ -254,34 +308,29 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
|
||||
export async function fetchData(): Promise<FetchResult> {
|
||||
// Check if NocoDB is configured
|
||||
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
|
||||
// Try cache
|
||||
const cached = loadFromCache();
|
||||
if (cached) {
|
||||
console.warn('NocoDB not configured, using cached data');
|
||||
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
|
||||
}
|
||||
throw new Error('NocoDB not configured and no cached data available. Set VITE_NOCODB_URL, VITE_NOCODB_TOKEN, and VITE_NOCODB_BASE_ID in .env.local');
|
||||
throw new DataError('NocoDB not configured', 'config');
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Try to fetch fresh data
|
||||
const data = await fetchFromNocoDB();
|
||||
|
||||
// Save to cache on success
|
||||
saveToCache(data);
|
||||
|
||||
return { data, fromCache: false };
|
||||
} catch (err) {
|
||||
console.error('NocoDB fetch failed:', (err as Error).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 { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
|
||||
}
|
||||
|
||||
throw new Error(`Database unavailable and no cached data: ${(err as Error).message}`);
|
||||
|
||||
const errorType = classifyError(err as Error);
|
||||
throw new DataError((err as Error).message, errorType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
// Salla Integration Service
|
||||
// Connects to the local Salla backend server
|
||||
|
||||
const SALLA_SERVER_URL = import.meta.env.VITE_SALLA_SERVER_URL || 'http://localhost:3001';
|
||||
|
||||
export interface SallaAuthStatus {
|
||||
connected: boolean;
|
||||
hasRefreshToken: boolean;
|
||||
}
|
||||
|
||||
export interface SallaOrder {
|
||||
id: number;
|
||||
reference_id: string;
|
||||
status: {
|
||||
id: string;
|
||||
name: string;
|
||||
customized: { id: string; name: string };
|
||||
};
|
||||
amounts: {
|
||||
total: { amount: number; currency: string };
|
||||
sub_total: { amount: number; currency: string };
|
||||
};
|
||||
customer: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
};
|
||||
items: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
amounts: { total: { amount: number } };
|
||||
}>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SallaProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
price: { amount: number; currency: string };
|
||||
quantity: number;
|
||||
status: string;
|
||||
sold_quantity: number;
|
||||
}
|
||||
|
||||
export interface SallaSummary {
|
||||
orders: { total: number; recent: number };
|
||||
products: { total: number };
|
||||
revenue: { total: number; average_order: number; currency: string };
|
||||
}
|
||||
|
||||
export interface SallaStore {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
domain: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Functions
|
||||
// ============================================
|
||||
|
||||
export async function checkSallaAuth(): Promise<SallaAuthStatus> {
|
||||
try {
|
||||
const response = await fetch(`${SALLA_SERVER_URL}/auth/status`);
|
||||
return response.json();
|
||||
} catch (err) {
|
||||
return { connected: false, hasRefreshToken: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function getSallaLoginUrl(): string {
|
||||
return `${SALLA_SERVER_URL}/auth/login`;
|
||||
}
|
||||
|
||||
export async function getSallaStore(): Promise<SallaStore | null> {
|
||||
try {
|
||||
const response = await fetch(`${SALLA_SERVER_URL}/api/store`);
|
||||
if (!response.ok) throw new Error('Failed to fetch store');
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching store:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSallaOrders(page = 1, perPage = 50): Promise<{ data: SallaOrder[]; pagination: any }> {
|
||||
try {
|
||||
const response = await fetch(`${SALLA_SERVER_URL}/api/orders?page=${page}&per_page=${perPage}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch orders');
|
||||
return response.json();
|
||||
} catch (err) {
|
||||
console.error('Error fetching orders:', err);
|
||||
return { data: [], pagination: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSallaProducts(page = 1, perPage = 50): Promise<{ data: SallaProduct[]; pagination: any }> {
|
||||
try {
|
||||
const response = await fetch(`${SALLA_SERVER_URL}/api/products?page=${page}&per_page=${perPage}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch products');
|
||||
return response.json();
|
||||
} catch (err) {
|
||||
console.error('Error fetching products:', err);
|
||||
return { data: [], pagination: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSallaSummary(): Promise<SallaSummary | null> {
|
||||
try {
|
||||
const response = await fetch(`${SALLA_SERVER_URL}/api/analytics/summary`);
|
||||
if (!response.ok) throw new Error('Failed to fetch summary');
|
||||
return response.json();
|
||||
} catch (err) {
|
||||
console.error('Error fetching summary:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Data Transformation for Dashboard
|
||||
// ============================================
|
||||
|
||||
export function transformOrdersForChart(orders: SallaOrder[]): {
|
||||
labels: string[];
|
||||
datasets: { label: string; data: number[] }[];
|
||||
} {
|
||||
// Group orders by date
|
||||
const byDate: Record<string, number> = {};
|
||||
|
||||
orders.forEach(order => {
|
||||
const date = order.created_at.split('T')[0];
|
||||
byDate[date] = (byDate[date] || 0) + (order.amounts?.total?.amount || 0);
|
||||
});
|
||||
|
||||
const sortedDates = Object.keys(byDate).sort();
|
||||
|
||||
return {
|
||||
labels: sortedDates,
|
||||
datasets: [{
|
||||
label: 'Daily Revenue (SAR)',
|
||||
data: sortedDates.map(d => byDate[d])
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrderStatusSummary(orders: SallaOrder[]): Record<string, number> {
|
||||
const byStatus: Record<string, number> = {};
|
||||
|
||||
orders.forEach(order => {
|
||||
const status = order.status?.name || 'Unknown';
|
||||
byStatus[status] = (byStatus[status] || 0) + 1;
|
||||
});
|
||||
|
||||
return byStatus;
|
||||
}
|
||||
@@ -53,6 +53,16 @@ export interface FetchResult {
|
||||
cacheTimestamp?: number;
|
||||
}
|
||||
|
||||
export type DataErrorType = 'config' | 'network' | 'auth' | 'timeout' | 'unknown';
|
||||
|
||||
export class DataError extends Error {
|
||||
type: DataErrorType;
|
||||
constructor(message: string, type: DataErrorType) {
|
||||
super(message);
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GroupedData {
|
||||
revenue: number;
|
||||
visitors: number;
|
||||
@@ -164,5 +174,35 @@ export interface NocoDBDailyStat {
|
||||
'nc_epk____Museums_id'?: number;
|
||||
}
|
||||
|
||||
// Slide types
|
||||
export interface SlideConfig {
|
||||
id: number;
|
||||
title: string;
|
||||
chartType: string;
|
||||
metric: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
district: string;
|
||||
museum: string;
|
||||
showComparison: boolean;
|
||||
}
|
||||
|
||||
export interface ChartTypeOption {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface MetricOption {
|
||||
id: string;
|
||||
label: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface MetricFieldInfo {
|
||||
field: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Translation function type
|
||||
export type TranslateFunction = (key: string) => string;
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": false,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "ESNext",
|
||||
|
||||
Reference in New Issue
Block a user