From 44317a6bbcd13eeb2c41f5e1a330258a0db12965 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 3 Jul 2026 14:39:47 +0000 Subject: [PATCH 1/2] Naming convention no digit or symbol allowed --- .../src/modules/warehouses/dto/allocation-rule.dto.ts | 3 ++- .../src/modules/warehouses/dto/create-warehouse.dto.ts | 3 ++- .../src/modules/warehouses/dto/fee-rule.dto.ts | 3 ++- .../src/components/warehouses/CreateWarehouseModal.tsx | 4 ++-- .../backoffice/src/components/warehouses/options.ts | 3 +++ .../backoffice/src/pages/warehouses/WarehouseRulesPage.tsx | 6 +++--- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts index 43cd6f61a..8d18d2a1f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAllocationRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiPropertyOptional({ default: 100 }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 788c798bf..5a8025948 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; @@ -7,6 +7,7 @@ export class CreateWarehouseDto { @ApiProperty() @IsString() @MaxLength(160) + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index e2c20901d..22e9f9491 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; @@ -25,6 +25,7 @@ export class FeeRuleTierDto { export class CreateFeeRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty({ enum: FEE_RULE_TYPES }) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index ad8b4109d..6494906b4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -14,7 +14,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; -import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; +import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options'; interface CreateWarehouseModalProps { opened: boolean; @@ -120,7 +120,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh placeholder="Modjo Open Warehouse" required value={form.name} - onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }} + onChange={(e) => { const v = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: v })); }} /> { }); }; +// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers. +export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, ''); + export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => { const responseData = (error as { response?: { data?: unknown } })?.response?.data; const data = responseData && typeof responseData === 'object' ? (responseData as Record) : undefined; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 7d1dbc555..b5c44e4d4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -33,7 +33,7 @@ import { } from '@/hooks/useWarehouses'; import { api } from '@/services/api'; import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; -import { extractErrorMessage } from '@/components/warehouses/options'; +import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options'; const FREIGHT = [ { value: 'CONTAINER', label: 'Container' }, @@ -253,7 +253,7 @@ function AllocationRules() { required value={form.name} onChange={(e) => { - const value = e.currentTarget.value; + const value = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: value })); }} /> @@ -569,7 +569,7 @@ function FeeRules() { required value={form.name} onChange={(e) => { - const value = e.currentTarget.value; + const value = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: value })); }} /> From 077667610aba6c56e064048666abfdb12a13eb80 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 3 Jul 2026 18:44:03 +0300 Subject: [PATCH 2/2] Add alternative schedule options if schedule not found --- .../portal/src/app/booking/results/page.tsx | 443 ++++++++++-------- 1 file changed, 260 insertions(+), 183 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 810e0689a..d1952da23 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; @@ -147,10 +147,16 @@ export default function ResultsPage() { // For one-way, check if outbound has results // For round-trip, check if BOTH outbound and inbound have results - const hasResults = isRoundTrip + const hasResults = isRoundTrip ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; + // One-way searches that come back with an empty outbound list may still include + // date-shifted alternatives from the API — surface those instead of a dead end. + const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : []; + const requestedDate: string = (results && results.requestedDate) || searchData.date; + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); }; @@ -217,7 +223,190 @@ export default function ResultsPage() { router.push('/booking/auth-check'); }; - const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => { + // Shared "Choose Your Coach" drawer — used by both the normal results view and the + // Alternative Travel Options fallback, so selecting an alternative opens the exact + // same coach-type picker as a normal schedule. + const renderClassModal = () => { + if (!classModal) return null; + + const scheduleId = classModal.scheduleId || classModal.id || ''; + const selectedCoachType = selectedCoachTypes[scheduleId]; + const isOutbound = (classModal as any).isOutbound; + const coachTypes = classModal.coachTypes || []; + + const getCoachIcon = (typeName: string) => { + const lower = typeName.toLowerCase(); + if (lower.includes('soft') || lower.includes('vip')) return Star; + if (lower.includes('bed')) return Bed; + return Armchair; + }; + + return ( + <> +
setClassModal(null)} /> +
+
+
+

