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} 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

View File

@@ -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')}
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>
<div className="toggle-switch">
<div className="chart-card half-width"> <button className={eventChartType === 'bar' ? 'active' : ''} onClick={() => setEventChartType('bar')}>{t('metrics.bar')}</button>
<ExportableChart filename="revenue-by-event" title={t('dashboard.revenueByMuseum')} className="chart-container"> <button className={eventChartType === 'pie' ? 'active' : ''} onClick={() => setEventChartType('pie')}>{t('metrics.pie')}</button>
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} /> </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 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 className="chart-container"> <div className="chart-container">
<Bar data={museumData.visitors} options={{...baseOptions, indexAxis: 'y'}} /> {eventChartType === 'bar'
</div> ? <Bar data={eventChartData} options={{...baseOptions, indexAxis: 'y'}} />
</div> : <Pie data={eventChartData} options={{
</div> ...pieOptions,
plugins: {
<div className="carousel-slide"> ...pieOptions.plugins,
<div className="chart-card"> datalabels: eventDisplayMode === 'percent'
<h2>{t('dashboard.revenueByMuseum')}</h2> ? { display: true, color: '#fff', font: { size: 11, weight: 'bold' as const }, formatter: (v: number) => v > 3 ? v.toFixed(1) + '%' : '' }
<div className="chart-container"> : { display: false },
<Bar data={museumData.revenue} options={{...baseOptions, indexAxis: 'y'}} /> 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>

View File

@@ -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,

View File

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

View File

@@ -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",

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 #!/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