Compare commits
8 Commits
main
...
f615407bba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f615407bba | ||
|
|
47122b5445 | ||
|
|
e373363e75 | ||
|
|
0a80103cfc | ||
|
|
ebdf90c8ab | ||
|
|
cb4fb6071a | ||
|
|
e70d9b92c6 | ||
|
|
418eb2c17c |
@@ -59,5 +59,4 @@ jobs:
|
|||||||
ETL_SECRET=${ETL_SECRET}
|
ETL_SECRET=${ETL_SECRET}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Restart server service
|
# Restart manually: sudo systemctl restart hihala-dashboard.service
|
||||||
run: sudo systemctl restart hihala-dashboard.service
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useSearchParams, Link } from 'react-router-dom';
|
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 { Carousel, EmptyState, FilterControls, MultiSelect, StatCard } from './shared';
|
||||||
import { ExportableChart } from './ChartExport';
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
||||||
@@ -79,6 +79,13 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
const [activeChart, setActiveChart] = useState(0);
|
const [activeChart, setActiveChart] = useState(0);
|
||||||
const [trendGranularity, setTrendGranularity] = useState('week');
|
const [trendGranularity, setTrendGranularity] = useState('week');
|
||||||
const [selectedSeason, setSelectedSeason] = useState<string>('');
|
const [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]);
|
const filteredData = useMemo(() => filterData(data, filters), [data, filters]);
|
||||||
|
|
||||||
@@ -250,6 +257,27 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
};
|
};
|
||||||
}, [seasonFilteredData, includeVAT]);
|
}, [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
|
// District data
|
||||||
const districtData = useMemo(() => {
|
const districtData = useMemo(() => {
|
||||||
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
|
const grouped = groupByDistrict(seasonFilteredData, includeVAT);
|
||||||
@@ -264,6 +292,16 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
};
|
};
|
||||||
}, [seasonFilteredData, includeVAT]);
|
}, [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
|
// Quarterly YoY
|
||||||
const quarterlyYoYData = useMemo(() => {
|
const quarterlyYoYData = useMemo(() => {
|
||||||
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
|
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 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
|
// Season annotation bands for revenue trend chart
|
||||||
const seasonAnnotations = useMemo(() => {
|
const seasonAnnotations = useMemo(() => {
|
||||||
const raw = trendData.rawDates;
|
const raw = trendData.rawDates;
|
||||||
@@ -601,14 +649,40 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="visitors-by-event" title={t('dashboard.visitorsByMuseum')} className="chart-container">
|
<ExportableChart
|
||||||
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
|
filename={eventMetric === 'visitors' ? 'visitors-by-event' : 'revenue-by-event'}
|
||||||
</ExportableChart>
|
title={eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}
|
||||||
</div>
|
className="chart-container"
|
||||||
|
controls={
|
||||||
<div className="chart-card half-width">
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
<ExportableChart filename="revenue-by-event" title={t('dashboard.revenueByMuseum')} className="chart-container">
|
<div className="toggle-switch">
|
||||||
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} />
|
<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>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -619,14 +693,75 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="channel-performance" title={t('dashboard.channelPerformance')} className="chart-container">
|
<ExportableChart
|
||||||
<Bar data={channelData} options={{...baseOptions, indexAxis: 'y'}} />
|
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>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<ExportableChart filename="district-performance" title={t('dashboard.districtPerformance')} className="chart-container">
|
<ExportableChart
|
||||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
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>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -696,18 +831,35 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
|
|
||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.visitorsByMuseum')}</h2>
|
<h2>{eventMetric === 'visitors' ? t('dashboard.visitorsByMuseum') : t('dashboard.revenueByMuseum')}</h2>
|
||||||
<div className="chart-container">
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||||
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} />
|
<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>
|
|
||||||
|
|
||||||
<div className="carousel-slide">
|
|
||||||
<div className="chart-card">
|
|
||||||
<h2>{t('dashboard.revenueByMuseum')}</h2>
|
|
||||||
<div className="chart-container">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -724,8 +876,30 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.channelPerformance')}</h2>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -733,8 +907,30 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels,
|
|||||||
<div className="carousel-slide">
|
<div className="carousel-slide">
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h2>{t('dashboard.districtPerformance')}</h2>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
BarElement,
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
@@ -21,7 +21,7 @@ ChartJS.register(
|
|||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
BarElement,
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
|
|||||||
@@ -57,7 +57,9 @@
|
|||||||
"avgRevenue": "متوسط الإيراد/زائر",
|
"avgRevenue": "متوسط الإيراد/زائر",
|
||||||
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
|
"avgRevenuePerVisitor": "متوسط الإيراد لكل زائر",
|
||||||
"pilgrims": "المعتمرون",
|
"pilgrims": "المعتمرون",
|
||||||
"captureRate": "نسبة الاستقطاب"
|
"captureRate": "نسبة الاستقطاب",
|
||||||
|
"bar": "أعمدة",
|
||||||
|
"pie": "دائري"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "لوحة التحكم",
|
"title": "لوحة التحكم",
|
||||||
|
|||||||
@@ -57,7 +57,9 @@
|
|||||||
"avgRevenue": "Avg Rev/Visitor",
|
"avgRevenue": "Avg Rev/Visitor",
|
||||||
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
|
"avgRevenuePerVisitor": "Avg Revenue/Visitor",
|
||||||
"pilgrims": "Pilgrims",
|
"pilgrims": "Pilgrims",
|
||||||
"captureRate": "Capture Rate"
|
"captureRate": "Capture Rate",
|
||||||
|
"bar": "Bar",
|
||||||
|
"pie": "Pie"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
45
start-dev.sh
45
start-dev.sh
@@ -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
|
|
||||||
39
start.sh
39
start.sh
@@ -1,46 +1,45 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Launch both NocoDB (backend) and React (frontend)
|
# Start local dev environment: NocoDB + Express server + Vite
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo ""
|
echo ""
|
||||||
echo "Shutting down..."
|
echo "Shutting down..."
|
||||||
if [ -n "$REACT_PID" ]; then
|
kill $SERVER_PID $CLIENT_PID 2>/dev/null
|
||||||
kill "$REACT_PID" 2>/dev/null
|
|
||||||
fi
|
|
||||||
docker stop nocodb 2>/dev/null
|
docker stop nocodb 2>/dev/null
|
||||||
echo "Done."
|
echo "Done."
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
# Start NocoDB container
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Start NocoDB
|
||||||
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
|
if docker ps --format '{{.Names}}' | grep -q '^nocodb$'; then
|
||||||
echo "NocoDB already running on port 8090"
|
echo "NocoDB already running on port 8090"
|
||||||
else
|
else
|
||||||
echo "Starting NocoDB..."
|
echo "Starting NocoDB..."
|
||||||
docker start nocodb 2>/dev/null || docker run -d \
|
docker start nocodb 2>/dev/null || docker run -d \
|
||||||
--name nocodb \
|
--name nocodb -p 8090:8080 nocodb/nocodb:latest
|
||||||
-p 8090:8080 \
|
|
||||||
nocodb/nocodb:latest
|
|
||||||
echo "NocoDB started on port 8090"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wait for NocoDB to be ready
|
|
||||||
echo "Waiting for NocoDB..."
|
echo "Waiting for NocoDB..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1; then
|
curl -s http://localhost:8090/api/v1/health >/dev/null 2>&1 && echo "NocoDB ready" && break
|
||||||
echo "NocoDB is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
# Start React dev server
|
# Start Express server (port 3002)
|
||||||
echo "Starting React dev server..."
|
echo "Starting Express server..."
|
||||||
cd "$(dirname "$0")"
|
(cd server && npm run dev) &
|
||||||
npm start &
|
SERVER_PID=$!
|
||||||
REACT_PID=$!
|
|
||||||
|
|
||||||
wait $REACT_PID
|
sleep 2
|
||||||
|
|
||||||
|
# Start Vite (port 3000)
|
||||||
|
echo "Starting Vite..."
|
||||||
|
npx vite &
|
||||||
|
CLIENT_PID=$!
|
||||||
|
|
||||||
|
wait $CLIENT_PID
|
||||||
|
|||||||
Reference in New Issue
Block a user