Compare commits

...

9 Commits

Author SHA1 Message Date
fahed
b8d33f4f8c feat: deploy Express server via CI/CD with systemd + nginx proxy
Some checks failed
Deploy HiHala Dashboard / deploy (push) Failing after 51m18s
- Update deploy.yml to rsync server/, install deps, write .env from
  Gitea secrets, and restart hihala-dashboard.service
- Move tsx to regular dependencies for production use
- Remove unused SESSION_SECRET from config
- Accept PORT env var as fallback for SERVER_PORT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:59:34 +03:00
fahed
f3ce7705d6 fix: style select input in settings, fix user name placeholder
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:20:39 +03:00
fahed
70af4962a6 feat: multi-user auth with role-based access
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Server checks PIN against env (super admin) + NocoDB Users table
- Session stores name + role (admin/viewer)
- Admin: sees Settings page (seasons + users management)
- Viewer: sees Dashboard + Comparison only, no Settings
- Users CRUD on Settings page: add name + PIN + role, delete
- Settings link + nav hidden for non-admin users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:17:44 +03:00
fahed
8cf6f9eedd feat: add PIN-based login with server-side cookie sessions
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Server: POST /auth/login (verify PIN, set httpOnly cookie)
- Server: GET /auth/check, POST /auth/logout
- Client: Login page shown when not authenticated
- Session persists 7 days via httpOnly cookie
- PIN stored server-side only (ADMIN_PIN env var)
- Dashboard loads data only after successful auth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:02:34 +03:00
fahed
c99f2abe10 fix: center settings page to match dashboard layout
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:01:55 +03:00
fahed
a06436baac fix: change NocoDB proxy from /api to /api/v2 to avoid route collision
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 9s
The catch-all /api proxy was swallowing /api/seasons requests before
the specific proxy rule could match. Narrowing to /api/v2 fixes this
since all NocoDB REST calls use /api/v2/ paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:58:41 +03:00
fahed
9657a9d221 ci: trigger rebuild with new NocoDB base ID
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:45:24 +03:00
fahed
3c19dee236 feat: add season annotation bands to Comparison trend chart
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Seasons that overlap the current comparison period appear as
colored bands on the Revenue Trend chart, same as Dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:23:35 +03:00
fahed
b4c436f909 feat: add settings link at bottom of dashboard
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:20:02 +03:00
19 changed files with 665 additions and 29 deletions

View File

@@ -8,11 +8,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
# --- Frontend ---
- name: Build frontend
env:
VITE_NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
@@ -21,7 +22,42 @@ jobs:
run: |
npm ci
npm run build
- name: Deploy to server
- name: Deploy frontend
run: rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
# --- Backend ---
- name: Deploy server
run: rsync -a --delete --exclude='.env' --exclude='node_modules' server/ /opt/apps/hihala-dashboard/server/
- name: Install server dependencies
run: cd /opt/apps/hihala-dashboard/server && npm ci
- name: Write server .env
env:
ADMIN_PIN: ${{ secrets.ADMIN_PIN }}
NOCODB_URL: ${{ secrets.VITE_NOCODB_URL }}
NOCODB_TOKEN: ${{ secrets.VITE_NOCODB_TOKEN }}
NOCODB_BASE_ID: ${{ secrets.VITE_NOCODB_BASE_ID }}
ERP_API_URL: ${{ secrets.ERP_API_URL }}
ERP_API_CODE: ${{ secrets.ERP_API_CODE }}
ERP_USERNAME: ${{ secrets.ERP_USERNAME }}
ERP_PASSWORD: ${{ secrets.ERP_PASSWORD }}
ETL_SECRET: ${{ secrets.ETL_SECRET }}
run: |
rsync -a --delete build/ /opt/apps/hihala-dashboard/build/
cat > /opt/apps/hihala-dashboard/server/.env << EOF
NODE_ENV=production
SERVER_PORT=3002
ADMIN_PIN=${ADMIN_PIN}
NOCODB_URL=${NOCODB_URL}
NOCODB_TOKEN=${NOCODB_TOKEN}
NOCODB_BASE_ID=${NOCODB_BASE_ID}
ERP_API_URL=${ERP_API_URL}
ERP_API_CODE=${ERP_API_CODE}
ERP_USERNAME=${ERP_USERNAME}
ERP_PASSWORD=${ERP_PASSWORD}
ETL_SECRET=${ETL_SECRET}
EOF
- name: Restart server service
run: sudo systemctl restart hihala-dashboard.service

