Compare commits

...

8 Commits

Author SHA1 Message Date
fahed
f615407bba fix: default district performance chart to pie
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:19:47 +03:00
fahed
47122b5445 feat: add bar/pie and #/% toggles to district performance chart
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:17:58 +03:00
fahed
e373363e75 feat: add % toggle to revenue/visitors by event chart
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:16:15 +03:00
fahed
0a80103cfc feat: add % toggle to channel performance, default events and channel to pie chart
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:14:17 +03:00
fahed
ebdf90c8ab fix: use correct translation keys for visitors/revenue/bar/pie toggles
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:11:47 +03:00
fahed
cb4fb6071a feat: merge event charts with metric toggle, add pie chart option to events and channels
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:09:37 +03:00
fahed
e70d9b92c6 chore: consolidate start scripts — replace start.sh with start-dev.sh content
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:55:19 +03:00
fahed
418eb2c17c ci: remove sudo restart step — restart manually after deploy
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:16:24 +03:00
7 changed files with 249 additions and 96 deletions

View File

@@ -59,5 +59,4 @@ jobs:
ETL_SECRET=${ETL_SECRET}
EOF
- name: Restart server service
run: sudo systemctl restart hihala-dashboard.service
# Restart manually: sudo systemctl restart hihala-dashboard.service

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import { Line, Bar, Pie } from 'react-chartjs-2';
import { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
import { ExportableChart } from './ChartExport';
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
@@ -79,6 +79,13 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
const [activeChart, setActiveChart] = useState(0);
const [trendGranularity, setTrendGranularity] = useState('week');
const [selectedSeason, setSelectedSeason] = useState<string>('');
const [eventMetric, setEventMetric] = useState<'visitors' | 'revenue'>('revenue');
const [eventChartType, setEventChartType] = useState<'bar' | 'pie'>('pie');
const [channelChartType, setChannelChartType] = useState<'bar' | 'pie'>('pie');
const [channelDisplayMode, setChannelDisplayMode] = useState<'absolute' | 'percent'>('absolute');
const [eventDisplayMode, setEventDisplayMode] = useState<'absolute' | 'percent'>('absolute');
const [districtChartType, setDistrictChartType] = useState<'bar' | 'pie'>('pie');
const [districtDisplayMode, setDistrictDisplayMode] = useState<'absolute' | 'percent'>('absolute');
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
@@ -250,6 +257,27 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
};
}, [seasonFilteredData, includeVAT]);
const eventChartData = useMemo(() => {
const source = museumData[eventMetric];
if (eventDisplayMode === 'absolute') return source;
const total = source.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
if (total === 0) return source;
return {
...source,
datasets: [{ ...source.datasets[0], data: source.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
};
}, [museumData, eventMetric, eventDisplayMode]);
const channelChartData = useMemo(() => {
if (channelDisplayMode === 'absolute') return channelData;
const total = channelData.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
if (total === 0) return channelData;
return {
...channelData,
datasets: [{ ...channelData.datasets[0], data: channelData.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
};
}, [channelData, channelDisplayMode]);
// District data
const districtData = useMemo(() => {
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
@@ -264,6 +292,16 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
};
}, [seasonFilteredData, includeVAT]);
const districtChartData = useMemo(() => {
if (districtDisplayMode === 'absolute') return districtData;
const total = districtData.datasets[0].data.reduce((s: number, v: number) => s + v, 0);
if (total === 0) return districtData;
return {
...districtData,
datasets: [{ ...districtData.datasets[0], data: districtData.datasets[0].data.map((v: number) => parseFloat(((v / total) * 100).toFixed(1))) }]
};
}, [districtData, districtDisplayMode]);
// Quarterly YoY
const quarterlyYoYData = useMemo(() => {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
@@ -400,6 +438,16 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
const pieOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'right' as const, labels: { boxWidth: 12, padding: 10, font: { size: 11 }, color: '#64748b' } },
tooltip: baseOptions.plugins.tooltip,
datalabels: { display: false }
}
}), [baseOptions]);
// Season annotation bands for revenue trend chart
const seasonAnnotations = useMemo(() => {
const raw = trendData.rawDates;
@@ -601,14 +649,40 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
</div>
<div className="chart-card half-width">
<ExportableChart filename="visitors-by-event" title={t('dashboard.visitorsByMuseum')} className="chart-container">
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="revenue-by-event" title={t('dashboard.revenueByMuseum')} className="chart-container">
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
<ExportableChart
filename={eventMetric === 'visitors' ? 'visitors-by-event' : 'revenue-by-event'}
title={eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}
className="chart-container"
controls={
<div style={{ display: 'flex', gap: '6px' }}>
<div className="toggle-switch">
<button className={eventMetric === 'visitors' ? 'active' : ''} onClick={() => setEventMetric('visitors')}>{t('metrics.visitors')}</button>
<button className={eventMetric === 'revenue' ? 'active' : ''} onClick={() => setEventMetric('revenue')}>{t('metrics.revenue')}</button>
</div>
<div className="toggle-switch">
<button className={eventChartType === 'bar' ? 'active' : ''} onClick={() => setEventChartType('bar')}>{t('metrics.bar')}</button>
<button className={eventChartType === 'pie' ? 'active' : ''} onClick={() => setEventChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={eventDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setEventDisplayMode('absolute')}>#</button>
<button className={eventDisplayMode === 'percent' ? 'active' : ''} onClick={() => setEventDisplayMode('percent')}>%</button>
</div>
</div>
}
>
{eventChartType === 'bar'
? <Bar data={eventChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={eventChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: eventDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => eventDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</ExportableChart>
</div>
@@ -619,14 +693,75 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
</div>
<div className="chart-card half-width">
<ExportableChart filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
<ExportableChart
filename="channel-performance"
title={t('dashboard.channelPerformance')}
className="chart-container"
controls={
<div style={{ display: 'flex', gap: '6px' }}>
<div className="toggle-switch">
<button className={channelChartType === 'bar' ? 'active' : ''} onClick={() => setChannelChartType('bar')}>{t('metrics.bar')}</button>
<button className={channelChartType === 'pie' ? 'active' : ''} onClick={() => setChannelChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={channelDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setChannelDisplayMode('absolute')}>#</button>
<button className={channelDisplayMode === 'percent' ? 'active' : ''} onClick={() => setChannelDisplayMode('percent')}>%</button>
</div>
</div>
}
>
{channelChartType === 'bar'
? <Bar data={channelChartData} options={{
...baseOptions,
indexAxis: 'y',
plugins: { ...baseOptions.plugins, datalabels: { ...baseOptions.plugins.datalabels, formatter: (v: number) => channelDisplayMode === 'percent' ? v.toFixed(1) + '%' : baseOptions.plugins.datalabels.formatter(v, {} as any) } },
scales: { ...baseOptions.scales, x: { ...baseOptions.scales.x, ticks: { ...baseOptions.scales.x.ticks, callback: (v: number | string) => channelDisplayMode === 'percent' ? v + '%' : v } } }
}} />
: <Pie data={channelChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: channelDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</ExportableChart>
</div>
<div className="chart-card half-width">
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
<ExportableChart
filename="district-performance"
title={t('dashboard.districtPerformance')}
className="chart-container"
controls={
<div style={{ display: 'flex', gap: '6px' }}>
<div className="toggle-switch">
<button className={districtChartType === 'bar' ? 'active' : ''} onClick={() => setDistrictChartType('bar')}>{t('metrics.bar')}</button>
<button className={districtChartType === 'pie' ? 'active' : ''} onClick={() => setDistrictChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={districtDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setDistrictDisplayMode('absolute')}>#</button>
<button className={districtDisplayMode === 'percent' ? 'active' : ''} onClick={() => setDistrictDisplayMode('percent')}>%</button>
</div>
</div>
}
>
{districtChartType === 'bar'
? <Bar data={districtChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={districtChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: districtDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</ExportableChart>
</div>
@@ -696,18 +831,35 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.visitorsByMuseum')}</h2>
<div className="chart-container">
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
<h2>{eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}</h2>
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
<div className="toggle-switch">
<button className={eventMetric === 'visitors' ? 'active' : ''} onClick={() => setEventMetric('visitors')}>{t('metrics.visitors')}</button>
<button className={eventMetric === 'revenue' ? 'active' : ''} onClick={() => setEventMetric('revenue')}>{t('metrics.revenue')}</button>
</div>
<div className="toggle-switch">
<button className={eventChartType === 'bar' ? 'active' : ''} onClick={() => setEventChartType('bar')}>{t('metrics.bar')}</button>
<button className={eventChartType === 'pie' ? 'active' : ''} onClick={() => setEventChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={eventDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setEventDisplayMode('absolute')}>#</button>
<button className={eventDisplayMode === 'percent' ? 'active' : ''} onClick={() => setEventDisplayMode('percent')}>%</button>
</div>
</div>
</div>
</div>
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.revenueByMuseum')}</h2>
<div className="chart-container">
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
{eventChartType === 'bar'
? <Bar data={eventChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={eventChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: eventDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => eventDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</div>
</div>
</div>
@@ -724,8 +876,30 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.channelPerformance')}</h2>
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
<div className="toggle-switch">
<button className={channelChartType === 'bar' ? 'active' : ''} onClick={() => setChannelChartType('bar')}>{t('metrics.bar')}</button>
<button className={channelChartType === 'pie' ? 'active' : ''} onClick={() => setChannelChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={channelDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setChannelDisplayMode('absolute')}>#</button>
<button className={channelDisplayMode === 'percent' ? 'active' : ''} onClick={() => setChannelDisplayMode('percent')}>%</button>
</div>
</div>
<div className="chart-container">
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
{channelChartType === 'bar'
? <Bar data={channelChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={channelChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: channelDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => channelDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</div>
</div>
</div>
@@ -733,8 +907,30 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
<div className="carousel-slide">
<div className="chart-card">
<h2>{t('dashboard.districtPerformance')}</h2>
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
<div className="toggle-switch">
<button className={districtChartType === 'bar' ? 'active' : ''} onClick={() => setDistrictChartType('bar')}>{t('metrics.bar')}</button>
<button className={districtChartType === 'pie' ? 'active' : ''} onClick={() => setDistrictChartType('pie')}>{t('metrics.pie')}</button>
</div>
<div className="toggle-switch">
<button className={districtDisplayMode === 'absolute' ? 'active' : ''} onClick={() => setDistrictDisplayMode('absolute')}>#</button>
<button className={districtDisplayMode === 'percent' ? 'active' : ''} onClick={() => setDistrictDisplayMode('percent')}>%</button>
</div>
</div>
<div className="chart-container">
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
{districtChartType === 'bar'
? <Bar data={districtChartData} options={{...baseOptions, indexAxis: 'y'}} />
: <Pie data={districtChartData} options={{
...pieOptions,
plugins: {
...pieOptions.plugins,
datalabels: districtDisplayMode === 'percent'
? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
: { display: false },
tooltip: { ...pieOptions.plugins.tooltip, callbacks: { label: (ctx: any) => districtDisplayMode === 'percent' ? ` ${ctx.parsed.toFixed(1)}%` : ` ${formatCurrency(ctx.parsed)}` } }
}
}} />
}
</div>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import {
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
@@ -21,7 +21,7 @@ ChartJS.register(
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,

View File

@@ -57,7 +57,9 @@
"avgRevenue": "متوسط الإيراد/زائر",
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
"pilgrims": "المعتمرون",
"captureRate": "نسبة الاستقطاب"
"captureRate": "نسبة الاستقطاب",
"bar": "أعمدة",
"pie": "دائري"
},
"dashboard": {
"title": "لوحة التحكم",

View File

@@ -57,7 +57,9 @@
"avgRevenue": "Avg Rev/Visitor",
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
"pilgrims": "Pilgrims",
"captureRate": "Capture Rate"
"captureRate": "Capture Rate",
"bar": "Bar",
"pie": "Pie"
},
"dashboard": {
"title": "Dashboard",

View File

@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Temporary dev script for ERP migration — starts NocoDB + Express server + Vite
set -e
cleanup() {
echo ""
echo "Shutting down..."
kill $SERVER_PID $CLIENT_PID 2>/dev/null
docker stop nocodb 2>/dev/null
echo "Done."
}
trap cleanup EXIT INT TERM
cd "$(dirname "$0")"
# Start NocoDB
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
echo "NocoDB already running on port 8090"
else
echo "Starting NocoDB..."
docker start nocodb 2>/dev/null || docker run -d \
--name nocodb -p 8090:8080 nocodb/nocodb:latest
fi
echo "Waiting for NocoDB..."
for i in $(seq 1 30); do
curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1 && echo "NocoDB ready" && break
sleep 1
done
# Start Express server (port 3002)
echo "Starting Express server..."
(cd server && npm run dev) &
SERVER_PID=$!
sleep 2
# Start Vite (port 3000)
echo "Starting Vite..."
npx vite &
CLIENT_PID=$!
wait $CLIENT_PID

View File

@@ -1,46 +1,45 @@
#!/usr/bin/env bash
# Launch both NocoDB (backend) and React (frontend)
# Start local dev environment: NocoDB + Express server + Vite
set -e
cleanup() {
echo ""
echo "Shutting down..."
if [ -n "$REACT_PID" ]; then
kill "$REACT_PID" 2>/dev/null
fi
kill $SERVER_PID $CLIENT_PID 2>/dev/null
docker stop nocodb 2>/dev/null
echo "Done."
}
trap cleanup EXIT INT TERM
# Start NocoDB container
cd "$(dirname "$0")"
# Start NocoDB
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
echo "NocoDB already running on port 8090"
else
echo "Starting NocoDB..."
docker start nocodb 2>/dev/null || docker run -d \
--name nocodb \
-p 8090:8080 \
nocodb/nocodb:latest
echo "NocoDB started on port 8090"
--name nocodb -p 8090:8080 nocodb/nocodb:latest
fi
# Wait for NocoDB to be ready
echo "Waiting for NocoDB..."
for i in $(seq 1 30); do
if curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1; then
echo "NocoDB is ready"
break
fi
curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1 && echo "NocoDB ready" && break
sleep 1
done
# Start React dev server
echo "Starting React dev server..."
cd "$(dirname "$0")"
npm start &
REACT_PID=$!
# Start Express server (port 3002)
echo "Starting Express server..."
(cd server && npm run dev) &
SERVER_PID=$!
wait $REACT_PID
sleep 2
# Start Vite (port 3000)
echo "Starting Vite..."
npx vite &
CLIENT_PID=$!
wait $CLIENT_PID