mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #390 from Tria-plc/alpha
Route stops distance and other fixes
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user