From ef48372033dc2e6f732cdd54df12d670bff920bf Mon Sep 17 00:00:00 2001 From: fahed Date: Tue, 31 Mar 2026 16:03:50 +0300 Subject: [PATCH] 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) --- server/src/index.ts | 2 + server/src/routes/seasons.ts | 63 +++++++++++ server/src/services/nocodbClient.ts | 44 +++++++- src/App.css | 96 ++++++++++++++++ src/App.tsx | 22 +++- src/components/Settings.tsx | 169 ++++++++++++++++++++++++++++ src/locales/ar.json | 15 +++ src/locales/en.json | 15 +++ src/services/seasonsService.ts | 37 ++++++ src/types/index.ts | 12 ++ vite.config.ts | 8 ++ 11 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 server/src/routes/seasons.ts create mode 100644 src/components/Settings.tsx create mode 100644 src/services/seasonsService.ts diff --git a/server/src/index.ts b/server/src/index.ts index 4b54e08..66e398f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,7 @@ import cors from 'cors'; import { server, erp, nocodb } from './config'; import erpRoutes from './routes/erp'; import etlRoutes from './routes/etl'; +import seasonsRoutes from './routes/seasons'; const app = express(); app.use(cors()); @@ -11,6 +12,7 @@ app.use(express.json()); // Mount routes app.use('/api/erp', erpRoutes); app.use('/api/etl', etlRoutes); +app.use('/api/seasons', seasonsRoutes); app.listen(server.port, () => { console.log(`\nServer running on http://localhost:${server.port}`); diff --git a/server/src/routes/seasons.ts b/server/src/routes/seasons.ts new file mode 100644 index 0000000..80e2d36 --- /dev/null +++ b/server/src/routes/seasons.ts @@ -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 { + 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; diff --git a/server/src/services/nocodbClient.ts b/server/src/services/nocodbClient.ts index ebf203d..52a1978 100644 --- a/server/src/services/nocodbClient.ts +++ b/server/src/services/nocodbClient.ts @@ -1,5 +1,4 @@ import { nocodb } from '../config'; -import type { AggregatedRecord } from '../types'; let discoveredTables: Record | null = null; @@ -91,8 +90,7 @@ export async function deleteAllRows(tableId: string): Promise { return deleted; } -export async function insertRecords(tableId: string, records: AggregatedRecord[]): Promise { - // NocoDB bulk insert accepts max 100 records at a time +export async function insertRecords>(tableId: string, records: T[]): Promise { const batchSize = 100; let inserted = 0; @@ -107,3 +105,43 @@ export async function insertRecords(tableId: string, records: AggregatedRecord[] return inserted; } + +// Generic CRUD helpers + +export async function fetchAllRecords(tableId: string): Promise { + 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>(tableId: string, record: T): Promise { + return await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, { + method: 'POST', + body: JSON.stringify(record), + }) as T; +} + +export async function updateRecord>(tableId: string, id: number, record: T): Promise { + 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 { + await fetchJson(`${nocodb.url}/api/v2/tables/${tableId}/records`, { + method: 'DELETE', + body: JSON.stringify([{ Id: id }]), + }); +} diff --git a/src/App.css b/src/App.css index 0c8ef88..dd5ebac 100644 --- a/src/App.css +++ b/src/App.css @@ -851,6 +851,102 @@ table tbody tr:hover { 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 { background: var(--bg); padding: 16px; diff --git a/src/App.tsx b/src/App.tsx index dd5f567..647d3ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,10 +3,12 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react const Dashboard = lazy(() => import('./components/Dashboard')); const Comparison = lazy(() => import('./components/Comparison')); +const Settings = lazy(() => import('./components/Settings')); import LoadingSkeleton from './components/shared/LoadingSkeleton'; import { fetchData, getCacheStatus, refreshData } from './services/dataService'; +import { fetchSeasons } from './services/seasonsService'; 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 './App.css'; @@ -43,6 +45,7 @@ function App() { const [showDataLabels, setShowDataLabels] = useState(false); const [includeVAT, setIncludeVAT] = useState(true); const [dataSource, setDataSource] = useState('museums'); + const [seasons, setSeasons] = useState([]); const [theme, setTheme] = useState(() => { if (typeof window !== 'undefined') { return localStorage.getItem('hihala_theme') || 'light'; @@ -97,8 +100,14 @@ function App() { } }, []); + const loadSeasons = useCallback(async () => { + const s = await fetchSeasons(); + setSeasons(s); + }, []); + useEffect(() => { loadData(); + loadSeasons(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -237,8 +246,9 @@ function App() {
}> - } /> - } /> + } /> + } /> + } />
@@ -262,6 +272,12 @@ function App() { {t('nav.compare')} + + + {t('nav.settings')} + + + + + + ); + } + + return ( + + +
+ setForm({ ...form, Name: e.target.value })} placeholder="Name" /> + setForm({ ...form, HijriYear: parseInt(e.target.value) || 0 })} placeholder="Year" style={{ width: 80 }} /> + setForm({ ...form, Color: e.target.value })} /> +
+ + setForm({ ...form, StartDate: e.target.value })} /> + setForm({ ...form, EndDate: e.target.value })} /> + +
+ + +
+ + + ); +} + +interface SettingsProps { + onSeasonsChange: () => void; +} + +function Settings({ onSeasonsChange }: SettingsProps) { + const { t } = useLanguage(); + const [seasons, setSeasons] = useState([]); + const [loading, setLoading] = useState(true); + + const [newSeason, setNewSeason] = useState>({ + 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) => { + await updateSeason(id, data); + await loadSeasons(); + onSeasonsChange(); + }; + + const handleDelete = async (id: number) => { + await deleteSeason(id); + await loadSeasons(); + onSeasonsChange(); + }; + + return ( +
+
+

{t('settings.title')}

+

{t('settings.subtitle')}

+
+ +
+

{t('settings.seasons')}

+

{t('settings.seasonsHint')}

+ +
+ + + + + + + + + + + {loading ? ( + + ) : ( + seasons.map(s => ( + + )) + )} + + + + + + + +
{t('settings.seasonName')}{t('settings.startDate')}{t('settings.endDate')}{t('settings.actions')}
Loading...
+
+ setNewSeason({ ...newSeason, Name: e.target.value })} placeholder={t('settings.namePlaceholder')} /> + setNewSeason({ ...newSeason, HijriYear: parseInt(e.target.value) || 0 })} style={{ width: 80 }} /> + setNewSeason({ ...newSeason, Color: e.target.value })} /> +
+
setNewSeason({ ...newSeason, StartDate: e.target.value })} /> setNewSeason({ ...newSeason, EndDate: e.target.value })} /> + +
+
+
+
+ ); +} + +export default Settings; diff --git a/src/locales/ar.json b/src/locales/ar.json index 180958d..56aaa2e 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -12,6 +12,7 @@ "comparison": "المقارنة", "compare": "مقارنة", "slides": "الشرائح", + "settings": "الإعدادات", "labels": "التسميات", "labelsOn": "التسميات مفعّلة", "labelsOff": "التسميات معطّلة", @@ -42,6 +43,8 @@ "allChannels": "جميع القنوات", "allMuseums": "كل الفعاليات", "allQuarters": "كل الأرباع", + "season": "الموسم", + "allSeasons": "كل المواسم", "reset": "إعادة تعيين الفلاتر" }, "metrics": { @@ -153,6 +156,18 @@ "channel": "القناة", "captureRate": "نسبة الاستقطاب" }, + "settings": { + "title": "الإعدادات", + "subtitle": "إعدادات لوحة التحكم والمواسم الهجرية", + "seasons": "المواسم الهجرية", + "seasonsHint": "حدد المواسم مع تواريخها الميلادية. تظهر كفلاتر مسبقة وتراكبات على الرسوم البيانية.", + "seasonName": "الموسم", + "startDate": "تاريخ البداية", + "endDate": "تاريخ النهاية", + "actions": "الإجراءات", + "namePlaceholder": "مثال: رمضان", + "add": "إضافة" + }, "errors": { "config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.", "network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.", diff --git a/src/locales/en.json b/src/locales/en.json index 93a176f..b9a5608 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -12,6 +12,7 @@ "comparison": "Comparison", "compare": "Compare", "slides": "Slides", + "settings": "Settings", "labels": "Labels", "labelsOn": "Labels On", "labelsOff": "Labels Off", @@ -42,6 +43,8 @@ "allChannels": "All Channels", "allMuseums": "All Events", "allQuarters": "All Quarters", + "season": "Season", + "allSeasons": "All Seasons", "reset": "Reset Filters" }, "metrics": { @@ -153,6 +156,18 @@ "channel": "Channel", "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": { "config": "The dashboard is not configured. Please set up the ERP API connection.", "network": "Cannot reach the database server. Please check your internet connection.", diff --git a/src/services/seasonsService.ts b/src/services/seasonsService.ts new file mode 100644 index 0000000..49547f8 --- /dev/null +++ b/src/services/seasonsService.ts @@ -0,0 +1,37 @@ +import type { Season } from '../types'; + +export async function fetchSeasons(): Promise { + 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): Promise { + 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): Promise { + 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 { + const res = await fetch(`/api/seasons/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete season'); +} diff --git a/src/types/index.ts b/src/types/index.ts index f17fa45..6ae60c5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -92,6 +92,16 @@ export interface NocoDBDailySale { NetRevenue: number; } +// Season (hijri calendar overlay) +export interface Season { + Id?: number; + Name: string; + HijriYear: number; + StartDate: string; + EndDate: string; + Color: string; +} + // Chart data types export interface ChartDataset { label?: string; @@ -120,6 +130,7 @@ export interface ChartData { // Component props export interface DashboardProps { data: MuseumRecord[]; + seasons: Season[]; showDataLabels: boolean; setShowDataLabels: (value: boolean) => void; includeVAT: boolean; @@ -128,6 +139,7 @@ export interface DashboardProps { export interface ComparisonProps { data: MuseumRecord[]; + seasons: Season[]; showDataLabels: boolean; setShowDataLabels: (value: boolean) => void; includeVAT: boolean; diff --git a/vite.config.ts b/vite.config.ts index 186efa1..e26362e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,14 @@ export default defineConfig({ target: 'http://localhost:3002', changeOrigin: true, }, + '/api/etl': { + target: 'http://localhost:3002', + changeOrigin: true, + }, + '/api/seasons': { + target: 'http://localhost:3002', + changeOrigin: true, + }, '/api': { target: 'http://localhost:8090', changeOrigin: true,