diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 3af67730a..1214acebc 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -201,22 +201,11 @@ export class GuestBookingService {
}
}
- // Generate unique phone number
- let guestPhone = firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`;
-
- // Check if phone exists and generate unique one if it does
- if (firstPassenger.phone) {
- const existingUserByPhone = await this.prisma.user.findUnique({ where: { phone: firstPassenger.phone } });
- if (existingUserByPhone) {
- guestPhone = `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`;
- }
- }
-
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: guestEmail,
- phone: guestPhone,
+ phone: firstPassenger.phone || `+251${uniqueId.replace(/[^0-9]/g, '').slice(0, 9)}`,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},
diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx
index 01c5aa8d1..95fc248e9 100644
--- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx
@@ -58,7 +58,7 @@ export default function AuditLogsPage() {
const actions = [
{
label: 'View Details',
- onClick: (log: any) => { window.location.href = `/audit/${log.id}`; },
+ onClick: (log: any) => window.location.href = `/audit/${log.id}`,
variant: 'secondary' as const,
icon: Eye,
},
diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx
index 8ae8dd642..371fa0c96 100644
--- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx
@@ -193,7 +193,7 @@ export default function CoachesPage() {
columns={columns}
data={coaches}
actions={actions}
- loading={isLoading}
+ isLoading={isLoading}
/>
diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
index 98036e71f..82b7c8cf5 100644
--- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
@@ -27,7 +27,7 @@ export default function DashboardPage() {
const recentBookings = Array.isArray(recentBookingsData)
? recentBookingsData
- : (recentBookingsData as any)?.items || (recentBookingsData as any)?.data || [];
+ : recentBookingsData?.items || recentBookingsData?.data || [];
const columns = [
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
index e80de0fe6..ba7bfe33b 100644
--- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
@@ -65,7 +65,7 @@ export default function PassengersPage() {
},
];
- const actions: any[] = [
+ const actions = [
// TODO: Create passenger detail page
// {
// label: 'View Details',
diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx
index 4cc2f867c..1c67df1b2 100644
--- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx
@@ -136,17 +136,15 @@ export default function RoutesPage() {
const generateRouteCode = (originId: string, destId: string) => {
if (!originId || !destId) return '';
- const stationsList = Array.isArray(stations) ? stations : (stations as any)?.items || [];
- const origin = stationsList.find((s: any) => s.id === originId);
- const dest = stationsList.find((s: any) => s.id === destId);
+ const origin = stations?.items?.find((s: any) => s.id === originId);
+ const dest = stations?.items?.find((s: any) => s.id === destId);
return origin && dest ? `${origin.code}-${dest.code}` : '';
};
const generateRouteName = (originId: string, destId: string) => {
if (!originId || !destId) return '';
- const stationsList = Array.isArray(stations) ? stations : (stations as any)?.items || [];
- const origin = stationsList.find((s: any) => s.id === originId);
- const dest = stationsList.find((s: any) => s.id === destId);
+ const origin = stations?.items?.find((s: any) => s.id === originId);
+ const dest = stations?.items?.find((s: any) => s.id === destId);
return origin && dest ? `${origin.name} - ${dest.name}` : '';
};
@@ -274,7 +272,7 @@ export default function RoutesPage() {
disabled={!!editingRoute}
>
Select Origin
- {(Array.isArray(stations) ? stations : (stations as any)?.items || []).map((station: any) => (
+ {stations?.items?.map((station: any) => (
{station.name} ({station.code})
@@ -291,7 +289,7 @@ export default function RoutesPage() {
disabled={!!editingRoute}
>
Select Destination
- {(Array.isArray(stations) ? stations : (stations as any)?.items || []).map((station: any) => (
+ {stations?.items?.map((station: any) => (
{station.name} ({station.code})
@@ -375,8 +373,8 @@ export default function RoutesPage() {
{originStationId ? (
- {(Array.isArray(stations) ? stations : (stations as any)?.items || []).find((s: any) => s.id === originStationId)?.name || 'Unknown'}
- {' '}({(Array.isArray(stations) ? stations : (stations as any)?.items || []).find((s: any) => s.id === originStationId)?.code || 'N/A'})
+ {stations?.items?.find((s: any) => s.id === originStationId)?.name || 'Unknown'}
+ {' '}({stations?.items?.find((s: any) => s.id === originStationId)?.code || 'N/A'})
) : (
Select origin station above
@@ -401,7 +399,7 @@ export default function RoutesPage() {
required
>
Select Station
- {(Array.isArray(stations) ? stations : (stations as any)?.items || []).filter((s: any) =>
+ {stations?.items?.filter((s: any) =>
s.id !== originStationId &&
s.id !== destinationStationId &&
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
@@ -457,8 +455,8 @@ export default function RoutesPage() {
{destinationStationId ? (
- {(Array.isArray(stations) ? stations : (stations as any)?.items || []).find((s: any) => s.id === destinationStationId)?.name || 'Unknown'}
- {' '}({(Array.isArray(stations) ? stations : (stations as any)?.items || []).find((s: any) => s.id === destinationStationId)?.code || 'N/A'})
+ {stations?.items?.find((s: any) => s.id === destinationStationId)?.name || 'Unknown'}
+ {' '}({stations?.items?.find((s: any) => s.id === destinationStationId)?.code || 'N/A'})
) : (
Select destination station above
diff --git a/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx
index 9c09138b5..d4a06f1f9 100644
--- a/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx
@@ -125,7 +125,7 @@ export default function SeatClassesPage() {
{
- if (!params) return '';
- const cleanParams = Object.fromEntries(
- Object.entries(params).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
- );
- return Object.keys(cleanParams).length ? `?${new URLSearchParams(cleanParams as Record).toString()}` : '';
-};
-
// Bookings API
export const bookingsApi = {
getAll: async (params?: any) => {
- const response = await apiClient.get(`/bookings${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const cleanParams = Object.fromEntries(
+ Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
+ );
+ const query = new URLSearchParams(cleanParams).toString();
+ const response = await apiClient.get(`/bookings${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get(`/bookings/${id}`),
cancel: (id: string, data?: any) => apiClient.post(`/bookings/${id}/cancel`, data),
@@ -23,18 +22,32 @@ export const bookingsApi = {
// Passengers API
export const passengersApi = {
getAll: async (params?: any) => {
- const response = await apiClient.get(`/passengers${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const cleanParams = Object.fromEntries(
+ Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
+ );
+ const query = new URLSearchParams(cleanParams).toString();
+ const response = await apiClient.get(`/passengers${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get(`/passengers/${id}`),
verify: (nationalId: string) => apiClient.post('/passengers/verify-fayda', { nationalId }),
};
-
// Stations API
export const stationsApi = {
getAll: async (params?: any) => {
- const response = await apiClient.get(`/stations${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const cleanParams = Object.fromEntries(
+ Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
+ );
+ const query = new URLSearchParams(cleanParams).toString();
+ const response = await apiClient.get(`/stations${query ? `?${query}` : ''}`);
+ // Handle wrapped response: { success, data: [...], timestamp }
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get(`/stations/${id}`),
create: (data: any) => apiClient.post('/stations', data),
@@ -45,12 +58,26 @@ export const stationsApi = {
// Fleet API
export const fleetApi = {
getTrains: async (params?: any) => {
- const response = await apiClient.get(`/fleet/trains${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const cleanParams = Object.fromEntries(
+ Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
+ );
+ const query = new URLSearchParams(cleanParams).toString();
+ const response = await apiClient.get(`/fleet/trains${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getCoaches: async (params?: any) => {
- const response = await apiClient.get(`/fleet/coaches${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const cleanParams = Object.fromEntries(
+ Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
+ );
+ const query = new URLSearchParams(cleanParams).toString();
+ const response = await apiClient.get(`/fleet/coaches${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
createTrain: (data: any) => apiClient.post('/fleet/trains', data),
updateTrain: (id: string, data: any) => apiClient.patch(`/fleet/trains/${id}`, data),
@@ -63,8 +90,12 @@ export const fleetApi = {
// Schedules API
export const schedulesApi = {
getAll: async (params?: any) => {
- const response = await apiClient.get(`/schedules${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/schedules${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get(`/schedules/${id}`),
create: (data: any) => apiClient.post('/schedules', data),
@@ -94,8 +125,12 @@ export const seatsApi = {
// Payments API
export const paymentsApi = {
getAll: async (params?: any) => {
- const response = await apiClient.get(`/payments${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/payments${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get(`/payments/${id}`),
refund: (id: string, data: any) => apiClient.post(`/payments/${id}/refund`, data),
@@ -105,8 +140,12 @@ export const paymentsApi = {
// Tickets API
export const ticketsApi = {
getAll: async (params?: any) => {
- const response = await apiClient.get(`/tickets${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/tickets${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get(`/tickets/${id}`),
validate: (ticketId: string, data: any) => apiClient.post(`/tickets/${ticketId}/validate`, data),
@@ -116,8 +155,12 @@ export const ticketsApi = {
// Agents API
export const agentsApi = {
getAll: async (params?: any) => {
- const response = await apiClient.get(`/agents${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/agents${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get(`/agents/${id}`),
create: (data: any) => apiClient.post('/agents', data),
@@ -131,8 +174,12 @@ export const agentsApi = {
// Loyalty API
export const loyaltyApi = {
getAccounts: async (params?: any) => {
- const response = await apiClient.get(`/loyalty/accounts${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/loyalty/accounts${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getAccount: (passengerId: string) => apiClient.get(`/loyalty/accounts/${passengerId}`),
adjustPoints: (accountId: string, data: any) => apiClient.post(`/loyalty/accounts/${accountId}/adjust`, data),
@@ -143,8 +190,12 @@ export const loyaltyApi = {
// Wallet API
export const walletApi = {
getAccounts: async (params?: any) => {
- const response = await apiClient.get(`/wallet/accounts${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/wallet/accounts${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getAccount: (passengerId: string) => apiClient.get(`/wallet/accounts/${passengerId}`),
adjustBalance: (accountId: string, data: any) => apiClient.post(`/wallet/accounts/${accountId}/adjust`, data),
@@ -154,8 +205,12 @@ export const walletApi = {
// Promotions API
export const promotionsApi = {
getAll: async (params?: any) => {
- const response = await apiClient.get(`/promos${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/promos${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get(`/promos/${id}`),
create: (data: any) => apiClient.post('/promos', data),
@@ -166,8 +221,12 @@ export const promotionsApi = {
// Support API
export const supportApi = {
getConversations: async (params?: any) => {
- const response = await apiClient.get(`/support/conversations${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/support/conversations${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getConversation: (id: string) => apiClient.get(`/support/conversations/${id}`),
updateStatus: (id: string, status: string) => apiClient.patch(`/support/conversations/${id}/status`, { status }),
@@ -183,16 +242,24 @@ export const notificationsApi = {
updateTemplate: (id: string, data: any) => apiClient.patch(`/notifications/templates/${id}`, data),
send: (data: any) => apiClient.post('/notifications/send', data),
getHistory: async (params?: any) => {
- const response = await apiClient.get(`/notifications/history${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/notifications/history${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
};
// Fraud API
export const fraudApi = {
getAlerts: async (params?: any) => {
- const response = await apiClient.get(`/fraud/alerts${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/fraud/alerts${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
acknowledgeAlert: (id: string) => apiClient.patch(`/fraud/alerts/${id}/acknowledge`),
getRules: () => apiClient.get('/fraud/rules'),
@@ -203,8 +270,12 @@ export const fraudApi = {
// Verifayda API
export const verifaydaApi = {
getVerifications: async (params?: any) => {
- const response = await apiClient.get(`/passengers/verifications${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/passengers/verifications${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
verify: (nationalId: string) => apiClient.post('/passengers/verify-fayda', { nationalId }),
getStats: () => apiClient.get('/passengers/verification-stats'),
@@ -213,8 +284,12 @@ export const verifaydaApi = {
// Audit API
export const auditApi = {
getLogs: async (params?: any) => {
- const response = await apiClient.get(`/audit/logs${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/audit/logs${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getLog: (id: string) => apiClient.get(`/audit/logs/${id}`),
};
@@ -241,8 +316,12 @@ export const foodApi = {
getCategories: () => apiClient.get('/food/categories'),
getMenuItems: (scheduleId: string) => apiClient.get(`/food/menu/${scheduleId}`),
getOrders: async (params?: any) => {
- const response = await apiClient.get(`/food/orders${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/food/orders${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
updateOrderStatus: (orderId: string, status: string) => apiClient.patch(`/food/orders/${orderId}/status`, { status }),
createMenuItem: (data: any) => apiClient.post('/food/menu-items', data),
@@ -251,8 +330,12 @@ export const foodApi = {
// Reports API
export const reportsApi = {
getOperationalReports: async (params?: any) => {
- const response = await apiClient.get(`/reports/operational${buildQuery(params)}`);
- return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : (Array.isArray(response) ? { items: response } : response);
+ const query = new URLSearchParams(params).toString();
+ const response = await apiClient.get(`/reports/operational${query ? `?${query}` : ''}`);
+ if (response?.data) {
+ return Array.isArray(response.data) ? { items: response.data } : response;
+ }
+ return Array.isArray(response) ? { items: response } : response;
},
getRevenue: (params?: any) => apiClient.get('/reports/revenue', { params }),
getOccupancy: (params?: any) => apiClient.get('/reports/occupancy', { params }),
diff --git a/apps/edr-passenger-web/portal/README.md b/apps/edr-passenger-web/portal/README.md
index a88c6f46d..b1e79bbab 100644
--- a/apps/edr-passenger-web/portal/README.md
+++ b/apps/edr-passenger-web/portal/README.md
@@ -14,13 +14,6 @@ Modern Next.js 14 web application for the Ethio-Djibouti Railway passenger booki
7. **Payment** - Choose payment method and process payment
8. **Confirmation** - View PNR, tickets with QR codes
-### Dashboard (Backoffice)
-- **Dashboard** - Overview of passenger operations
-- **Tickets** - Issue, view, and manage tickets
-- **Schedules** - View train schedules and details
-- **Stations** - Browse station directory
-- **Passengers** - Passenger management
-
### Key Capabilities
- **Fayda 2.0 Integration** - Ethiopian national ID verification
- **Age-Based Pricing** - First child travels free
@@ -47,7 +40,7 @@ Modern Next.js 14 web application for the Ethio-Djibouti Railway passenger booki
### Prerequisites
- Node.js >= 20.x
- pnpm >= 9.x
-- EDR Passenger API running on port 4000
+- EDR Passenger API running on port 3002
### Installation
@@ -59,7 +52,7 @@ pnpm install
cp .env.example .env.local
# Update .env.local with API URL
-NEXT_PUBLIC_API_URL=http://localhost:4000
+NEXT_PUBLIC_API_URL=http://localhost:3002
```
### Development
@@ -86,13 +79,6 @@ pnpm start
```
src/
├── app/ # Next.js App Router pages
-│ ├── (dashboard)/ # Dashboard route group
-│ │ ├── dashboard/ # Overview page
-│ │ ├── tickets/ # Ticket management
-│ │ ├── schedules/ # Schedule views
-│ │ ├── stations/ # Station directory
-│ │ ├── passengers/ # Passenger management
-│ │ └── layout.tsx # Dashboard layout wrapper
│ ├── booking/
│ │ ├── search/ # Search trains
│ │ ├── results/ # Search results
@@ -103,33 +89,19 @@ src/
│ │ ├── payment/ # Payment processing
│ │ └── confirmation/ # Booking confirmation
│ ├── login/ # Login page
-│ ├── profile/ # User profile
-│ ├── guide/ # Travel guide
│ ├── layout.tsx # Root layout
-│ ├── page.tsx # Home (redirects to dashboard)
-│ ├── providers.tsx # React Query + Theme provider
+│ ├── page.tsx # Home (redirects to search)
+│ ├── providers.tsx # React Query provider
│ └── globals.css # Global styles
├── components/ # Reusable components
-│ ├── schedules/ # Schedule components
-│ ├── tickets/ # Ticket components
-│ ├── seats/ # Seat selector
-│ ├── AppHeader.tsx # Navigation header
-│ └── ThemeProvider.tsx # Theme context
├── lib/ # Core utilities
│ ├── api-client.ts # Axios client with interceptors
│ ├── auth-store.ts # Auth state (Zustand)
│ ├── booking-store.ts # Booking flow state (Zustand)
│ └── payment-store.ts # Payment state (Zustand)
-├── hooks/ # Custom React hooks
-│ ├── useSchedules.ts # Schedule queries
-│ ├── useStations.ts # Station queries
-│ └── useTickets.ts # Ticket queries
-├── services/ # API service layer
-│ ├── schedules.service.ts
-│ ├── stations.service.ts
-│ └── tickets.service.ts
-└── types/ # TypeScript types
- └── index.ts
+├── types/ # TypeScript types
+│ └── index.ts
+└── hooks/ # Custom React hooks
```
## State Management
diff --git a/apps/edr-passenger-web/portal/index.html b/apps/edr-passenger-web/portal/index.html
new file mode 100644
index 000000000..38eb8605d
--- /dev/null
+++ b/apps/edr-passenger-web/portal/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ EDR Passenger Portal
+
+
+
+
+
+
diff --git a/apps/edr-passenger-web/portal/next.config.js b/apps/edr-passenger-web/portal/next.config.js
index d0993180a..9d962888f 100644
--- a/apps/edr-passenger-web/portal/next.config.js
+++ b/apps/edr-passenger-web/portal/next.config.js
@@ -2,10 +2,6 @@
const nextConfig = {
reactStrictMode: true,
transpilePackages: ['@edr/types', '@edr/ui-common'],
- output: 'standalone',
- experimental: {
- cpus: 1,
- },
};
export default nextConfig;
diff --git a/apps/edr-passenger-web/portal/src/App.tsx b/apps/edr-passenger-web/portal/src/App.tsx
new file mode 100644
index 000000000..4e890f1c3
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/App.tsx
@@ -0,0 +1,53 @@
+import {
+ useNavigate,
+ useLocation,
+ Routes,
+ Route,
+ Navigate,
+} from "react-router-dom";
+import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
+
+import TicketsPage from "./pages/tickets/TicketsPage";
+import TicketDetailPage from "./pages/tickets/TicketDetailPage";
+import BookTicketPage from "./pages/tickets/BookTicketPage";
+import SchedulesPage from "./pages/schedules/SchedulesPage";
+import ScheduleDetailPage from "./pages/schedules/ScheduleDetailPage";
+import StationsPage from "./pages/stations/StationsPage";
+import PassengersPage from "./pages/passengers/PassengersPage";
+import DashboardPage from "./pages/dashboard/DashboardPage";
+
+const sidebarItems: SidebarItem[] = [
+ { label: "Dashboard", href: "/" },
+ { label: "Tickets", href: "/tickets" },
+ { label: "Schedules", href: "/schedules" },
+ { label: "Stations", href: "/stations" },
+ { label: "Passengers", href: "/passengers" },
+];
+
+const App = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+};
+
+export default App;
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
index 12d6bd5a7..7ed28a907 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
@@ -9,8 +9,6 @@ import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucid
import { QRCodeSVG } from 'qrcode.react';
import { format } from 'date-fns';
-export const dynamic = 'force-dynamic';
-
export default function ConfirmationPage() {
const router = useRouter();
const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore();
@@ -20,7 +18,7 @@ export default function ConfirmationPage() {
mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }),
});
- useQuery({
+ const { data: booking } = useQuery({
queryKey: ['booking', bookingId],
queryFn: async () => {
try {
@@ -43,8 +41,7 @@ export default function ConfirmationPage() {
if (bookingId && !confirmMutation.isSuccess && !confirmMutation.isPending) {
confirmMutation.mutate();
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [bookingId, confirmMutation]);
+ }, [bookingId]);
const copyPNR = () => {
if (pnr) {
diff --git a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx
index 4438f9903..276a3af51 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx
@@ -3,8 +3,6 @@
import { usePathname } from 'next/navigation';
import { ProgressIndicator } from '@/components/ProgressIndicator';
-export const dynamic = 'force-dynamic';
-
export default function BookingLayout({
children,
}: {
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
index 078d3cc73..7ed3e1a83 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
@@ -10,8 +10,6 @@ import { apiClient } from '@/lib/api-client';
import { useState, useEffect } from 'react';
import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react';
-export const dynamic = 'force-dynamic';
-
const passengerSchema = z.object({
name: z.string().min(2, 'Name is required'),
dateOfBirth: z.string().min(1, 'Date of birth is required'),
@@ -50,7 +48,7 @@ type FormData = z.infer;
export default function PassengersPage() {
const router = useRouter();
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
- const { user, isAuthenticated, updateUser } = useAuthStore();
+ const { user, isAuthenticated, logout, updateUser } = useAuthStore();
const [faydaEnabled, setFaydaEnabled] = useState(true);
const [verificationStatus, setVerificationStatus] = useState>({});
const [updatingUser, setUpdatingUser] = useState(false);
@@ -61,7 +59,7 @@ export default function PassengersPage() {
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
- passengers: Array.from({ length: totalPassengers }, () => ({
+ passengers: Array.from({ length: totalPassengers }, (_, i) => ({
name: '',
dateOfBirth: '',
gender: undefined,
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 536fe435b..200e378e6 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
@@ -9,8 +9,6 @@ import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, Chevro
import { format } from 'date-fns';
import { useState } from 'react';
-export const dynamic = 'force-dynamic';
-
export default function ResultsPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -41,11 +39,11 @@ export default function ResultsPage() {
const { data: results, isLoading, error } = useQuery({
queryKey: ['search', searchData],
- queryFn: async (): Promise => {
+ queryFn: async () => {
console.log('Searching with criteria:', searchData);
- const response = await apiClient.post('/search', searchData) as Schedule[];
+ const response = await apiClient.post('/search', searchData);
console.log('Search results:', response);
- console.log('Number of results:', Array.isArray(response) ? response.length : 0);
+ console.log('Number of results:', response?.length || 0);
return response;
},
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
@@ -92,8 +90,8 @@ export default function ResultsPage() {
trainNumber: schedule.trainNumber,
origin: schedule.origin?.name || 'Origin',
destination: schedule.destination?.name || 'Destination',
- departureTime: schedule.departureAt || schedule.departureTime || '',
- arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '',
+ departureTime: schedule.departureAt || schedule.departureTime,
+ arrivalTime: schedule.arrivalAt || schedule.arrivalTime,
duration: durationStr,
baseFareAdult: selectedClassFare.baseFareMinor,
baseFareChild: selectedClassFare.baseFareMinor,
@@ -142,7 +140,7 @@ export default function ResultsPage() {
No Trains Found
- We couldn't find any trains matching your search criteria. Try adjusting your dates or route.
+ We couldn't find any trains matching your search criteria. Try adjusting your dates or route.
router.push(buildSearchUrl())} className="btn-primary">
Modify Search
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
index 14c3d2574..2359dc8e7 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
@@ -209,9 +209,9 @@ export default function ReviewPage() {
const baseFare = passengers.reduce((sum, p, i) => {
// Get the fare per passenger from the schedule
const farePerPassenger = selectedSchedule.baseFareAdult ||
- (selectedSchedule as any).baseFare ||
- (selectedSchedule as any).fareAdult ||
- (selectedSchedule as any).price ||
+ selectedSchedule.baseFare ||
+ selectedSchedule.fareAdult ||
+ selectedSchedule.price ||
0;
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
index af0a5a896..10c3f2526 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx
@@ -8,12 +8,10 @@ import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store';
import { Station } from '@/types';
-import { Train, MapPin, Calendar, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react';
+import { Train, MapPin, Calendar, Users, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react';
import { useEffect } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker';
-export const dynamic = 'force-dynamic';
-
const searchSchema = z.object({
originStationId: z.string().min(1, 'Please select origin station'),
destinationStationId: z.string().min(1, 'Please select destination station'),
@@ -35,8 +33,8 @@ export default function SearchPage() {
const { data: stations, isLoading, error } = useQuery({
queryKey: ['stations'],
- queryFn: async (): Promise => {
- const response = await apiClient.get('/stations') as Station[];
+ queryFn: async () => {
+ const response = await apiClient.get('/stations');
return response;
},
});
@@ -96,10 +94,10 @@ export default function SearchPage() {
};
const getStationByName = (name: string) => {
- if (!stations || !Array.isArray(stations)) return null;
- const exactMatch = stations.find((s: Station) => s.name.toLowerCase() === name.toLowerCase());
+ if (!stations) return null;
+ const exactMatch = stations.find(s => s.name.toLowerCase() === name.toLowerCase());
if (exactMatch) return exactMatch;
- return stations.find((s: Station) => s.name.toLowerCase().includes(name.toLowerCase()));
+ return stations.find(s => s.name.toLowerCase().includes(name.toLowerCase()));
};
const handlePopularRoute = (fromName: string, toName: string) => {
@@ -157,7 +155,7 @@ export default function SearchPage() {
disabled={isLoading}
>
Select departure station
- {Array.isArray(stations) && stations.map((s: Station) => (
+ {stations?.map((s) => (
{s.name}
))}
@@ -186,7 +184,7 @@ export default function SearchPage() {
disabled={isLoading}
>
Select arrival station
- {Array.isArray(stations) && stations.map((s: Station) => (
+ {stations?.map((s) => (
{s.name}
))}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
index 82ce081dc..bc80d0f2d 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
@@ -4,16 +4,16 @@ import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
-import { useState, useEffect, useMemo } from 'react';
+import { useState, useEffect } from 'react';
+import { Seat, Coach } from '@/types';
import CustomModal from '@/components/CustomModal';
-export const dynamic = 'force-dynamic';
-
export default function SeatsPage() {
const router = useRouter();
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore();
const [selectedSeats, setSelectedSeats] = useState([]);
const [selectedCoach, setSelectedCoach] = useState(null);
+ const [timeLeft, setTimeLeft] = useState(null);
const [modalState, setModalState] = useState({
isOpen: false,
title: '',
@@ -32,14 +32,14 @@ export default function SeatsPage() {
if (seatMapData) {
console.log('Seat map data:', seatMapData);
console.log('Is array?', Array.isArray(seatMapData));
- console.log('Has coaches?', (seatMapData as any)?.coaches);
+ console.log('Has coaches?', seatMapData?.coaches);
}
}, [seatMapData]);
const holdMutation = useMutation({
mutationFn: async (seatIds: string[]) => {
// Create temporary passenger IDs for the hold
- const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({
+ const passengersForHold = passengers.slice(0, seatIds.length).map((p, i) => ({
passengerId: `temp-${Date.now()}-${i}`, // Temporary ID for guest booking
seatId: seatIds[i],
}));
@@ -60,7 +60,7 @@ export default function SeatsPage() {
});
// Extract coaches and seats from seat map data
- const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
+ const coaches = seatMapData?.coaches || [];
// Debug: Log coaches
useEffect(() => {
@@ -78,11 +78,10 @@ export default function SeatsPage() {
? coaches.filter((c: any) => {
// seatClass can be either a string or an object with a name property
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
- const selectedClass = selectedSchedule.selectedSeatClass || '';
- console.log('Comparing:', seatClassName, 'with', selectedClass);
- return seatClassName === selectedClass ||
- seatClassName.replace(/_/g, ' ').toLowerCase() === selectedClass.toLowerCase() ||
- seatClassName.toLowerCase() === selectedClass.toLowerCase();
+ console.log('Comparing:', seatClassName, 'with', selectedSchedule.selectedSeatClass);
+ return seatClassName === selectedSchedule.selectedSeatClass ||
+ seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass.toLowerCase() ||
+ seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass.toLowerCase();
})
: coaches;
@@ -93,7 +92,7 @@ export default function SeatsPage() {
}, [filteredCoaches]);
const selectedCoachData = filteredCoaches.find((c: any) => c.id === selectedCoach);
- const seats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
+ const seats = selectedCoachData?.seats || [];
// Debug seats
useEffect(() => {
diff --git a/apps/edr-passenger-web/portal/src/app/guide/page.tsx b/apps/edr-passenger-web/portal/src/app/guide/page.tsx
index 5e48615ea..de1d707da 100644
--- a/apps/edr-passenger-web/portal/src/app/guide/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/guide/page.tsx
@@ -95,7 +95,7 @@ export default function HowToGuidePage() {
For Ethiopian Citizens:
-
Click "Verify with Fayda" to auto-fill your details using your national ID.
+
Click "Verify with Fayda" to auto-fill your details using your national ID.
For International Travelers:
diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx
index b4becfa40..8eacd9c10 100644
--- a/apps/edr-passenger-web/portal/src/app/login/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx
@@ -9,8 +9,6 @@ import { useBookingStore } from '@/lib/booking-store';
import { useState } from 'react';
import { Train } from 'lucide-react';
-export const dynamic = 'force-dynamic';
-
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
diff --git a/apps/edr-passenger-web/portal/src/app/profile/page.tsx b/apps/edr-passenger-web/portal/src/app/profile/page.tsx
index 01a5dc45e..67d15f30a 100644
--- a/apps/edr-passenger-web/portal/src/app/profile/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/profile/page.tsx
@@ -5,9 +5,9 @@ import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/components/ThemeProvider';
import {
- User, Settings, Ticket, Calendar, MapPin,
- Download, Trash2, Lock, Bell, CreditCard,
- MapPinned, Palette, CheckCircle,
+ User, Settings, Ticket, ChevronRight, Calendar, MapPin,
+ Download, Trash2, Lock, Bell, CreditCard, Globe,
+ MapPinned, Palette, CheckCircle, XCircle, Clock,
Eye, Edit, LogOut, X
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
@@ -84,11 +84,11 @@ export default function ProfilePage() {
}
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
- const { data: bookings, isLoading: loadingBookings } = useQuery
({
+ const { data: bookings, isLoading: loadingBookings } = useQuery({
queryKey: ['user-bookings'],
- queryFn: async (): Promise => {
+ queryFn: async () => {
try {
- return await apiClient.get('/bookings/my-bookings') as Booking[];
+ return await apiClient.get('/bookings/my-bookings');
} catch {
return [];
}
@@ -98,7 +98,7 @@ export default function ProfilePage() {
const updateProfileMutation = useMutation({
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
- onSuccess: (response: any) => {
+ onSuccess: (response) => {
const updatedData = response.data || response;
updateUser(updatedData);
setShowEditProfile(false);
@@ -143,7 +143,7 @@ export default function ProfilePage() {
const downloadDataMutation = useMutation({
mutationFn: () => apiClient.get('/auth/download-data'),
- onSuccess: (data: any) => {
+ onSuccess: (data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
index 145b5daca..326570d97 100644
--- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
+++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
@@ -1,13 +1,15 @@
'use client';
-import { Train, User, BookOpen, LogIn } from 'lucide-react';
+import { Train, LogOut, User, BookOpen, LogIn } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import Link from 'next/link';
import { useAuthStore } from '@/lib/auth-store';
+import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function AppHeader() {
const { user, isAuthenticated, initialize } = useAuthStore();
+ const router = useRouter();
useEffect(() => {
initialize();
diff --git a/apps/edr-passenger-web/portal/src/components/DualCalendarPicker.tsx b/apps/edr-passenger-web/portal/src/components/DualCalendarPicker.tsx
index 4c8a7f24f..cc437e251 100644
--- a/apps/edr-passenger-web/portal/src/components/DualCalendarPicker.tsx
+++ b/apps/edr-passenger-web/portal/src/components/DualCalendarPicker.tsx
@@ -29,7 +29,7 @@ export default function DualCalendarPicker({
}: DualCalendarPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
- const [, setCurrentDate] = useState(value || new Date());
+ const [currentDate, setCurrentDate] = useState(value || new Date());
const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth());
const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear());
diff --git a/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts b/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts
index aad31a4c5..ee762d9a8 100644
--- a/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts
+++ b/apps/edr-passenger-web/portal/src/lib/ethiopian-calendar.ts
@@ -99,7 +99,7 @@ export function ethiopianToGregorian(ethDate: EthiopianDate): Date {
/**
* Get day of year from date (1-366)
*/
-export function getDayOfYear(date: Date): number {
+function getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
const oneDay = 1000 * 60 * 60 * 24;
@@ -109,7 +109,7 @@ export function getDayOfYear(date: Date): number {
/**
* Convert day of year to Date object
*/
-export function dayOfYearToDate(year: number, dayOfYear: number): Date {
+function dayOfYearToDate(year: number, dayOfYear: number): Date {
const date = new Date(year, 0);
date.setDate(dayOfYear);
return date;
diff --git a/apps/edr-passenger-web/portal/src/main.tsx b/apps/edr-passenger-web/portal/src/main.tsx
new file mode 100644
index 000000000..67557625f
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/main.tsx
@@ -0,0 +1,18 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import App from "./App";
+
+const queryClient = new QueryClient();
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/apps/edr-passenger-web/portal/src/pages/dashboard/DashboardPage.tsx b/apps/edr-passenger-web/portal/src/pages/dashboard/DashboardPage.tsx
new file mode 100644
index 000000000..71bc2e539
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/dashboard/DashboardPage.tsx
@@ -0,0 +1,13 @@
+const DashboardPage = () => (
+
+
+ Passenger Dashboard
+
+
+ Daily ridership, ticket sales, occupancy by class, and schedule status go
+ here.
+
+
+);
+
+export default DashboardPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/passengers/PassengersPage.tsx b/apps/edr-passenger-web/portal/src/pages/passengers/PassengersPage.tsx
new file mode 100644
index 000000000..936b5f573
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/passengers/PassengersPage.tsx
@@ -0,0 +1,10 @@
+const PassengersPage = () => (
+
+
Passengers
+
+ Passenger directory. Connect to /passengers when ready.
+
+
+);
+
+export default PassengersPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/schedules/ScheduleDetailPage.tsx b/apps/edr-passenger-web/portal/src/pages/schedules/ScheduleDetailPage.tsx
new file mode 100644
index 000000000..304b3c020
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/schedules/ScheduleDetailPage.tsx
@@ -0,0 +1,36 @@
+import { useParams } from "react-router-dom";
+
+import { useSchedule } from "@/hooks/useSchedules";
+
+const ScheduleDetailPage = () => {
+ const { id } = useParams<{ id: string }>();
+ const { data, isLoading } = useSchedule(id ?? "");
+
+ if (isLoading) return Loading…
;
+ if (!data)
+ return Schedule not found.
;
+
+ return (
+
+
+ Schedule {data.trainCode}
+
+
+ Status
+ {data.status}
+ Departure
+
+ {new Date(data.departureTime).toLocaleString()}
+
+ Arrival
+
+ {new Date(data.arrivalTime).toLocaleString()}
+
+ Base price
+ {data.basePrice.toFixed(2)}
+
+
+ );
+};
+
+export default ScheduleDetailPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/schedules/SchedulesPage.tsx b/apps/edr-passenger-web/portal/src/pages/schedules/SchedulesPage.tsx
new file mode 100644
index 000000000..a51d63f91
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/schedules/SchedulesPage.tsx
@@ -0,0 +1,20 @@
+import ScheduleTable from "../../components/schedules/ScheduleTable";
+import { useSchedules } from "../../hooks/useSchedules";
+
+const SchedulesPage = () => {
+ const { data, isLoading } = useSchedules();
+ const items = data ?? [];
+
+ return (
+
+
Schedules
+ {isLoading ? (
+
Loading…
+ ) : (
+
+ )}
+
+ );
+};
+
+export default SchedulesPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/stations/StationsPage.tsx b/apps/edr-passenger-web/portal/src/pages/stations/StationsPage.tsx
new file mode 100644
index 000000000..53c5ae1fb
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/stations/StationsPage.tsx
@@ -0,0 +1,32 @@
+import { Table, type TableColumn } from "@edr/ui-common";
+import type { Passenger } from "@edr/types";
+
+import { useStations } from "../../hooks/useStations";
+
+const columns: TableColumn[] = [
+ { key: "code", header: "Code" },
+ { key: "name", header: "Name" },
+ { key: "city", header: "City" },
+ { key: "country", header: "Country" },
+];
+
+const StationsPage = () => {
+ const { data, isLoading } = useStations();
+ return (
+
+
Stations
+ {isLoading ? (
+
Loading…
+ ) : (
+
row.id}
+ emptyMessage="No stations"
+ />
+ )}
+
+ );
+};
+
+export default StationsPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/tickets/BookTicketPage.tsx b/apps/edr-passenger-web/portal/src/pages/tickets/BookTicketPage.tsx
new file mode 100644
index 000000000..8f02a9264
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/tickets/BookTicketPage.tsx
@@ -0,0 +1,32 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+
+import TicketForm from "../../components/tickets/TicketForm";
+import { ticketsService } from "../../services/tickets.service";
+
+const BookTicketPage = () => {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: ticketsService.create,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tickets"] });
+ navigate("/tickets");
+ },
+ });
+
+ return (
+
+
+ Issue ticket
+
+
+
+ );
+};
+
+export default BookTicketPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/tickets/TicketDetailPage.tsx b/apps/edr-passenger-web/portal/src/pages/tickets/TicketDetailPage.tsx
new file mode 100644
index 000000000..d53614726
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/tickets/TicketDetailPage.tsx
@@ -0,0 +1,38 @@
+import { useParams } from "react-router-dom";
+
+import { useTicket } from "../../hooks/useTickets";
+
+const TicketDetailPage = () => {
+ const { id } = useParams<{ id: string }>();
+ const { data, isLoading } = useTicket(id ?? "");
+
+ if (isLoading) return Loading…
;
+ if (!data)
+ return Ticket not found.
;
+
+ return (
+
+
+ Ticket {data.reference}
+
+
+ Passenger ID
+ {data.passengerId}
+ Schedule ID
+ {data.scheduleId}
+ Seat ID
+ {data.seatId}
+ Status
+ {data.status}
+ Issued
+
+ {new Date(data.issuedAt).toLocaleString()}
+
+ Price paid
+ {data.pricePaid.toFixed(2)}
+
+
+ );
+};
+
+export default TicketDetailPage;
diff --git a/apps/edr-passenger-web/portal/src/pages/tickets/TicketsPage.tsx b/apps/edr-passenger-web/portal/src/pages/tickets/TicketsPage.tsx
new file mode 100644
index 000000000..b33471fac
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/pages/tickets/TicketsPage.tsx
@@ -0,0 +1,28 @@
+import { Link } from "react-router-dom";
+import { Button } from "@edr/ui-common";
+
+import TicketTable from "../../components/tickets/TicketTable";
+import { useTickets } from "../../hooks/useTickets";
+
+const TicketsPage = () => {
+ const { data, isLoading } = useTickets();
+ const items = data?.items ?? [];
+
+ return (
+
+
+
Tickets
+
+ Issue ticket
+
+
+ {isLoading ? (
+
Loading…
+ ) : (
+
+ )}
+
+ );
+};
+
+export default TicketsPage;
diff --git a/apps/edr-passenger-web/portal/src/utils/api.ts b/apps/edr-passenger-web/portal/src/utils/api.ts
index d84d9a124..ca7c2031e 100644
--- a/apps/edr-passenger-web/portal/src/utils/api.ts
+++ b/apps/edr-passenger-web/portal/src/utils/api.ts
@@ -1,7 +1,7 @@
import axios from "axios";
export const api = axios.create({
- baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
+ baseURL: import.meta.env.VITE_API_URL,
});
// TODO: integrate @edr/auth — add a request interceptor here that attaches
diff --git a/apps/edr-passenger-web/portal/src/vite-env.d.ts b/apps/edr-passenger-web/portal/src/vite-env.d.ts
new file mode 100644
index 000000000..29c29a188
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/apps/edr-passenger-web/portal/tsconfig.app.json b/apps/edr-passenger-web/portal/tsconfig.app.json
new file mode 100644
index 000000000..73df43221
--- /dev/null
+++ b/apps/edr-passenger-web/portal/tsconfig.app.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@edr/tsconfig/react.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "useDefineForClassFields": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}
diff --git a/apps/edr-passenger-web/portal/tsconfig.node.json b/apps/edr-passenger-web/portal/tsconfig.node.json
new file mode 100644
index 000000000..181375c8f
--- /dev/null
+++ b/apps/edr-passenger-web/portal/tsconfig.node.json
@@ -0,0 +1,14 @@
+{
+ "extends": "@edr/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/edr-passenger-web/portal/vite.config.ts b/apps/edr-passenger-web/portal/vite.config.ts
new file mode 100644
index 000000000..324a8dfa7
--- /dev/null
+++ b/apps/edr-passenger-web/portal/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5174,
+ host: "0.0.0.0",
+ },
+ // test: {
+ // environment: "jsdom",
+ // globals: true,
+ // },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 053d67038..8964fecde 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -222,7 +222,7 @@ importers:
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)
'@nestjs/testing':
specifier: ^11.1.19
- version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(@nestjs/platform-express@11.1.23)
+ version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.23)
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2