Merge pull request #390 from Tria-plc/alpha

Route stops distance and other fixes
This commit is contained in:
Eyob T.
2026-07-01 13:37:32 +03:00
committed by GitHub
5 changed files with 52 additions and 163 deletions

View File

@@ -42,4 +42,5 @@ export class UpdateRouteDto {
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}

View File

@@ -81,7 +81,8 @@ export class RoutesService {
async updateRoute(id: string, dto: UpdateRouteDto) {
const route = await this.prisma.route.findUnique({ where: { id } });
if (!route) throw new NotFoundException('Route not found');
return this.prisma.route.update({
await this.prisma.route.update({
where: { id },
data: {
name: dto.name,
@@ -89,6 +90,22 @@ export class RoutesService {
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
},
});
if (dto.stops && dto.stops.length >= 2) {
await this.prisma.routeStop.deleteMany({ where: { routeId: id } });
await this.prisma.routeStop.createMany({
data: dto.stops.map(s => ({
routeId: id,
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
})),
});
}
return this.prisma.route.findUnique({
where: { id },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
}
@@ -128,6 +145,7 @@ export class RoutesService {
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`);
if (!station.isOperational) throw new BadRequestException(`Station ${dto.stationId} is not operational`);
const existing = await this.prisma.routeStop.findUnique({
where: { routeId_sequence: { routeId, sequence: dto.sequence } },

View File

@@ -24,8 +24,6 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
setIsInitializing(true);
setCameraError(null);
console.log('Starting camera...');
// Check if mediaDevices is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.';
@@ -45,7 +43,6 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
let mediaStream: MediaStream | null = null;
try {
console.log('Requesting back camera...');
// Try with environment (back) camera first
mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
@@ -55,18 +52,14 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
},
audio: false
});
console.log('Back camera acquired');
} catch (err) {
console.warn('Back camera not available, trying default camera:', err);
} catch {
// Fallback to any available camera with simple constraints
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
});
console.log('Default camera acquired');
} catch (fallbackErr) {
console.error('All camera attempts failed:', fallbackErr);
throw fallbackErr;
}
}
@@ -79,7 +72,6 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
throw new Error('Video element not found');
}
console.log('Setting video source...');
const video = videoRef.current;
video.srcObject = mediaStream;
@@ -98,29 +90,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
if (!resolved) {
resolved = true;
cleanup();
console.log('Video ready!');
resolve();
}
};
const onLoadedMetadata = () => {
console.log('Metadata loaded');
finishResolve();
};
const onLoadedMetadata = () => finishResolve();
const onLoadedData = () => finishResolve();
const onCanPlay = () => finishResolve();
const onLoadedData = () => {
console.log('Data loaded');
finishResolve();
};
const onCanPlay = () => {
console.log('Can play');
finishResolve();
};
const onVideoError = (e: Event) => {
const onVideoError = (_e: Event) => {
cleanup();
console.error('Video error:', e);
reject(new Error('Video failed to load'));
};
@@ -130,40 +109,22 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
video.addEventListener('canplay', onCanPlay);
video.addEventListener('error', onVideoError);
// Fallback timeout - but shorter since we have multiple events
setTimeout(() => {
console.log('Video load timeout, proceeding anyway');
finishResolve();
}, 2000);
setTimeout(() => finishResolve(), 2000);
});
// Play the video
console.log('Playing video...');
try {
await video.play();
console.log('Video playing');
} catch (playError) {
console.warn('Play attempt 1 failed, retrying:', playError);
// Retry play after a short delay
} catch {
await new Promise(resolve => setTimeout(resolve, 100));
try {
await video.play();
console.log('Video playing (retry succeeded)');
} catch (retryError) {
console.warn('Play retry also failed (continuing anyway):', retryError);
}
try { await video.play(); } catch { /* continue */ }
}
// Set state to show video
setStream(mediaStream);
setIsScanning(true);
setIsInitializing(false);
console.log('Camera started successfully');
console.log('isScanning state set to:', true);
console.log('isInitializing state set to:', false);
} catch (error: any) {
console.error('Camera start error:', error);
let errorMsg = 'Camera access failed. Please check permissions and try again.';
@@ -224,21 +185,17 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
try {
// Try to use jsqr if available
const jsQR = (window as any).jsQR;
if (jsQR) {
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert',
});
if (code) {
onScan(code.data);
stopCamera();
}
}
} catch (err) {
console.error('QR scan error:', err);
}
} catch { /* ignore scan errors */ }
}
}, [isScanning, onScan, stopCamera]);
@@ -269,11 +226,6 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
return (
<div className="space-y-4">
{/* Debug info */}
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
Debug: isScanning={String(isScanning)}, isInitializing={String(isInitializing)}, stream={stream ? 'active' : 'null'}
</div>
{/* Video viewer - always rendered, visibility controlled by display style */}
<div className={`space-y-3 ${!isScanning ? '!hidden' : ''}`}>
<div className="relative bg-black rounded-xl overflow-hidden" style={{ minHeight: '320px' }}>
@@ -337,9 +289,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
<p className="text-blue-600 dark:text-blue-400 text-sm text-center">
Please allow camera access when prompted by your browser
</p>
<p className="text-blue-500 dark:text-blue-500 text-xs text-center">
Check console (F12) for detailed camera logs if this takes too long
</p>
</div>
</div>
<button

View File

@@ -3,13 +3,13 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { Ticket, Users, DollarSign, Percent, AlertCircle, TrendingUp, Calendar } from 'lucide-react';
import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react';
import StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { dashboardApi } from '@/lib/api/dashboard';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
@@ -18,7 +18,6 @@ const MOCK_STATS = {
totalBookings: 1247,
totalRevenue: 892450,
totalPassengers: 2156,
occupancyRate: 78
};
const MOCK_RECENT_BOOKINGS = [
@@ -50,12 +49,6 @@ function DashboardPageContent() {
staleTime: 60000, // 1 minute
});
const { data: revenueData, isLoading: revenueLoading } = useQuery({
queryKey: ['revenue-chart'],
queryFn: () => dashboardApi.getRevenueChart(30),
retry: 1,
});
const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
@@ -68,12 +61,6 @@ function DashboardPageContent() {
retry: 1,
});
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
queryKey: ['occupancy-trend'],
queryFn: () => dashboardApi.getOccupancyTrend(7),
retry: 1,
});
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
queryKey: ['upcoming-trips'],
queryFn: () => dashboardApi.getUpcomingTrips(5),
@@ -170,7 +157,7 @@ function DashboardPageContent() {
)}
{/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
title="Total Bookings"
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
@@ -189,75 +176,6 @@ function DashboardPageContent() {
icon={Users}
color="purple"
/>
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${displayStats?.occupancyRate || 0}%`}
icon={Percent}
color="orange"
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Revenue Trend (Last 30 Days)
</h2>
{revenueLoading ? (
<div className="flex h-[300px] items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : revenueData && revenueData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-center">
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No revenue data available</p>
</div>
</div>
)}
</div>
{/* Occupancy Trend */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Percent className="h-5 w-5" />
Occupancy Trend (Last 7 Days)
</h2>
{occupancyLoading ? (
<div className="flex h-[300px] items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
) : occupancyTrend && occupancyTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={occupancyTrend}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => `${value}%`} />
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-center">
<Percent className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No occupancy data available</p>
</div>
</div>
)}
</div>
</div>
{/* Payment Methods Distribution */}

View File

@@ -33,7 +33,6 @@ export default function RoutesPage() {
queryKey: ['routes'],
queryFn: async () => {
const result = await routesApi.getAll();
console.log('Routes query result:', result);
return result;
},
});
@@ -71,7 +70,7 @@ export default function RoutesPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (!originStationId || !destinationStationId) {
alert('Please select origin and destination stations');
return;
@@ -112,9 +111,7 @@ export default function RoutesPage() {
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
stops: stopsArray,
};
console.log('Submitting route data:', JSON.stringify(routeData, null, 2));
if (editingRoute) {
await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData });
} else {
@@ -189,8 +186,8 @@ export default function RoutesPage() {
{ key: 'code', label: 'Route Code', sortable: true },
{ key: 'name', label: 'Route Name', sortable: true },
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
{
key: 'active',
{
key: 'active',
label: 'Status',
render: (route: any) => (
<Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}>
@@ -220,15 +217,20 @@ export default function RoutesPage() {
if (routeStops.length >= 2) {
setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
// Last stop's distanceKm is already cumulative from origin
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
// Last stop's distanceKm is segment distance from previous stop, so accumulate
let cumulative = 0;
const allStops = routeStops.map((stop: any) => {
cumulative += stop.distanceKm || 0;
return { ...stop, _cumulative: cumulative };
});
setDestinationDistance(allStops[allStops.length - 1]._cumulative);
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: stop.distanceKm || 0,
distanceFromOrigin: allStops[idx + 1]._cumulative,
}));
setStops(middleStops);
}
@@ -392,7 +394,7 @@ export default function RoutesPage() {
)}
</div>
</div>
<div>
<label className="label">Description</label>
<textarea
@@ -441,7 +443,7 @@ export default function RoutesPage() {
<label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
</div>
<div className="space-y-2">
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
@@ -483,8 +485,8 @@ export default function RoutesPage() {
required
>
<option value="">Select Station</option>
{stations?.items?.filter((s: any) =>
s.id !== originStationId &&
{stations?.items?.filter((s: any) =>
s.id !== originStationId &&
s.id !== destinationStationId &&
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
).map((station: any) => (
@@ -561,7 +563,7 @@ export default function RoutesPage() {
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"