View File

@@ -14,3 +14,7 @@ NOCODB_BASE_ID=your-base-id
# ETL sync secret (for cron auth)
ETL_SECRET=your-secret-here
# Auth
ADMIN_PIN=your-pin-code
SESSION_SECRET=your-random-session-secret

View File

@@ -9,11 +9,13 @@
"version": "1.0.0",
"dependencies": {
"axios": "^1.6.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"tsx": "^4.19.0",
@@ -483,6 +485,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -730,6 +742,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",

View File

@@ -10,14 +10,16 @@
},
"dependencies": {
"axios": "^1.6.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2"
"express": "^4.18.2",
"tsx": "^4.19.0"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
}
}

View File

@@ -33,3 +33,7 @@ export const nocodb = {
export const etl = {
secret: process.env.ETL_SECRET || '',
};
export const auth = {
adminPin: process.env.ADMIN_PIN || '',
};

View File

@@ -1,18 +1,24 @@
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { server, erp, nocodb } from './config';
import authRoutes from './routes/auth';
import erpRoutes from './routes/erp';
import etlRoutes from './routes/etl';
import seasonsRoutes from './routes/seasons';
import usersRoutes from './routes/users';
const app = express();
app.use(cors());
app.use(cors({ origin: true, credentials: true }));
app.use(cookieParser());
app.use(express.json());
// Mount routes
app.use('/auth', authRoutes);
app.use('/api/erp', erpRoutes);
app.use('/api/etl', etlRoutes);
app.use('/api/seasons', seasonsRoutes);
app.use('/api/users', usersRoutes);
app.listen(server.port, () => {
console.log(`\nServer running on http://localhost:${server.port}`);

95
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,95 @@
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import { auth } from '../config';
import { discoverTableIds, fetchAllRecords } from '../services/nocodbClient';
const router = Router();
interface UserRecord {
Id: number;
Name: string;
PIN: string;
Role: string;
}
interface Session {
name: string;
role: string;
createdAt: number;
}
const sessions = new Map<string, Session>();
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
function generateSessionId(): string {
return crypto.randomBytes(32).toString('hex');
}
function getSession(sessionId: string): Session | null {
const session = sessions.get(sessionId);
if (!session) return null;
if (Date.now() - session.createdAt > SESSION_MAX_AGE) {
sessions.delete(sessionId);
return null;
}
return session;
}
// POST /auth/login
router.post('/login', async (req: Request, res: Response) => {
const { pin } = req.body;
if (!pin) {
res.status(400).json({ error: 'PIN required' });
return;
}
// Check super admin PIN from env first
if (auth.adminPin && pin === auth.adminPin) {
const sessionId = generateSessionId();
sessions.set(sessionId, { name: 'Admin', role: 'admin', createdAt: Date.now() });
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
res.json({ ok: true, name: 'Admin', role: 'admin' });
return;
}
// Check NocoDB Users table
try {
const tables = await discoverTableIds();
if (tables['Users']) {
const users = await fetchAllRecords<UserRecord>(tables['Users']);
const user = users.find(u => u.PIN === pin);
if (user) {
const sessionId = generateSessionId();
sessions.set(sessionId, { name: user.Name, role: user.Role || 'viewer', createdAt: Date.now() });
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
res.json({ ok: true, name: user.Name, role: user.Role || 'viewer' });
return;
}
}
} catch (err) {
console.warn('Failed to check Users table:', (err as Error).message);
}
res.status(401).json({ error: 'Invalid PIN' });
});
// GET /auth/check
router.get('/check', (req: Request, res: Response) => {
const sessionId = req.cookies?.hihala_session;
const session = sessionId ? getSession(sessionId) : null;
res.json({
authenticated: !!session,
name: session?.name || null,
role: session?.role || null,
});
});
// POST /auth/logout
router.post('/logout', (req: Request, res: Response) => {
const sessionId = req.cookies?.hihala_session;
if (sessionId) sessions.delete(sessionId);
res.clearCookie('hihala_session', { path: '/' });
res.json({ ok: true });
});
export default router;

View File

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

View File

@@ -851,10 +851,119 @@ table tbody tr:hover {
accent-color: var(--accent);
}
/* Login page */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg);
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 48px 40px;
width: 100%;
max-width: 380px;
text-align: center;
}
.login-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
}
.login-brand h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.login-subtitle {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 32px;
}
.login-card form {
display: flex;
flex-direction: column;
gap: 12px;
}
.login-card input {
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 1.125rem;
text-align: center;
letter-spacing: 0.15em;
background: var(--bg);
color: var(--text-primary);
}
.login-card input:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}
.login-card button {
padding: 14px;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
color: white;
cursor: pointer;
transition: opacity 150ms ease;
}
.login-card button:hover:not(:disabled) {
opacity: 0.9;
}
.login-card button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-error {
color: var(--danger, #dc2626);
font-size: 0.8125rem;
}
.settings-link {
text-align: center;
padding: 32px 0 16px;
}
.settings-link a {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 0.8125rem;
text-decoration: none;
opacity: 0.6;
transition: opacity 150ms ease;
}
.settings-link a:hover {
opacity: 1;
}
/* Settings page */
.settings-page {
padding: 24px;
max-width: 900px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
.settings-hint {
@@ -938,7 +1047,8 @@ tr.editing td {
.settings-page input[type="text"],
.settings-page input[type="number"],
.settings-page input[type="date"] {
.settings-page input[type="date"],
.settings-page select {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;

View File

@@ -4,6 +4,7 @@ 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 Login from './components/Login';
import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData } from './services/dataService';
import { fetchSeasons } from './services/seasonsService';
@@ -36,6 +37,9 @@ interface DataSource {
function App() {
const { t, dir, switchLanguage } = useLanguage();
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [userRole, setUserRole] = useState<string>('viewer');
const [userName, setUserName] = useState<string>('');
const [data, setData] = useState<MuseumRecord[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [refreshing, setRefreshing] = useState<boolean>(false);
@@ -105,16 +109,53 @@ function App() {
setSeasons(s);
}, []);
// Check auth on mount
useEffect(() => {
loadData();
loadSeasons();
fetch('/auth/check', { credentials: 'include' })
.then(r => r.json())
.then(d => {
setAuthenticated(d.authenticated);
if (d.authenticated) {
setUserRole(d.role || 'viewer');
setUserName(d.name || '');
loadData();
loadSeasons();
}
})
.catch(() => setAuthenticated(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleLogin = (name: string, role: string) => {
setAuthenticated(true);
setUserName(name);
setUserRole(role);
loadData();
loadSeasons();
};
const handleRefresh = () => {
loadData(true);
};
// Auth check loading
if (authenticated === null) {
return (
<div className="app" dir={dir}>
<LoadingSkeleton />
</div>
);
}
// Not authenticated — show login
if (!authenticated) {
return (
<div className="app" dir={dir}>
<Login onLogin={handleLogin} />
</div>
);
}
if (loading) {
return (
<div className="app" dir={dir}>
@@ -246,9 +287,9 @@ function App() {
<main>
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} 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} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} />} />}
</Routes>
</Suspense>
</main>
@@ -272,12 +313,14 @@ 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>
{userRole === 'admin' && (
<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}

View File

@@ -532,11 +532,53 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
// Map seasons to annotation bands on the current period's timeline
const seasonAnnotations = useMemo(() => {
if (!seasons.length) return {};
const currStart = new Date(ranges.curr.start);
const currEnd = new Date(ranges.curr.end);
const annotations: Record<string, unknown> = {};
const msPerDay = 1000 * 60 * 60 * 24;
const granDivisor = chartGranularity === 'month' ? 30 : chartGranularity === 'week' ? 7 : 1;
seasons.forEach((s, i) => {
const sStart = new Date(s.StartDate);
const sEnd = new Date(s.EndDate);
// Check overlap with current period
if (sEnd < currStart || sStart > currEnd) return;
const clampedStart = sStart < currStart ? currStart : sStart;
const clampedEnd = sEnd > currEnd ? currEnd : sEnd;
const startIdx = Math.floor((clampedStart.getTime() - currStart.getTime()) / msPerDay / granDivisor);
const endIdx = Math.floor((clampedEnd.getTime() - currStart.getTime()) / msPerDay / granDivisor);
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, ranges.curr, chartGranularity]);
const chartOptions: any = {
...baseOptions,
plugins: {
...baseOptions.plugins,
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } },
annotation: { annotations: seasonAnnotations }
}
};

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, Link } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
import { ExportableChart } from './ChartExport';
@@ -34,7 +34,7 @@ const defaultFilters: Filters = {
const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter'];
function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) {
const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams();
const [pilgrimLoaded, setPilgrimLoaded] = useState(false);
@@ -799,6 +799,15 @@ function Dashboard({ data, seasons, showDataLabels, setShowDataLabels, includeVA
</div>
</>
)}
{userRole === 'admin' && <div className="settings-link">
<Link to="/settings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
{t('nav.settings')}
</Link>
</div>}
</div>
);
}

75
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface LoginProps {
onLogin: (name: string, role: string) => void;
}
function Login({ onLogin }: LoginProps) {
const { t } = useLanguage();
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ pin }),
});
if (!res.ok) {
setError(t('login.invalid'));
setLoading(false);
return;
}
const data = await res.json();
onLogin(data.name || '', data.role || 'viewer');
} catch {
setError(t('login.error'));
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-card">
<div className="login-brand">
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="4" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="11" width="7" height="10" rx="1"/>
</svg>
<h1>HiHala Data</h1>
</div>
<p className="login-subtitle">{t('login.subtitle')}</p>
<form onSubmit={handleSubmit}>
<input
type="password"
inputMode="numeric"
value={pin}
onChange={e => setPin(e.target.value)}
placeholder={t('login.placeholder')}
autoFocus
disabled={loading}
/>
{error && <p className="login-error">{error}</p>}
<button type="submit" disabled={loading || !pin}>
{loading ? '...' : t('login.submit')}
</button>
</form>
</div>
</div>
);
}
export default Login;

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService';
import { fetchUsers, createUser, deleteUser, type User } from '../services/usersService';
import type { Season } from '../types';
const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'];
@@ -78,6 +79,9 @@ function Settings({ onSeasonsChange }: SettingsProps) {
Color: DEFAULT_COLORS[0],
});
const [users, setUsers] = useState<User[]>([]);
const [newUser, setNewUser] = useState<Omit<User, 'Id'>>({ Name: '', PIN: '', Role: 'viewer' });
const loadSeasons = async () => {
setLoading(true);
const data = await fetchSeasons();
@@ -85,7 +89,12 @@ function Settings({ onSeasonsChange }: SettingsProps) {
setLoading(false);
};
useEffect(() => { loadSeasons(); }, []);
const loadUsers = async () => {
const data = await fetchUsers();
setUsers(data);
};
useEffect(() => { loadSeasons(); loadUsers(); }, []);
const handleCreate = async () => {
if (!newSeason.Name || !newSeason.StartDate || !newSeason.EndDate) return;
@@ -162,6 +171,62 @@ function Settings({ onSeasonsChange }: SettingsProps) {
</table>
</div>
</div>
<div className="chart-card" style={{ marginTop: 24 }}>
<h2>{t('settings.users')}</h2>
<p className="settings-hint">{t('settings.usersHint')}</p>
<div className="table-container">
<table>
<thead>
<tr>
<th>{t('settings.userName')}</th>
<th>{t('settings.userPin')}</th>
<th>{t('settings.userRole')}</th>
<th>{t('settings.actions')}</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.Id}>
<td>{u.Name}</td>
<td><code>{u.PIN}</code></td>
<td>{u.Role}</td>
<td>
<button className="btn-small btn-danger" onClick={async () => { await deleteUser(u.Id!); await loadUsers(); }}>
{t('settings.delete') || 'Delete'}
</button>
</td>
</tr>
))}
<tr className="add-row">
<td>
<input type="text" value={newUser.Name} onChange={e => setNewUser({ ...newUser, Name: e.target.value })} placeholder={t('settings.userNamePlaceholder')} />
</td>
<td>
<input type="text" value={newUser.PIN} onChange={e => setNewUser({ ...newUser, PIN: e.target.value })} placeholder="PIN" />
</td>
<td>
<select value={newUser.Role} onChange={e => setNewUser({ ...newUser, Role: e.target.value })}>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</td>
<td>
<button className="btn-small btn-primary" onClick={async () => {
if (!newUser.Name || !newUser.PIN) return;
await createUser(newUser);
setNewUser({ Name: '', PIN: '', Role: 'viewer' });
await loadUsers();
}} disabled={!newUser.Name || !newUser.PIN}>
{t('settings.add')}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -166,7 +166,21 @@
"endDate": "تاريخ النهاية",
"actions": "الإجراءات",
"namePlaceholder": "مثال: رمضان",
"add": "إضافة"
"add": "إضافة",
"delete": "حذف",
"users": "المستخدمون",
"usersHint": "أضف مستخدمين برمز PIN. المشاهدون يمكنهم رؤية لوحة التحكم فقط.",
"userName": "الاسم",
"userNamePlaceholder": "مثال: أحمد",
"userPin": "رمز PIN",
"userRole": "الدور"
},
"login": {
"subtitle": "أدخل رمز PIN للوصول إلى لوحة التحكم",
"placeholder": "رمز PIN",
"submit": "تسجيل الدخول",
"invalid": "رمز PIN غير صحيح",
"error": "خطأ في الاتصال. يرجى المحاولة مرة أخرى."
},
"errors": {
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",

View File

@@ -166,7 +166,21 @@
"endDate": "End Date",
"actions": "Actions",
"namePlaceholder": "e.g. Ramadan",
"add": "Add"
"add": "Add",
"delete": "Delete",
"users": "Users",
"usersHint": "Add users with a PIN code. Viewers can see the dashboard but not settings.",
"userName": "Name",
"userNamePlaceholder": "e.g. Ahmed",
"userPin": "PIN",
"userRole": "Role"
},
"login": {
"subtitle": "Enter your PIN to access the dashboard",
"placeholder": "PIN code",
"submit": "Login",
"invalid": "Invalid PIN code",
"error": "Connection error. Please try again."
},
"errors": {
"config": "The dashboard is not configured. Please set up the ERP API connection.",

View File

@@ -0,0 +1,31 @@
export interface User {
Id?: number;
Name: string;
PIN: string;
Role: string;
}
export async function fetchUsers(): Promise<User[]> {
try {
const res = await fetch('/api/users');
if (!res.ok) return [];
return res.json();
} catch {
return [];
}
}
export async function createUser(user: Omit<User, 'Id'>): Promise<User> {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (!res.ok) throw new Error('Failed to create user');
return res.json();
}
export async function deleteUser(id: number): Promise<void> {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete user');
}

View File

@@ -131,6 +131,7 @@ export interface ChartData {
export interface DashboardProps {
data: MuseumRecord[];
seasons: Season[];
userRole: string;
showDataLabels: boolean;
setShowDataLabels: (value: boolean) => void;
includeVAT: boolean;

View File

@@ -6,6 +6,10 @@ export default defineConfig({
server: {
port: 3000,
proxy: {
'/auth': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api/erp': {
target: 'http://localhost:3002',
changeOrigin: true,
@@ -14,11 +18,15 @@ export default defineConfig({
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api/users': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api/seasons': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/api': {
'/api/v2': {
target: 'http://localhost:8090',
changeOrigin: true,
},