Choose Your Coach

+

+ + {classModal.trainNumber} + · + {classModal.origin?.name} → {classModal.destination?.name} +

+
+ +
+ +
+ {coachTypes.length > 0 ? ( +
+ {coachTypes.map((coachType: any, index: number) => { + const isSelected = selectedCoachType?.id === coachType.coachTypeId; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const coachCurrency = 'ETB'; + const CoachIcon = getCoachIcon(coachType.coachTypeName); + + return ( + + ); + })} +
+ ) : ( +
+
+ +
+

No coach types available for this journey

+
+ )} +
+ +
+
+ + {!selectedCoachType && ( +

+ + Select a coach type to continue +

+ )} +
+
+
+ + + ); + }; + + const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false, isAlternative: boolean = false) => { const scheduleId = schedule.scheduleId || schedule.id || ''; const selectedCoachType = selectedCoachTypes[scheduleId]; @@ -242,7 +431,22 @@ export default function ResultsPage() { const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString(); return ( -
+
+ {isAlternative && ( +
+ + + Different date — {schedule.departureAt ? format(new Date(schedule.departureAt), 'EEEE, MMMM d, yyyy') : 'N/A'} + + + {schedule.hasAvailability ? 'Seats available' : 'Fully booked'} + +
+ )}
@@ -310,9 +514,10 @@ export default function ResultsPage() { )}
@@ -478,6 +683,54 @@ export default function ResultsPage() { } if (!hasResults) { + // ONE_WAY search with an explicit empty outbound list — surface any date-shifted + // alternatives the API suggests instead of a dead-end "no trains found" screen. + if (isOneWayNoOutbound) { + const requestedDateLabel = requestedDate + ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') + : 'your selected date'; + const hasAlternatives = alternativeOutbound.length > 0; + + return ( +
+ {renderClassModal()} +
+
+
+
+ +
+

No trains available

+

+ No trains are available on {requestedDateLabel}. This + may be due to no scheduled service or full capacity. {hasAlternatives + ? 'Please check the alternative options below or try a different date.' + : 'Please try a different date.'} +

+ +
+ + {hasAlternatives && ( +
+
+

Alternative Travel Options

+

+ These trains run on different dates than requested — adjust your travel date to book one of them. +

+
+
+ {alternativeOutbound.map((schedule) => renderScheduleCard(schedule, true, true))} +
+
+ )} +
+
+
+ ); + } + return (
@@ -505,183 +758,7 @@ export default function ResultsPage() {
- {classModal && (() => { - const scheduleId = classModal.scheduleId || classModal.id || ''; - const selectedCoachType = selectedCoachTypes[scheduleId]; - const isOutbound = (classModal as any).isOutbound; - const coachTypes = classModal.coachTypes || []; - - const getCoachIcon = (typeName: string) => { - const lower = typeName.toLowerCase(); - if (lower.includes('soft') || lower.includes('vip')) return Star; - if (lower.includes('bed')) return Bed; - return Armchair; - }; - - return ( - <> -
setClassModal(null)} /> -
-
-
-

Choose Your Coach

-

- - {classModal.trainNumber} - · - {classModal.origin?.name} → {classModal.destination?.name} -

-
- -
- -
- {coachTypes.length > 0 ? ( -
- {coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; - const coachCurrency = 'ETB'; - const CoachIcon = getCoachIcon(coachType.coachTypeName); - - return ( - - ); - })} -
- ) : ( -
-
- -
-

No coach types available for this journey

-
- )} -
- -
-
- - {!selectedCoachType && ( -

- - Select a coach type to continue -

- )} -
-
-
- - - ); - })()} + {renderClassModal()} {promoData && (