diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts index 1bd3bbc27..55a6568c3 100644 --- a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -12,23 +12,28 @@ export class AddEmailToOtpVerifications1900000000000 name = "AddEmailToOtpVerifications1900000000000"; public async up(queryRunner: QueryRunner): Promise { + // The table lives in the `freight` schema (the OtpVerification entity pins + // schema: "freight"). An earlier version of this migration targeted + // `public.otp_verifications`, which does not exist there — leaving the real + // freight table without an `email` column and OTP send failing with + // `column OtpVerification.email does not exist`. Target `freight` explicitly. await queryRunner.query(` - ALTER TABLE public.otp_verifications + ALTER TABLE freight.otp_verifications ALTER COLUMN phone DROP NOT NULL `); await queryRunner.query(` - ALTER TABLE public.otp_verifications + ALTER TABLE freight.otp_verifications ADD COLUMN IF NOT EXISTS email varchar UNIQUE `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` - ALTER TABLE public.otp_verifications + ALTER TABLE freight.otp_verifications DROP COLUMN IF EXISTS email `); await queryRunner.query(` - ALTER TABLE public.otp_verifications + ALTER TABLE freight.otp_verifications ALTER COLUMN phone SET NOT NULL `); } diff --git a/apps/edr-freight-api/src/migrations/2020000000000-RepairOtpEmailSchema.ts b/apps/edr-freight-api/src/migrations/2020000000000-RepairOtpEmailSchema.ts new file mode 100644 index 000000000..e11923a4d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2020000000000-RepairOtpEmailSchema.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Repair: AddEmailToOtpVerifications1900000000000 originally altered + * `public.otp_verifications`, but the OtpVerification entity pins + * schema: "freight". On any DB where that migration already ran (and is recorded + * as executed, so it won't run again), the real `freight.otp_verifications` table + * never got the `email` column and `phone` was never made nullable — so OTP send + * dies with `column OtpVerification.email does not exist`. + * + * This migration re-applies the change against the correct schema. Idempotent + * (IF NOT EXISTS / no-op DROP NOT NULL), and guarded so it's a no-op when the + * freight table is absent. + */ +export class RepairOtpEmailSchema2020000000000 implements MigrationInterface { + name = "RepairOtpEmailSchema2020000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable("freight.otp_verifications"); + if (!exists) return; + + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable("freight.otp_verifications"); + if (!exists) return; + + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + DROP COLUMN IF EXISTS email + `); + } +} diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 67fbdec9b..e26ed35c9 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -72,8 +72,14 @@ export class OtpService { message: "OTP sent successfully", }; } catch (error) { - console.log(error); - + // Log the real cause (DB/SMS/email failure) with its stack so a deployed + // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. + this.logger.error( + `Failed to send OTP to ${target.email ?? target.phone}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); throw new BadRequestException("Failed to send OTP"); } } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx index 7eb995ac2..e253a8db8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx @@ -16,7 +16,7 @@ import { Group, Loader, Paper, - Progress, + RingProgress, Stack, Text, ThemeIcon, @@ -26,7 +26,11 @@ import { import { PageContainer } from "@/components/page"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; -import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals"; +import { + RouteCorridor, + StatusPill, + scheduleBrand, +} from "@/components/trainScheduling/scheduleVisuals"; import { freightBrand } from "@/theme/freight-brand"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -52,8 +56,11 @@ function formatDateTime(iso?: string | null) { }); } -/** Compact icon + label + value cell used in the header meta strip. */ -function MetaStat({ +/** + * A single fact in the hero's glass meta strip — icon chip + uppercase label + + * value, laid on the translucent panel over the gradient. + */ +function HeroStat({ icon, label, value, @@ -63,15 +70,33 @@ function MetaStat({ value: string; }) { return ( - - + + {icon} - - - + + + {label} - + {value} @@ -79,6 +104,38 @@ function MetaStat({ ); } +/** Section header — icon chip + title + one-line hint. Shared by the cards. */ +function SectionHead({ + icon, + title, + hint, +}: { + icon: React.ReactNode; + title: string; + hint: string; +}) { + return ( + + + {icon} + + + + {title} + + + {hint} + + + + ); +} + +const CARD_STYLE = { + borderColor: scheduleBrand.mutedBorder, + boxShadow: scheduleBrand.shadowSm, +} as const; + export default function TrainScheduleTrackPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const { toast } = useToast(); @@ -116,9 +173,13 @@ export default function TrainScheduleTrackPage() { const canLog = track.status === "DISPATCHED"; const totalStations = track.stations.length; const reached = Math.min(track.currentSequenceNo + 1, totalStations); - const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0; + const progressPct = + totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0; const clampedPct = Math.min(100, Math.max(0, progressPct)); - const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"; + const currentStation = + track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"; + const inTransit = track.status === "DISPATCHED"; + const arrived = track.status === "ARRIVED"; const handleLog = (sequenceNo: number) => { const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; @@ -156,160 +217,259 @@ export default function TrainScheduleTrackPage() { Back to schedule - {/* Header */} - - - - - - - - - - - Train tracking - - {track.trainNumber ? ( - - {track.trainNumber} - - ) : null} - {track.direction ? ( - - {track.direction} - - ) : null} - - - + {/* ── Hero: gradient wash, route + a bold progress ring woven together ── */} + + + {/* soft decorative glow, purely artistic */} + + + + {/* left — identity + route */} + + + + - - - - + + + + Train tracking + + {track.trainNumber ? ( + + {track.trainNumber} + + ) : null} + {track.direction ? ( + + {track.direction} + + ) : null} + + + + + + - {/* Journey progress */} - - - - Journey progress - - - {reached} / {totalStations} stations · {Math.round(clampedPct)}% - - - - + + + + + {arrived + ? "Journey complete" + : inTransit + ? `En route · ${currentStation}` + : "Awaiting dispatch"} + + + + - {/* Meta strip */} - - } label="Current" value={currentStation} /> - } - label="Departed" - value={formatDateTime(track.actualDepartureAt)} - /> - } - label="Arrived" - value={formatDateTime(track.actualArrivalAt)} - /> - } - label="Stations" - value={`${reached} of ${totalStations}`} + {/* right — progress ring, the artistic focal point */} + + + {Math.round(clampedPct)}% + + + {reached}/{totalStations} stops + + + } /> - + + + {/* glass meta strip below the wash */} + + } label="Current" value={currentStation} /> + } + label="Departed" + value={formatDateTime(track.actualDepartureAt)} + /> + } + label="Arrived" + value={formatDateTime(track.actualArrivalAt)} + /> + } + label="Stations" + value={`${reached} of ${totalStations}`} + /> + - {/* Corridor */} - + {/* ── Route corridor ── */} + - - - - - - - Route corridor - - - {canLog - ? "Log the train passing each station; the final station marks arrival." - : track.status === "ARRIVED" - ? "This train has arrived at its destination." - : "Tracking becomes available once the train is dispatched."} - - - - + } + title="Route corridor" + hint={ + canLog + ? "Log the train passing each station; the final station marks arrival." + : arrived + ? "This train has arrived at its destination." + : "Tracking becomes available once the train is dispatched." + } + /> - {/* Checkpoint log */} - - - - - - - - Checkpoint log - - - {track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded - - + {/* ── Checkpoint log ── */} + + + } + title="Checkpoint log" + hint={`${track.checkpoints.length} event${ + track.checkpoints.length === 1 ? "" : "s" + } recorded`} + /> {track.checkpoints.length === 0 ? ( - - - + + + - + No checkpoints yet - - Each station the train passes will be logged here with its timestamp. + + Each station the train passes will be logged here with its + timestamp. ) : ( - + {track.checkpoints.map((cp) => ( : } + bullet={ + cp.kind === "ARRIVED" ? ( + + ) : ( + + ) + } title={ @@ -319,7 +479,13 @@ export default function TrainScheduleTrackPage() { size="xs" radius="sm" variant="light" - color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "edr-green"} + color={ + cp.kind === "ARRIVED" + ? "teal" + : cp.kind === "DEPARTED" + ? "blue" + : "edr-green" + } > {cp.kind}