Compare commits

...

3 Commits

Author SHA1 Message Date
fahed
db6a6ac609 feat: season filter + chart bands on Dashboard and Comparison
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Dashboard:
- Season dropdown filter (filters data by season date range)
- Revenue trend chart shows colored annotation bands for each season
- All downstream memos use season-filtered data

Comparison:
- Season presets in period selector (optgroup)
- Auto-compares with same season from previous hijri year if defined
- Season preset persists start/end dates in URL

Added chartjs-plugin-annotation for chart bands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:10:49 +03:00
fahed
ef48372033 feat: add Settings page with hijri seasons CRUD
- Server: seasons CRUD routes + generic NocoDB helpers
- Client: Settings page at /settings with inline add/edit/delete
- Seasons stored in NocoDB Seasons table
- Vite proxy: /api/seasons routed to Express server
- Nav links added (desktop + mobile)
- Locale keys for EN + AR
- Seasons loaded non-blocking on app mount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:03:50 +03:00
fahed
1dd216f933 docs: add hijri seasons feature design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:54:52 +03:00
17 changed files with 730 additions and 36 deletions

View File

@@ -0,0 +1,118 @@
# Hijri Seasons Feature
## Goal
Add configurable hijri seasons (Ramadan, Hajj, etc.) to the dashboard as a presentation overlay. Seasons are user-defined with Gregorian date ranges (since hijri months shift ~11 days each year). They appear as filter presets, chart bands, and are managed through a settings page.
## Data Storage
New NocoDB `Seasons` table:
| Column | Type | Example |
|--------|------|---------|
| Name | string | `Ramadan` |
| HijriYear | number | `1446` |
| StartDate | string | `2025-02-28` |
| EndDate | string | `2025-03-30` |
| Color | string | `#10b981` |
Read on dashboard load alongside PilgrimStats. Written via server proxy to keep NocoDB credentials server-side.
**Loading lifecycle:** Seasons load independently of the main data fetch. A failure to load seasons degrades gracefully — seasons state defaults to `[]`, the dashboard renders normally without bands or season presets. Seasons are non-blocking and non-critical.
## Server Changes
### New files
| File | Responsibility |
|------|----------------|
| `server/src/routes/seasons.ts` | `GET /api/seasons` (read all), `POST /api/seasons` (create), `PUT /api/seasons/:id` (update), `DELETE /api/seasons/:id` (delete) |
### Modified files
| File | Change |
|------|--------|
| `server/src/index.ts` | Mount seasons routes at `/api/seasons` |
| `server/src/services/nocodbClient.ts` | Add generic CRUD helpers typed as `<T extends Record<string, unknown>>` so both ETL and seasons routes can share them without coupling |
| `vite.config.ts` | Add `/api/seasons` proxy rule **before** the catch-all `/api` rule (same pattern as `/api/erp`). Order: `/api/erp``/api/etl``/api/seasons``/api` |
## Client Changes
### New files
| File | Responsibility |
|------|----------------|
| `src/components/Settings.tsx` | Settings page with seasons CRUD table |
| `src/services/seasonsService.ts` | Fetch/create/update/delete seasons via server proxy |
### Modified files
| File | Change |
|------|--------|
| `src/types/index.ts` | Add `Season` interface |
| `src/App.tsx` | Add `/settings` route, nav link (both desktop and mobile bottom nav), load seasons on mount (non-blocking) |
| `src/components/Dashboard.tsx` | Add season filter dropdown, chart annotation bands |
| `src/components/Comparison.tsx` | Add season filter as period preset |
| `src/config/chartConfig.ts` | Import and register `chartjs-plugin-annotation` in the central `ChartJS.register()` call |
| `src/locales/en.json` | Settings page labels, season filter labels |
| `src/locales/ar.json` | Arabic translations |
| `package.json` | Add `chartjs-plugin-annotation` dependency |
## Season Interface
```typescript
export interface Season {
Id?: number;
Name: string;
HijriYear: number;
StartDate: string;
EndDate: string;
Color: string;
}
```
## Settings Page (`/settings`)
New route accessible from the nav bar (gear icon on desktop, gear in mobile bottom nav). Contains:
- **Seasons table**: lists all defined seasons with columns: Name, Hijri Year, Start Date, End Date, Color, Actions (edit/delete)
- **Add season form**: inline row at the bottom of the table with inputs for each field + color picker + save button
- **Edit**: click a row to edit inline
- **Delete**: delete button per row with confirmation
- **No empty state needed**: just show the empty table with the add form
## Period Filter Integration
### Dashboard
Add a "Season" select in the filters section (after Quarter). Options populated from the loaded seasons list:
- `All Seasons` (default — no date filtering from season)
- `Ramadan 1446 (Feb 28 Mar 30, 2025)`
- `Hajj 1446 (Jun 4 Jun 9, 2025)`
- etc.
Selecting a season sets a date range filter on the data — equivalent to filtering by start/end date. This works alongside existing year/district/channel/event filters.
Implementation: when a season is selected, filter data to `row.date >= season.StartDate && row.date <= season.EndDate`. Store the selected season ID in state (not URL params — seasons are dynamic).
### Comparison
Seasons appear as preset period options alongside months/quarters. Selecting "Ramadan 1446" sets the period dates and auto-compares with the same season name in the previous hijri year if defined (e.g. "Ramadan 1445").
## Chart Bands (Revenue Trend)
Uses `chartjs-plugin-annotation` to draw semi-transparent vertical bands on the revenue trend chart. Must be registered in `chartConfig.ts` via `ChartJS.register(Annotation)`.
For each season whose date range overlaps the chart's visible range:
- Draw a vertical box from `season.StartDate` to `season.EndDate`
- Fill with `season.Color` at 15% opacity
- Label at the top with season name + hijri year
Only the revenue trend chart gets bands (it's the only time-series chart where seasons make visual sense).
## What's NOT Changing
- ETL pipeline unchanged — seasons are a UI/presentation concern
- NocoDB DailySales schema unchanged
- All existing filters (year, district, channel, event, quarter) unchanged
- Seasons don't affect data aggregation or storage

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
@@ -1571,6 +1572,15 @@
"pnpm": ">=8" "pnpm": ">=8"
} }
}, },
"node_modules/chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=4.0.0"
}
},
"node_modules/chartjs-plugin-datalabels": { "node_modules/chartjs-plugin-datalabels": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",

View File

@@ -8,6 +8,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",

View File

@@ -3,6 +3,7 @@ import cors from 'cors';
import { server, erp, nocodb } from './config'; import { server, erp, nocodb } from './config';
import erpRoutes from './routes/erp'; import erpRoutes from './routes/erp';
import etlRoutes from './routes/etl'; import etlRoutes from './routes/etl';
import seasonsRoutes from './routes/seasons';
const app = express(); const app = express();
app.use(cors()); app.use(cors());
@@ -11,6 +12,7 @@ app.use(express.json());
// Mount routes // Mount routes
app.use('/api/erp', erpRoutes); app.use('/api/erp', erpRoutes);
app.use('/api/etl', etlRoutes); app.use('/api/etl', etlRoutes);
app.use('/api/seasons', seasonsRoutes);
app.listen(server.port, () => { app.listen(server.port, () => {
console.log(`\nServer running on http://localhost:${server.port}`); console.log(`\nServer running on http://localhost:${server.port}`);

View File

@@ -0,0 +1,63 @@
import { Router, Request, Response } from 'express';
import { discoverTableIds, fetchAllRecords, createRecord, updateRecord, deleteRecord } from '../services/nocodbClient';
const router = Router();
async function getSeasonsTableId(): Promise<string> {
const tables = await discoverTableIds();
const id = tables['Seasons'];
if (!id) throw new Error("NocoDB table 'Seasons' not found");
return id;
}
// GET /api/seasons
router.get('/', async (_req: Request, res: Response) => {
try {
const tableId = await getSeasonsTableId();
const records = await fetchAllRecords(tableId);
res.json(records);
} catch (err) {
console.error('Failed to fetch seasons:', (err as Error).message);
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/seasons
router.post('/', async (req: Request, res: Response) => {
try {
const tableId = await getSeasonsTableId();
const result = await createRecord(tableId, req.body);
res.json(result);
} catch (err) {
console.error('Failed to create season:', (err as Error).message);
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/seasons/:id
router.put('/:id', async (req: Request, res: Response) => {
try {
const tableId = await getSeasonsTableId();
const id = parseInt(req.params.id);
const result = await updateRecord(tableId, id, req.body);
res.json(result);
} catch (err) {
console.error('Failed to update season:', (err as Error).message);
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/seasons/:id
router.delete('/:id', async (req: Request, res: Response) => {
try {
const tableId = await getSeasonsTableId();
const id = parseInt(req.params.id);
await deleteRecord(tableId, id);
res.json({ ok: true });
} catch (err) {
console.error('Failed to delete season:', (err as Error).message);
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@@ -1,5 +1,4 @@
import { nocodb } from '../config'; import { nocodb } from '../config';
import type { AggregatedRecord } from '../types';
let discoveredTables: Record<string, string> | null = null; let discoveredTables: Record<string, string> | null = null;
@@ -91,8 +90,7 @@ export async function deleteAllRows(tableId: string): Promise<number> {
return deleted; return deleted;
} }
export async function insertRecords(tableId: string, records: AggregatedRecord[]): Promise<number> { export async function insertRecords<T extends Record<string, unknown>>(tableId: string, records: T[]): Promise<number> {
// NocoDB bulk insert accepts max 100 records at a time
const batchSize = 100; const batchSize = 100;
let inserted = 0; let inserted = 0;
@@ -107,3 +105,43 @@ export async function insertRecords(tableId: string, records: AggregatedRecord[]
return inserted; return inserted;
} }
// Generic CRUD helpers
export async function fetchAllRecords<T>(tableId: string): Promise<T[]> {
let all: T[] = [];
let offset = 0;
while (true) {
const json = await fetchJson(
`${nocodb.url}/api/v2/tables/${tableId}/records?limit=1000&offset=${offset}`
) as { list: T[] };
all = all.concat(json.list);
if (json.list.length < 1000) break;
offset += 1000;
}
return all;
}
export async function createRecord<T extends Record<string, unknown>>(tableId: string, record: T): Promise<T> {
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'POST',
body: JSON.stringify(record),
}) as T;
}
export async function updateRecord<T extends Record<string, unknown>>(tableId: string, id: number, record: T): Promise<T> {
return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'PATCH',
body: JSON.stringify({ Id: id, ...record }),
}) as T;
}
export async function deleteRecord(tableId: string, id: number): Promise<void> {
await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, {
method: 'DELETE',
body: JSON.stringify([{ Id: id }]),
});
}

View File

@@ -851,6 +851,102 @@ table tbody tr:hover {
accent-color: var(--accent); accent-color: var(--accent);
} }
/* Settings page */
.settings-page {
padding: 24px;
max-width: 900px;
}
.settings-hint {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 16px;
}
.season-chip {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 600;
border: 1px solid;
}
.season-edit-name {
display: flex;
gap: 6px;
align-items: center;
}
.season-edit-name input[type="text"] {
flex: 1;
min-width: 80px;
}
.season-edit-name input[type="color"] {
width: 32px;
height: 32px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.season-actions {
display: flex;
gap: 6px;
}
.btn-small {
padding: 4px 10px;
font-size: 0.75rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-primary);
cursor: pointer;
}
.btn-small:hover {
background: var(--hover);
}
.btn-small.btn-primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.btn-small.btn-danger {
color: var(--danger, #dc2626);
border-color: var(--danger, #dc2626);
}
.btn-small.btn-danger:hover {
background: var(--danger, #dc2626);
color: white;
}
tr.add-row td {
border-top: 2px dashed var(--border);
padding-top: 12px;
}
tr.editing td {
background: var(--hover);
}
.settings-page input[type="text"],
.settings-page input[type="number"],
.settings-page input[type="date"] {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8125rem;
background: var(--surface);
color: var(--text-primary);
}
.period-display { .period-display {
background: var(--bg); background: var(--bg);
padding: 16px; padding: 16px;

View File

@@ -3,10 +3,12 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
const Dashboard = lazy(() => import('./components/Dashboard')); const Dashboard = lazy(() => import('./components/Dashboard'));
const Comparison = lazy(() => import('./components/Comparison')); const Comparison = lazy(() => import('./components/Comparison'));
const Settings = lazy(() => import('./components/Settings'));
import LoadingSkeleton from './components/shared/LoadingSkeleton'; import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData } from './services/dataService'; import { fetchData, getCacheStatus, refreshData } from './services/dataService';
import { fetchSeasons } from './services/seasonsService';
import { useLanguage } from './contexts/LanguageContext'; import { useLanguage } from './contexts/LanguageContext';
import type { MuseumRecord, CacheStatus, DataErrorType } from './types'; import type { MuseumRecord, Season, CacheStatus, DataErrorType } from './types';
import { DataError } from './types'; import { DataError } from './types';
import './App.css'; import './App.css';
@@ -43,6 +45,7 @@ function App() {
const [showDataLabels, setShowDataLabels] = useState<boolean>(false); const [showDataLabels, setShowDataLabels] = useState<boolean>(false);
const [includeVAT, setIncludeVAT] = useState<boolean>(true); const [includeVAT, setIncludeVAT] = useState<boolean>(true);
const [dataSource, setDataSource] = useState<string>('museums'); const [dataSource, setDataSource] = useState<string>('museums');
const [seasons, setSeasons] = useState<Season[]>([]);
const [theme, setTheme] = useState<string>(() => { const [theme, setTheme] = useState<string>(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('hihala_theme') || 'light'; return localStorage.getItem('hihala_theme') || 'light';
@@ -97,8 +100,14 @@ function App() {
} }
}, []); }, []);
const loadSeasons = useCallback(async () => {
const s = await fetchSeasons();
setSeasons(s);
}, []);
useEffect(() => { useEffect(() => {
loadData(); loadData();
loadSeasons();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -237,8 +246,9 @@ function App() {
<main> <main>
<Suspense fallback={<LoadingSkeleton />}> <Suspense fallback={<LoadingSkeleton />}>
<Routes> <Routes>
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} /> <Route path="/" element={<Dashboard data={data} seasons={seasons} 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="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} />} />
</Routes> </Routes>
</Suspense> </Suspense>
</main> </main>
@@ -262,6 +272,12 @@ function App() {
</svg> </svg>
<span>{t('nav.compare')}</span> <span>{t('nav.compare')}</span>
</NavLink> </NavLink>
<NavLink to="/settings" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>{t('nav.settings')}</span>
</NavLink>
<button <button
className="mobile-nav-item" className="mobile-nav-item"
onClick={switchLanguage} onClick={switchLanguage}

View File

@@ -17,7 +17,7 @@ import {
getMuseumsForDistrict, getMuseumsForDistrict,
getLatestYear getLatestYear
} from '../services/dataService'; } from '../services/dataService';
import type { MuseumRecord, ComparisonProps, DateRangeFilters } from '../types'; import type { MuseumRecord, ComparisonProps, DateRangeFilters, Season } from '../types';
interface PresetDateRange { interface PresetDateRange {
start: string; start: string;
@@ -63,7 +63,7 @@ const generatePresetDates = (year: number): PresetDates => ({
'full': { start: `${year}-01-01`, end: `${year}-12-31` } 'full': { start: `${year}-01-01`, end: `${year}-12-31` }
}); });
function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) { function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -95,7 +95,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) { if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
return dates[urlPreset].start; return dates[urlPreset].start;
} }
return searchParams.get('from') || `${year}-01-01`; // Season presets store from/to in URL
const fromParam = searchParams.get('from');
if (fromParam) return fromParam;
return `${year}-01-01`;
}); });
const [endDate, setEndDateState] = useState(() => { const [endDate, setEndDateState] = useState(() => {
const urlPreset = searchParams.get('preset'); const urlPreset = searchParams.get('preset');
@@ -105,7 +108,10 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) { if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
return dates[urlPreset].end; return dates[urlPreset].end;
} }
return searchParams.get('to') || `${year}-01-31`; // Season presets store from/to in URL
const toParam = searchParams.get('to');
if (toParam) return toParam;
return `${year}-01-31`;
}); });
const [filters, setFiltersState] = useState(() => ({ const [filters, setFiltersState] = useState(() => ({
district: searchParams.get('district') || 'all', district: searchParams.get('district') || 'all',
@@ -123,7 +129,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const params = new URLSearchParams(); const params = new URLSearchParams();
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset); if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
if (newYear && newYear !== latestYear) params.set('year', newYear.toString()); if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
if (newPreset === 'custom') { if (newPreset === 'custom' || newPreset.startsWith('season-')) {
if (newFrom) params.set('from', newFrom); if (newFrom) params.set('from', newFrom);
if (newTo) params.set('to', newTo); if (newTo) params.set('to', newTo);
} }
@@ -136,7 +142,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const setSelectedYear = (year: number) => { const setSelectedYear = (year: number) => {
setSelectedYearState(year); setSelectedYearState(year);
const newDates = generatePresetDates(year); const newDates = generatePresetDates(year);
if (preset !== 'custom' && newDates[preset]) { if (preset !== 'custom' && !preset.startsWith('season-') && newDates[preset]) {
setStartDateState(newDates[preset].start); setStartDateState(newDates[preset].start);
setEndDateState(newDates[preset].end); setEndDateState(newDates[preset].end);
} }
@@ -145,7 +151,15 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const setPreset = (value: string) => { const setPreset = (value: string) => {
setPresetState(value); setPresetState(value);
if (value !== 'custom' && presetDates[value]) { if (value.startsWith('season-')) {
const seasonId = parseInt(value.replace('season-', ''));
const season = seasons.find(s => s.Id === seasonId);
if (season) {
setStartDateState(season.StartDate);
setEndDateState(season.EndDate);
updateUrl(value, season.StartDate, season.EndDate, filters, selectedYear);
}
} else if (value !== 'custom' && presetDates[value]) {
setStartDateState(presetDates[value].start); setStartDateState(presetDates[value].start);
setEndDateState(presetDates[value].end); setEndDateState(presetDates[value].end);
updateUrl(value, null, null, filters, selectedYear); updateUrl(value, null, null, filters, selectedYear);
@@ -227,13 +241,29 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]); const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]);
// Year-over-year comparison: same dates, previous year // Year-over-year comparison: same dates, previous year
const ranges = useMemo(() => ({ // For season presets, try to find the same season name from the previous hijri year
curr: { start: startDate, end: endDate }, const ranges = useMemo(() => {
prev: { const curr = { start: startDate, end: endDate };
let prev = {
start: startDate.replace(/^(\d{4})/, (_: string, y: string) => String(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)) end: endDate.replace(/^(\d{4})/, (_: string, y: string) => String(parseInt(y) - 1))
};
if (preset.startsWith('season-')) {
const seasonId = parseInt(preset.replace('season-', ''));
const currentSeason = seasons.find(s => s.Id === seasonId);
if (currentSeason) {
const prevSeason = seasons.find(
s => s.Name === currentSeason.Name && s.HijriYear === currentSeason.HijriYear - 1
);
if (prevSeason) {
prev = { start: prevSeason.StartDate, end: prevSeason.EndDate };
}
}
} }
}), [startDate, endDate]);
return { curr, prev };
}, [startDate, endDate, preset, seasons]);
const prevData = useMemo(() => const prevData = useMemo(() =>
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters), filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
@@ -559,9 +589,18 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
<option value="h1">{t('time.h1')}</option> <option value="h1">{t('time.h1')}</option>
<option value="h2">{t('time.h2')}</option> <option value="h2">{t('time.h2')}</option>
<option value="full">{t('time.fullYear')}</option> <option value="full">{t('time.fullYear')}</option>
{seasons.length > 0 && (
<optgroup label={t('comparison.seasons') || 'Seasons'}>
{seasons.map(s => (
<option key={s.Id} value={`season-${s.Id}`}>
{s.Name} {s.HijriYear}
</option>
))}
</optgroup>
)}
</select> </select>
</FilterControls.Group> </FilterControls.Group>
{preset !== 'custom' && ( {preset !== 'custom' && !preset.startsWith('season-') && (
<FilterControls.Group label={t('filters.year')}> <FilterControls.Group label={t('filters.year')}>
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}> <select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
{availableYears.map(y => ( {availableYears.map(y => (
@@ -570,7 +609,7 @@ function Comparison({ data, showDataLabels, setShowDataLabels, includeVAT, setIn
</select> </select>
</FilterControls.Group> </FilterControls.Group>
)} )}
{preset === 'custom' && ( {(preset === 'custom' || preset.startsWith('season-')) && (
<> <>
<FilterControls.Group label={t('comparison.from')}> <FilterControls.Group label={t('comparison.from')}>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} /> <input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />

View File

@@ -22,7 +22,7 @@ import {
getMuseumsForDistrict, getMuseumsForDistrict,
groupByDistrict groupByDistrict
} from '../services/dataService'; } from '../services/dataService';
import type { DashboardProps, Filters, MuseumRecord } from '../types'; import type { DashboardProps, Filters, MuseumRecord, Season } from '../types';
const defaultFilters: Filters = { const defaultFilters: Filters = {
year: 'all', year: 'all',
@@ -34,7 +34,7 @@ const defaultFilters: Filters = {
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter']; const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) { function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [pilgrimLoaded, setPilgrimLoaded] = useState(false); const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
@@ -78,12 +78,24 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const [activeStatCard, setActiveStatCard] = useState(0); const [activeStatCard, setActiveStatCard] = useState(0);
const [activeChart, setActiveChart] = useState(0); const [activeChart, setActiveChart] = useState(0);
const [trendGranularity, setTrendGranularity] = useState('week'); const [trendGranularity, setTrendGranularity] = useState('week');
const [selectedSeason, setSelectedSeason] = useState<string>('');
const filteredData = useMemo(() => filterData(data, filters), [data, filters]); const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
const metrics = useMemo(() => calculateMetrics(filteredData, includeVAT), [filteredData, includeVAT]);
const hasData = filteredData.length > 0;
const resetFilters = () => setFilters(defaultFilters); const seasonFilteredData = useMemo(() => {
if (!selectedSeason) return filteredData;
const season = seasons.find(s => String(s.Id) === selectedSeason);
if (!season) return filteredData;
return filteredData.filter(row => row.date >= season.StartDate && row.date <= season.EndDate);
}, [filteredData, selectedSeason, seasons]);
const metrics = useMemo(() => calculateMetrics(seasonFilteredData, includeVAT), [seasonFilteredData, includeVAT]);
const hasData = seasonFilteredData.length > 0;
const resetFilters = () => {
setFilters(defaultFilters);
setSelectedSeason('');
};
// Stat cards for carousel // Stat cards for carousel
const statCards = useMemo(() => [ const statCards = useMemo(() => [
@@ -153,11 +165,12 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}); });
if (trendGranularity === 'week') { if (trendGranularity === 'week') {
const grouped = groupByWeek(filteredData, includeVAT); const grouped = groupByWeek(seasonFilteredData, includeVAT);
const weeks = Object.keys(grouped).filter(w => w).sort(); const weeks = Object.keys(grouped).filter(w => w).sort();
const revenueValues = weeks.map(w => grouped[w].revenue); const revenueValues = weeks.map(w => grouped[w].revenue);
return { return {
labels: weeks.map(formatLabel), labels: weeks.map(formatLabel),
rawDates: weeks,
datasets: [{ datasets: [{
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: revenueValues, data: revenueValues,
@@ -173,7 +186,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
} else { } else {
// Daily granularity // Daily granularity
const dailyData: Record<string, number> = {}; const dailyData: Record<string, number> = {};
filteredData.forEach(row => { seasonFilteredData.forEach(row => {
const date = row.date; const date = row.date;
if (!dailyData[date]) dailyData[date] = 0; if (!dailyData[date]) dailyData[date] = 0;
dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0); dailyData[date] += Number((row as unknown as Record<string, unknown>)[revenueField] || 0);
@@ -182,6 +195,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const revenueValues = days.map(d => dailyData[d]); const revenueValues = days.map(d => dailyData[d]);
return { return {
labels: days.map(formatLabel), labels: days.map(formatLabel),
rawDates: days,
datasets: [{ datasets: [{
label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)', label: includeVAT ? 'Revenue (incl. VAT)' : 'Revenue (excl. VAT)',
data: revenueValues, data: revenueValues,
@@ -195,11 +209,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}, trendlineDataset(revenueValues)] }, trendlineDataset(revenueValues)]
}; };
} }
}, [filteredData, trendGranularity, includeVAT]); }, [seasonFilteredData, trendGranularity, includeVAT]);
// Museum data // Museum data
const museumData = useMemo(() => { const museumData = useMemo(() => {
const grouped = groupByMuseum(filteredData, includeVAT); const grouped = groupByMuseum(seasonFilteredData, includeVAT);
const museums = Object.keys(grouped); const museums = Object.keys(grouped);
return { return {
visitors: { visitors: {
@@ -220,11 +234,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
}] }]
} }
}; };
}, [filteredData, includeVAT]); }, [seasonFilteredData, includeVAT]);
// Channel data // Channel data
const channelData = useMemo(() => { const channelData = useMemo(() => {
const grouped = groupByChannel(filteredData, includeVAT); const grouped = groupByChannel(seasonFilteredData, includeVAT);
const channels = Object.keys(grouped); const channels = Object.keys(grouped);
return { return {
labels: channels, labels: channels,
@@ -234,11 +248,11 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
borderRadius: 4 borderRadius: 4
}] }]
}; };
}, [filteredData, includeVAT]); }, [seasonFilteredData, includeVAT]);
// District data // District data
const districtData = useMemo(() => { const districtData = useMemo(() => {
const grouped = groupByDistrict(filteredData, includeVAT); const grouped = groupByDistrict(seasonFilteredData, includeVAT);
const districtNames = Object.keys(grouped); const districtNames = Object.keys(grouped);
return { return {
labels: districtNames, labels: districtNames,
@@ -248,7 +262,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
borderRadius: 4 borderRadius: 4
}] }]
}; };
}, [filteredData, includeVAT]); }, [seasonFilteredData, includeVAT]);
// Quarterly YoY // Quarterly YoY
const quarterlyYoYData = useMemo(() => { const quarterlyYoYData = useMemo(() => {
@@ -386,6 +400,35 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]); const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
// Season annotation bands for revenue trend chart
const seasonAnnotations = useMemo(() => {
const raw = trendData.rawDates;
if (!seasons.length || !raw?.length) return {};
const annotations: Record<string, unknown> = {};
seasons.forEach((s, i) => {
const startIdx = raw.findIndex(d => d >= s.StartDate);
const endIdx = raw.length - 1 - [...raw].reverse().findIndex(d => d <= s.EndDate);
if (startIdx === -1 || endIdx < startIdx) return;
annotations[`season${i}`] = {
type: 'box',
xMin: startIdx - 0.5,
xMax: endIdx + 0.5,
backgroundColor: s.Color + '20',
borderColor: s.Color + '40',
borderWidth: 1,
label: {
display: true,
content: `${s.Name} ${s.HijriYear}`,
position: 'start',
color: s.Color,
font: { size: 10, weight: '600' },
padding: 4
}
};
});
return annotations;
}, [seasons, trendData.rawDates]);
return ( return (
<div className="dashboard" id="dashboard-container"> <div className="dashboard" id="dashboard-container">
<div className="page-title-with-actions"> <div className="page-title-with-actions">
@@ -450,6 +493,16 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<option value="4">{t('time.q4')}</option> <option value="4">{t('time.q4')}</option>
</select> </select>
</FilterControls.Group> </FilterControls.Group>
<FilterControls.Group label={t('filters.season')}>
<select value={selectedSeason} onChange={e => setSelectedSeason(e.target.value)}>
<option value="">{t('filters.allSeasons')}</option>
{seasons.map(s => (
<option key={s.Id} value={String(s.Id)}>
{s.Name} {s.HijriYear}
</option>
))}
</select>
</FilterControls.Group>
</FilterControls.Row> </FilterControls.Row>
</FilterControls> </FilterControls>
@@ -543,7 +596,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
</div> </div>
} }
> >
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} /> <Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 13}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
</ExportableChart> </ExportableChart>
</div> </div>
@@ -636,7 +689,7 @@ function Dashboard({ data, showDataLabels, setShowDataLabels, includeVAT, setInc
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button> <button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>{t('time.weekly')}</button>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} /> <Line data={trendData} options={{...baseOptions, plugins: {...baseOptions.plugins, annotation: { annotations: seasonAnnotations }, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 10, padding: 8, font: {size: 12}}}}, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: 8}}}}} />
</div> </div>
</div> </div>
</div> </div>

169
src/components/Settings.tsx Normal file
View File

@@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService';
import type { Season } from '../types';
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
interface SeasonRowProps {
season: Season;
onSave: (id: number, data: Partial<Season>) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}
function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) {
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(season);
const handleSave = async () => {
await onSave(season.Id!, form);
setEditing(false);
};
if (!editing) {
return (
<tr>
<td>
<span className="season-chip" style={{ backgroundColor: season.Color + '20', color: season.Color, borderColor: season.Color }}>
{season.Name} {season.HijriYear}
</span>
</td>
<td>{season.StartDate}</td>
<td>{season.EndDate}</td>
<td>
<div className="season-actions">
<button className="btn-small" onClick={() => setEditing(true)}>Edit</button>
<button className="btn-small btn-danger" onClick={() => onDelete(season.Id!)}>Delete</button>
</div>
</td>
</tr>
);
}
return (
<tr className="editing">
<td>
<div className="season-edit-name">
<input type="text" value={form.Name} onChange={e => setForm({ ...form, Name: e.target.value })} placeholder="Name" />
<input type="number" value={form.HijriYear} onChange={e => setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} />
<input type="color" value={form.Color} onChange={e => setForm({ ...form, Color: e.target.value })} />
</div>
</td>
<td><input type="date" value={form.StartDate} onChange={e => setForm({ ...form, StartDate: e.target.value })} /></td>
<td><input type="date" value={form.EndDate} onChange={e => setForm({ ...form, EndDate: e.target.value })} /></td>
<td>
<div className="season-actions">
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
</div>
</td>
</tr>
);
}
interface SettingsProps {
onSeasonsChange: () => void;
}
function Settings({ onSeasonsChange }: SettingsProps) {
const { t } = useLanguage();
const [seasons, setSeasons] = useState<Season[]>([]);
const [loading, setLoading] = useState(true);
const [newSeason, setNewSeason] = useState<Omit<Season, 'Id'>>({
Name: '',
HijriYear: new Date().getFullYear() - 579, // rough Gregorian → Hijri
StartDate: '',
EndDate: '',
Color: DEFAULT_COLORS[0],
});
const loadSeasons = async () => {
setLoading(true);
const data = await fetchSeasons();
setSeasons(data);
setLoading(false);
};
useEffect(() => { loadSeasons(); }, []);
const handleCreate = async () => {
if (!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate) return;
await createSeason(newSeason);
setNewSeason({
Name: '',
HijriYear: newSeason.HijriYear,
StartDate: '',
EndDate: '',
Color: DEFAULT_COLORS[(seasons.length + 1) % DEFAULT_COLORS.length],
});
await loadSeasons();
onSeasonsChange();
};
const handleSave = async (id: number, data: Partial<Season>) => {
await updateSeason(id, data);
await loadSeasons();
onSeasonsChange();
};
const handleDelete = async (id: number) => {
await deleteSeason(id);
await loadSeasons();
onSeasonsChange();
};
return (
<div className="settings-page">
<div className="page-title">
<h1>{t('settings.title')}</h1>
<p>{t('settings.subtitle')}</p>
</div>
<div className="chart-card">
<h2>{t('settings.seasons')}</h2>
<p className="settings-hint">{t('settings.seasonsHint')}</p>
<div className="table-container">
<table>
<thead>
<tr>
<th>{t('settings.seasonName')}</th>
<th>{t('settings.startDate')}</th>
<th>{t('settings.endDate')}</th>
<th>{t('settings.actions')}</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={4} style={{ textAlign: 'center', padding: 24 }}>Loading...</td></tr>
) : (
seasons.map(s => (
<SeasonRow key={s.Id} season={s} onSave={handleSave} onDelete={handleDelete} />
))
)}
<tr className="add-row">
<td>
<div className="season-edit-name">
<input type="text" value={newSeason.Name} onChange={e => setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} />
<input type="number" value={newSeason.HijriYear} onChange={e => setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} />
<input type="color" value={newSeason.Color} onChange={e => setNewSeason({ ...newSeason, Color: e.target.value })} />
</div>
</td>
<td><input type="date" value={newSeason.StartDate} onChange={e => setNewSeason({ ...newSeason, StartDate: e.target.value })} /></td>
<td><input type="date" value={newSeason.EndDate} onChange={e => setNewSeason({ ...newSeason, EndDate: e.target.value })} /></td>
<td>
<button className="btn-small btn-primary" onClick={handleCreate} disabled={!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate}>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
export default Settings;

View File

@@ -12,6 +12,7 @@ import {
Filler Filler
} from 'chart.js'; } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels'; import ChartDataLabels from 'chartjs-plugin-datalabels';
import Annotation from 'chartjs-plugin-annotation';
// Register ChartJS components once // Register ChartJS components once
ChartJS.register( ChartJS.register(
@@ -25,7 +26,8 @@ ChartJS.register(
Tooltip, Tooltip,
Legend, Legend,
Filler, Filler,
ChartDataLabels ChartDataLabels,
Annotation
); );
export const chartColors = { export const chartColors = {

View File

@@ -12,6 +12,7 @@
"comparison": "المقارنة", "comparison": "المقارنة",
"compare": "مقارنة", "compare": "مقارنة",
"slides": "الشرائح", "slides": "الشرائح",
"settings": "الإعدادات",
"labels": "التسميات", "labels": "التسميات",
"labelsOn": "التسميات مفعّلة", "labelsOn": "التسميات مفعّلة",
"labelsOff": "التسميات معطّلة", "labelsOff": "التسميات معطّلة",
@@ -42,6 +43,8 @@
"allChannels": "جميع القنوات", "allChannels": "جميع القنوات",
"allMuseums": "كل الفعاليات", "allMuseums": "كل الفعاليات",
"allQuarters": "كل الأرباع", "allQuarters": "كل الأرباع",
"season": "الموسم",
"allSeasons": "كل المواسم",
"reset": "إعادة تعيين الفلاتر" "reset": "إعادة تعيين الفلاتر"
}, },
"metrics": { "metrics": {
@@ -153,6 +156,18 @@
"channel": "القناة", "channel": "القناة",
"captureRate": "نسبة الاستقطاب" "captureRate": "نسبة الاستقطاب"
}, },
"settings": {
"title": "الإعدادات",
"subtitle": "إعدادات لوحة التحكم والمواسم الهجرية",
"seasons": "المواسم الهجرية",
"seasonsHint": "حدد المواسم مع تواريخها الميلادية. تظهر كفلاتر مسبقة وتراكبات على الرسوم البيانية.",
"seasonName": "الموسم",
"startDate": "تاريخ البداية",
"endDate": "تاريخ النهاية",
"actions": "الإجراءات",
"namePlaceholder": "مثال: رمضان",
"add": "إضافة"
},
"errors": { "errors": {
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.", "config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.", "network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",

View File

@@ -12,6 +12,7 @@
"comparison": "Comparison", "comparison": "Comparison",
"compare": "Compare", "compare": "Compare",
"slides": "Slides", "slides": "Slides",
"settings": "Settings",
"labels": "Labels", "labels": "Labels",
"labelsOn": "Labels On", "labelsOn": "Labels On",
"labelsOff": "Labels Off", "labelsOff": "Labels Off",
@@ -42,6 +43,8 @@
"allChannels": "All Channels", "allChannels": "All Channels",
"allMuseums": "All Events", "allMuseums": "All Events",
"allQuarters": "All Quarters", "allQuarters": "All Quarters",
"season": "Season",
"allSeasons": "All Seasons",
"reset": "Reset Filters" "reset": "Reset Filters"
}, },
"metrics": { "metrics": {
@@ -153,6 +156,18 @@
"channel": "Channel", "channel": "Channel",
"captureRate": "Capture Rate" "captureRate": "Capture Rate"
}, },
"settings": {
"title": "Settings",
"subtitle": "Configure dashboard settings and hijri seasons",
"seasons": "Hijri Seasons",
"seasonsHint": "Define seasons with their Gregorian date ranges. These appear as filter presets and chart overlays.",
"seasonName": "Season",
"startDate": "Start Date",
"endDate": "End Date",
"actions": "Actions",
"namePlaceholder": "e.g. Ramadan",
"add": "Add"
},
"errors": { "errors": {
"config": "The dashboard is not configured. Please set up the ERP API connection.", "config": "The dashboard is not configured. Please set up the ERP API connection.",
"network": "Cannot reach the database server. Please check your internet connection.", "network": "Cannot reach the database server. Please check your internet connection.",

View File

@@ -0,0 +1,37 @@
import type { Season } from '../types';
export async function fetchSeasons(): Promise<Season[]> {
try {
const res = await fetch('/api/seasons');
if (!res.ok) return [];
return res.json();
} catch {
console.warn('Failed to load seasons, using empty list');
return [];
}
}
export async function createSeason(season: Omit<Season, 'Id'>): Promise<Season> {
const res = await fetch('/api/seasons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(season),
});
if (!res.ok) throw new Error('Failed to create season');
return res.json();
}
export async function updateSeason(id: number, season: Partial<Season>): Promise<Season> {
const res = await fetch(`/api/seasons/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(season),
});
if (!res.ok) throw new Error('Failed to update season');
return res.json();
}
export async function deleteSeason(id: number): Promise<void> {
const res = await fetch(`/api/seasons/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete season');
}

View File

@@ -92,6 +92,16 @@ export interface NocoDBDailySale {
NetRevenue: number; NetRevenue: number;
} }
// Season (hijri calendar overlay)
export interface Season {
Id?: number;
Name: string;
HijriYear: number;
StartDate: string;
EndDate: string;
Color: string;
}
// Chart data types // Chart data types
export interface ChartDataset { export interface ChartDataset {
label?: string; label?: string;
@@ -120,6 +130,7 @@ export interface ChartData {
// Component props // Component props
export interface DashboardProps { export interface DashboardProps {
data: MuseumRecord[]; data: MuseumRecord[];
seasons: Season[];
showDataLabels: boolean; showDataLabels: boolean;
setShowDataLabels: (value: boolean) => void; setShowDataLabels: (value: boolean) => void;
includeVAT: boolean; includeVAT: boolean;
@@ -128,6 +139,7 @@ export interface DashboardProps {
export interface ComparisonProps { export interface ComparisonProps {
data: MuseumRecord[]; data: MuseumRecord[];
seasons: Season[];
showDataLabels: boolean; showDataLabels: boolean;
setShowDataLabels: (value: boolean) => void; setShowDataLabels: (value: boolean) => void;
includeVAT: boolean; includeVAT: boolean;

View File

@@ -10,6 +10,14 @@ export default defineConfig({
target: 'http://localhost:3002', target: 'http://localhost:3002',
changeOrigin: true, changeOrigin: true,
}, },
'/api/etl': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api/seasons': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api': { '/api': {
target: 'http://localhost:8090', target: 'http://localhost:8090',
changeOrigin: true, changeOrigin: true,