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>
This commit is contained in:
@@ -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}`);
|
||||
|
||||
63
server/src/routes/seasons.ts
Normal file
63
server/src/routes/seasons.ts
Normal 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;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { nocodb } from '../config';
|
||||
import type { AggregatedRecord } from '../types';
|
||||
|
||||
let discoveredTables: Record<string, string> | null = null;
|
||||
|
||||
@@ -91,8 +90,7 @@ export async function deleteAllRows(tableId: string): Promise<number> {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export async function insertRecords(tableId: string, records: AggregatedRecord[]): Promise<number> {
|
||||
// NocoDB bulk insert accepts max 100 records at a time
|
||||
export async function insertRecords<T extends Record<string, unknown>>(tableId: string, records: T[]): Promise<number> {
|
||||
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<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 }]),
|
||||
});
|
||||
}
|
||||
|
||||
96
src/App.css
96
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;
|
||||
|
||||
22
src/App.tsx
22
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<boolean>(false);
|
||||
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||
const [dataSource, setDataSource] = useState<string>('museums');
|
||||
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||
const [theme, setTheme] = useState<string>(() => {
|
||||
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() {
|
||||
<main>
|
||||
<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="/" element={<Dashboard data={data} seasons={seasons} 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>
|
||||
</Suspense>
|
||||
</main>
|
||||
@@ -262,6 +272,12 @@ function App() {
|
||||
</svg>
|
||||
<span>{t('nav.compare')}</span>
|
||||
</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
|
||||
className="mobile-nav-item"
|
||||
onClick={switchLanguage}
|
||||
|
||||
169
src/components/Settings.tsx
Normal file
169
src/components/Settings.tsx
Normal 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;
|
||||
@@ -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": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
37
src/services/seasonsService.ts
Normal file
37
src/services/seasonsService.ts
Normal 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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user