Merge branch 'freight_feature/priority' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
Marshal
2026-06-16 15:28:38 +00:00
132 changed files with 12096 additions and 3022 deletions

View File

@@ -1,5 +1,4 @@
name: Deploy Stacks
on:
push:
branches:
@@ -8,50 +7,129 @@ on:
- staging
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
jobs:
detect-changes:
name: Detect changed services
runs-on: self-hosted
outputs:
matrix: ${{ steps.filter.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Determine changed services
id: filter
run: |
set -euo pipefail
ALL_SERVICES=(
"freight-api"
"freight-portal"
"freight-backoffice"
"passenger-api"
"passenger-portal"
"passenger-backoffice"
"payment-api"
)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only HEAD~1 HEAD)
echo "=== Changed files ==="
echo "$CHANGED"
echo "====================="
SERVICES=()
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
if [ -z "$DEPLOYABLE" ]; then
echo "Only non-deployable files changed. Skipping deploy."
echo "matrix=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then
echo "Global file(s) changed — deploying all services."
JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
echo "$CHANGED" | grep -q "^apps/edr-freight-portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api")
SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
if [ ${#SERVICES[@]} -eq 0 ]; then
echo "No deployable service changes detected."
echo "matrix=[]" >> "$GITHUB_OUTPUT"
else
echo "Services to deploy: ${SERVICES[*]}"
JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
fi
deploy:
name: Deploy ${{ matrix.service }}
needs: detect-changes
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
include:
- project: edr-freight
build_env_file: freight-web.build.env
service: freight-api
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-portal
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-backoffice
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-api
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-portal
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-backoffice
- project: edr-payment
build_env_file: payment-web.build.env
service: payment-api
service: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
env:
PROJECT: ${{ matrix.project }}
BRANCH: ${{ github.ref_name }}
DEPLOY_USER: tria
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve project and build env file
run: |
case "${{ matrix.service }}" in
freight-api|freight-portal|freight-backoffice)
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
;;
passenger-api|passenger-portal|passenger-backoffice)
echo "PROJECT=edr-passenger" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=passenger-web.build.env" >> "$GITHUB_ENV"
;;
payment-api)
echo "PROJECT=edr-payment" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=payment-web.build.env" >> "$GITHUB_ENV"
;;
*)
echo "Unknown service: ${{ matrix.service }}" && exit 1
;;
esac
- name: Sync environment from server
run: |
chmod +x scripts/deploy/*.sh

View File

@@ -15,6 +15,7 @@ import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
interface CurrentIamUser {
id: string;
@@ -45,6 +46,12 @@ export class CompaniesController {
return new ProfileResponseDto(profile, company);
}
@Get('dashboard')
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
return this.companiesService.getDashboardSummary(user.id);
}
@Patch('profile')
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
async updateProfile(

View File

@@ -6,14 +6,16 @@ import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import { Booking } from '../bookings/entities/booking.entity';
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
exports: [CompaniesService],
})
export class CompaniesModule {}

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
@@ -9,6 +10,7 @@ import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@@ -27,6 +29,7 @@ export class CompaniesService {
private readonly companiesRepo: CompaniesRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly ffClientsRepo: FFClientRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
) {}
async createCompany(dto: CreateCompanyDto): Promise<Company> {
@@ -98,6 +101,122 @@ export class CompaniesService {
return { profile, company };
}
/**
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
* current user's company bookings. All figures are scoped to that company.
*
* Note: delivered/spend/volume all derive from the bookings table — there is
* no separate data source for them. On-time delivery rate is replaced by
* completion rate (delivered ÷ committed): the schema has no ETA /
* promised-delivery date, so on-time cannot be computed.
*
* Period attribution uses booking.created_at: there is no delivery-date
* column, so "delivered YTD" counts bookings created this year that reached a
* delivered/completed status.
*/
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
// A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders.
const profile = await this.profilesRepo.findByUserId(userId);
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
if (!companyId) return this.emptyDashboardSummary();
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
// Same point in the previous year, so YoY compares like-for-like windows.
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
const [
deliveredThis,
committedThis,
spendThisByCcy,
spendPrevByCcy,
tonnageThis,
tonnagePrev,
monthlyRows,
] = await Promise.all([
this.dashboardRepo.countDelivered(companyId, yearStart, now),
this.dashboardRepo.countCommitted(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
]);
// Spend can span currencies; report the dominant one (prefer ETB on ties).
const spend = this.pickCurrencyTotal(spendThisByCcy);
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
return {
deliveredCount: deliveredThis,
// Share of committed bookings that reached delivered/completed.
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
spendYtd: spend.total,
spendCurrency: spend.currency,
spendYtdChangePct: this.changePct(spend.total, spendPrev),
freightVolume: {
totalTonnes: Math.round(tonnageThis),
totalValue: spend.total,
currency: spend.currency,
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
monthly: this.buildMonthlySeries(now, monthlyRows),
},
};
}
private emptyDashboardSummary(): DashboardSummaryResponseDto {
const now = new Date();
return {
deliveredCount: 0,
completionRate: 0,
spendYtd: 0,
spendCurrency: 'ETB',
spendYtdChangePct: 0,
freightVolume: {
totalTonnes: 0,
totalValue: 0,
currency: 'ETB',
ytdChangePct: 0,
monthly: this.buildMonthlySeries(now, []),
},
};
}
/** First day of the month `n` months before `from`. */
private monthsAgo(from: Date, n: number): Date {
return new Date(from.getFullYear(), from.getMonth() - n, 1);
}
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
if (totals.length === 0) return { currency: 'ETB', total: 0 };
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
}
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
private changePct(current: number, previous: number): number {
if (previous <= 0) return 0;
return Math.round(((current - previous) / previous) * 100);
}
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
private buildMonthlySeries(
now: Date,
rows: { year: number; month: number; tonnes: number }[],
): { month: string; tonnes: number }[] {
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
const series: { month: string; tonnes: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
}
return series;
}
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);

View File

@@ -0,0 +1,126 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
/** Booking statuses that represent a delivered/finished shipment. */
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
/**
* Statuses that represent real, committed freight (excludes drafts and dead
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
*/
const COMMITTED_STATUSES = [
'APPROVED',
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'COMPLETED',
'DELIVERED',
'CONSOLIDATED',
] as const;
export interface CurrencyTotal {
currency: string;
total: number;
}
export interface MonthlyTonnage {
year: number;
month: number; // 1-12
tonnes: number;
}
/**
* Read-only aggregation queries against the bookings table, scoped to a
* company, that back the portal dashboard. Lives in the companies module so it
* can be exposed via `companies.controller` without a circular dependency on
* BookingsModule (which already imports CompaniesModule).
*/
@Injectable()
export class CompanyDashboardRepository {
constructor(
@InjectRepository(Booking)
private readonly bookings: Repository<Booking>,
) {}
/** Count of delivered/completed bookings for a company within [from, to). */
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Sum of paid booking totals, grouped by currency, within [from, to). */
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('b.payment_currency', 'currency')
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere("b.payment_status = 'PAID'")
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.groupBy('b.payment_currency')
.getRawMany<{ currency: string; total: string }>();
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
}
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
const row = await this.bookings
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getRawOne<{ total: string }>();
return Number(row?.total ?? 0);
}
/** Committed tonnage grouped by calendar month within [from, to). */
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.groupBy('year')
.addGroupBy('month')
.getRawMany<{ year: string; month: string; total: string }>();
return rows.map((r) => ({
year: Number(r.year),
month: Number(r.month),
tonnes: Number(r.total),
}));
}
}

View File

@@ -0,0 +1,59 @@
import { ApiProperty } from '@nestjs/swagger';
export class FreightVolumePointDto {
@ApiProperty({ example: 'May', description: 'Short month label' })
month!: string;
@ApiProperty({ example: 940, description: 'Tonnage shipped in the month' })
tonnes!: number;
}
export class FreightVolumeDto {
@ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' })
totalTonnes!: number;
@ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' })
totalValue!: number;
@ApiProperty({ example: 'ETB' })
currency!: string;
@ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' })
ytdChangePct!: number;
@ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' })
monthly!: FreightVolumePointDto[];
}
/**
* KPIs for the portal dashboard (MyPortalPage), aggregated from the current
* user's company bookings. All figures are scoped to that company.
*
* Note: every metric here derives from the bookings table — there is no
* separate "non-booking" data source for delivered/spend/volume. On-time
* delivery rate is replaced by completion rate: no ETA / promised-delivery
* column exists in the schema, so on-time cannot be computed, whereas
* completion rate (delivered ÷ committed) can.
*/
export class DashboardSummaryResponseDto {
@ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' })
deliveredCount!: number;
@ApiProperty({
example: 92,
description: 'Share of committed bookings that have been delivered/completed (YTD), in percent',
})
completionRate!: number;
@ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' })
spendYtd!: number;
@ApiProperty({ example: 'ETB' })
spendCurrency!: string;
@ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' })
spendYtdChangePct!: number;
@ApiProperty({ type: FreightVolumeDto })
freightVolume!: FreightVolumeDto;
}

View File

@@ -173,7 +173,7 @@ const App = () => {
<Route path="/onboarding" element={<OnboardingPage />} />
</Route>
<Route element={<RequireCompany path={location.pathname} />}>
<Route element={<RequireCompany />}>
<Route
element={
<AppLayout

View File

@@ -147,7 +147,6 @@ export function AppLayout({
flexShrink: 0,
cursor: "pointer",
};
const toggleStyle: CSSProperties = { ...islandStyle, borderRadius: 10 };
return (
<AppShell

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { ChevronRight, Home } from "lucide-react";
import { ChevronRight } from "lucide-react";
export interface BreadcrumbItem {
label: string;
@@ -34,10 +34,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
{item.href && !isLast ? (
<Link
to={item.href}
className="transition hover:text-[#10B981]"
>
<Link to={item.href} className="transition hover:text-[#10B981]">
{item.label}
</Link>
) : (

View File

@@ -1,62 +0,0 @@
import { FormEvent, useState } from "react";
import { Button, FormField } from "@edr/ui-common";
import type { CreateBookingPayload } from "../../services/bookings.service";
export interface BookingFormProps {
onSubmit: (payload: CreateBookingPayload) => void;
isSubmitting?: boolean;
}
const BookingForm = ({ onSubmit, isSubmitting }: BookingFormProps) => {
const [reference, setReference] = useState("");
const [customerId, setCustomerId] = useState("");
const [scheduledDate, setScheduledDate] = useState("");
const [totalAmount, setTotalAmount] = useState("0");
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit({
reference,
customerId,
scheduledDate,
totalAmount: Number(totalAmount),
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<FormField
label="Reference"
value={reference}
onChange={(e) => setReference(e.target.value)}
required
/>
<FormField
label="Customer ID"
value={customerId}
onChange={(e) => setCustomerId(e.target.value)}
required
/>
<FormField
label="Scheduled date"
type="date"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
required
/>
<FormField
label="Total amount"
type="number"
value={totalAmount}
onChange={(e) => setTotalAmount(e.target.value)}
min="0"
/>
<Button type="submit" isLoading={isSubmitting}>
Create booking
</Button>
</form>
);
};
export default BookingForm;

View File

@@ -1,33 +0,0 @@
import type { Freight } from "@edr/types";
import { Table, type TableColumn } from "@edr/ui-common";
export interface BookingTableProps {
bookings: Freight.IBooking[];
}
const columns: TableColumn<Freight.IBooking>[] = [
{ key: "reference", header: "Reference" },
{ key: "customerId", header: "Customer" },
{ key: "status", header: "Status" },
{
key: "scheduledDate",
header: "Scheduled",
render: (row) => new Date(row.scheduledDate).toLocaleDateString(),
},
{
key: "totalAmount",
header: "Total",
render: (row) => row.totalAmount.toFixed(2),
},
];
const BookingTable = ({ bookings }: BookingTableProps) => (
<Table
columns={columns}
data={bookings}
rowKey={(row) => row.id}
emptyMessage="No bookings yet"
/>
);
export default BookingTable;

View File

@@ -1,81 +0,0 @@
import { FormEvent, useState } from "react";
import { Button, FormField } from "@edr/ui-common";
export interface ConsignmentFormProps {
onSubmit: (payload: {
bookingId: string;
trackingNumber: string;
cargoType: string;
weightKg: number;
originStation: string;
destinationStation: string;
}) => void;
isSubmitting?: boolean;
}
const ConsignmentForm = ({ onSubmit, isSubmitting }: ConsignmentFormProps) => {
const [bookingId, setBookingId] = useState("");
const [trackingNumber, setTrackingNumber] = useState("");
const [cargoType, setCargoType] = useState("GENERAL");
const [weightKg, setWeightKg] = useState("0");
const [originStation, setOriginStation] = useState("");
const [destinationStation, setDestinationStation] = useState("");
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit({
bookingId,
trackingNumber,
cargoType,
weightKg: Number(weightKg),
originStation,
destinationStation,
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<FormField
label="Booking ID"
value={bookingId}
onChange={(e) => setBookingId(e.target.value)}
required
/>
<FormField
label="Tracking #"
value={trackingNumber}
onChange={(e) => setTrackingNumber(e.target.value)}
required
/>
<FormField
label="Cargo type"
value={cargoType}
onChange={(e) => setCargoType(e.target.value)}
/>
<FormField
label="Weight (kg)"
type="number"
value={weightKg}
onChange={(e) => setWeightKg(e.target.value)}
min="0"
/>
<FormField
label="Origin station"
value={originStation}
onChange={(e) => setOriginStation(e.target.value)}
required
/>
<FormField
label="Destination station"
value={destinationStation}
onChange={(e) => setDestinationStation(e.target.value)}
required
/>
<Button type="submit" isLoading={isSubmitting}>
Create consignment
</Button>
</form>
);
};
export default ConsignmentForm;

View File

@@ -1,30 +0,0 @@
import type { Freight } from "@edr/types";
import { Table, type TableColumn } from "@edr/ui-common";
export interface ConsignmentTableProps {
consignments: Freight.IConsignment[];
}
const columns: TableColumn<Freight.IConsignment>[] = [
{ key: "trackingNumber", header: "Tracking #" },
{ key: "cargoType", header: "Cargo" },
{ key: "status", header: "Status" },
{ key: "originStation", header: "Origin" },
{ key: "destinationStation", header: "Destination" },
{
key: "weightKg",
header: "Weight (kg)",
render: (row) => row.weightKg.toFixed(2),
},
];
const ConsignmentTable = ({ consignments }: ConsignmentTableProps) => (
<Table
columns={columns}
data={consignments}
rowKey={(row) => row.id}
emptyMessage="No consignments yet"
/>
);
export default ConsignmentTable;

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
import { api } from "@/services/api";
import { Loader2, AlertCircle } from "lucide-react";
import * as React from "react";
import {
Select,
@@ -64,11 +63,7 @@ export function DynamicSelect({
const options = [...data.children].sort((a, b) => a.order - b.order);
return (
<Select
value={value}
onValueChange={onValueChange}
disabled={disabled}
>
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
<SelectTrigger className={cn("w-full", className)}>
<SelectValue placeholder={placeholder ?? `Select ${data.label}`} />
</SelectTrigger>

View File

@@ -17,7 +17,7 @@ const TrackingTimeline = ({ events }: TrackingTimelineProps) => {
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span>{event.location}</span>
<Badge tone="info">{event.status}</Badge>
<Badge>{event.status}</Badge>
</div>
<time className="text-xs text-gray-500">
{new Date(event.occurredAt).toLocaleString()}

View File

@@ -1,8 +1,7 @@
export * from './table';
export * from './badge';
export * from './button';
export * from './dialog';
export * from './input';
export * from './label';
export * from './textarea';
export * from './Breadcrumbs';
export * from "./table";
export * from "./badge";
export * from "./button";
export * from "./dialog";
export * from "./input";
export * from "./label";
export * from "./textarea";

View File

@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create",
PROFILE: "/api/companies/profile",
DASHBOARD: "/api/companies/dashboard",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
},
@@ -100,4 +101,10 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
},
PAYMENTS: {
INITIATE: "/api/payments/initiate",
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
CHECKOUT: "/api/payments/checkout",
},
};

View File

@@ -1,16 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { bookingsService } from "../services/bookings.service";
export const useBookings = () =>
useQuery({
queryKey: ["bookings"],
queryFn: bookingsService.list,
});
export const useBooking = (id: string) =>
useQuery({
queryKey: ["bookings", id],
queryFn: () => bookingsService.get(id),
enabled: Boolean(id),
});

View File

@@ -1,16 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { consignmentsService } from "../services/consignments.service";
export const useConsignments = () =>
useQuery({
queryKey: ["consignments"],
queryFn: consignmentsService.list,
});
export const useConsignment = (id: string) =>
useQuery({
queryKey: ["consignments", id],
queryFn: () => consignmentsService.get(id),
enabled: Boolean(id),
});

View File

@@ -1,50 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { customersService } from "@/services/customers.service";
import type {
CreateCustomerDto,
UpdateCustomerDto,
} from "@/types/customers";
const KEY = ["customers"] as const;
export const useCustomers = () =>
useQuery({
queryKey: KEY,
queryFn: customersService.list,
});
export const useCustomer = (id: string | undefined) =>
useQuery({
queryKey: [...KEY, "id", id],
queryFn: () => customersService.getById(id!),
enabled: Boolean(id),
});
export const useCreateCustomer = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateCustomerDto) => customersService.create(dto),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};
export const useUpdateCustomer = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) =>
customersService.update(id, dto),
onSuccess: (_data, { id }) => {
qc.invalidateQueries({ queryKey: KEY });
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
},
});
};
export const useDeleteCustomer = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => customersService.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
};

View File

@@ -1,7 +1,5 @@
import { customers, type Customer } from "@/pages/customers/customers.mock";
import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
import { shipments, type Shipment } from "@/pages/tracking/shipments.mock";
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
import { customers, type Customer } from "@/pages/customers/customers.mock";
/**
* Mock "logged-in customer". When auth integrates, replace this with the value
@@ -16,16 +14,6 @@ export function getCurrentCustomer(): Customer {
);
}
export function getMyBookings(): Booking[] {
const me = getCurrentCustomer();
return bookings.filter((b) => b.customerId === me.id);
}
export function getMyShipments(): Shipment[] {
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
return shipments.filter((s) => myBookingIds.has(s.bookingId));
}
export function getMyInvoices(): Invoice[] {
const me = getCurrentCustomer();
return invoices.filter((inv) => inv.customerId === me.id);

View File

@@ -26,8 +26,8 @@ import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import { getMyInvoices, getMyShipments } from "@/lib/currentCustomer";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import { getMyInvoices } from "@/lib/currentCustomer";
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { api } from "@/services/api";
@@ -38,6 +38,9 @@ const cv = (token: string) => {
return `var(--mantine-color-${name}-${shade ?? "6"})`;
};
/** Format a signed percentage for KPI deltas, e.g. 16 → "+16%", -4 → "-4%". */
const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`;
const ACTIVE_STATUSES = [
"DRAFT",
"SUBMITTED",
@@ -356,12 +359,8 @@ const INVOICE_BADGE: Record<
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
};
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
export default function MyPortalPage() {
const { user, customer } = useAuth();
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const navigate = useNavigate();
@@ -371,10 +370,17 @@ export default function MyPortalPage() {
}),
);
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions());
const dashboard = dashboardQuery.data;
const allBookings = bookingsQuery.data?.items ?? [];
const activeBookings = allBookings.filter((b) =>
ACTIVE_STATUSES.includes(b.status),
);
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const newActiveThisWeek = activeBookings.filter(
(b) => new Date(b.createdAt).getTime() >= weekAgo,
).length;
const visibleBookings = allBookings;
const outstandingInvoices = myInvoices.filter(
@@ -384,9 +390,6 @@ export default function MyPortalPage() {
(sum, inv) => sum + inv.amount,
0,
);
const deliveredCount =
myShipments.filter((s) => s.status === "Delivered").length || 12;
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
const companyName = (customer as any)?.companyName ?? displayName;
@@ -398,7 +401,9 @@ export default function MyPortalPage() {
? "Good afternoon,"
: "Good evening,";
const recentInvoices = myInvoices.slice(0, 3);
const maxVolume = Math.max(...VOLUME_DATA);
const volumePoints = dashboard?.freightVolume.monthly ?? [];
const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes));
return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
@@ -471,8 +476,12 @@ export default function MyPortalPage() {
<StatKpi
icon={Truck}
label="Active Shipments"
value={activeBookings.length.toString()}
delta="+2 this week"
value={
bookingsQuery.isPending ? "—" : activeBookings.length.toString()
}
delta={
bookingsQuery.isPending ? "" : `+${newActiveThisWeek} this week`
}
deltaColor="edr-green.7"
/>
<StatKpi
@@ -485,17 +494,26 @@ export default function MyPortalPage() {
/>
<StatKpi
icon={CheckCircle2}
label="Delivered (May)"
value={deliveredCount.toString()}
delta="96% on-time"
label="Delivered (YTD)"
value={dashboard ? dashboard.deliveredCount.toString() : "—"}
delta={dashboard ? `${dashboard.completionRate}% completed` : ""}
deltaColor="edr-muted"
divider
/>
<StatKpi
icon={Wallet}
label="Spend YTD"
value="ETB 1.24M"
delta="+16% YoY"
value={
dashboard
? formatCurrency(
dashboard.spendYtd,
dashboard.spendCurrency as Currency,
)
: "—"
}
delta={
dashboard ? `${formatPct(dashboard.spendYtdChangePct)} YoY` : ""
}
deltaColor="edr-green.7"
divider
/>
@@ -670,37 +688,57 @@ export default function MyPortalPage() {
Freight Volume
</Text>
<Group gap={10} align="baseline" mt={4} mb={22}>
<Text fz={26} fw={800} c="edr-text">
4,180 t
</Text>
<Text fz={13} c="edr-muted">
ETB 1.24M
</Text>
<Text fz={12} fw={700} c="edr-green.7">
+16% YTD
</Text>
{dashboardQuery.isPending ? (
<Skeleton height={32} width={180} radius="sm" />
) : (
<>
<Text fz={26} fw={800} c="edr-text">
{(dashboard?.freightVolume.totalTonnes ?? 0).toLocaleString()}{" "}
t
</Text>
<Text fz={13} c="edr-muted">
{formatCurrency(
dashboard?.freightVolume.totalValue ?? 0,
(dashboard?.freightVolume.currency ?? "ETB") as Currency,
)}
</Text>
<Text fz={12} fw={700} c="edr-green.7">
{formatPct(dashboard?.freightVolume.ytdChangePct ?? 0)} YTD
</Text>
</>
)}
</Group>
<Group align="flex-end" gap={10} className="h-[110px]">
{VOLUME_DATA.map((val, i) => {
const isLast = i === VOLUME_DATA.length - 1;
return (
<Box
key={i}
className="flex flex-1 flex-col items-center gap-2"
>
{dashboardQuery.isPending ? (
<Skeleton height={110} radius="md" />
) : volumePoints.length === 0 ? (
<Box className="flex h-[110px] items-center">
<Text fz={13} c="edr-muted">
No freight volume yet.
</Text>
</Box>
) : (
<Group align="flex-end" gap={10} className="h-[110px]">
{volumePoints.map((point, i) => {
const isLast = i === volumePoints.length - 1;
return (
<Box
bg={isLast ? "edr-green" : "edr-soft"}
bd={isLast ? undefined : "1px solid edr-border"}
h={Math.round((val / maxVolume) * 86)}
className="w-full rounded-t-md"
/>
<Text fz={11} c="edr-muted">
{MONTHS[i]}
</Text>
</Box>
);
})}
</Group>
key={point.month}
className="flex flex-1 flex-col items-center gap-2"
>
<Box
bg={isLast ? "edr-green" : "edr-soft"}
bd={isLast ? undefined : "1px solid edr-border"}
h={Math.round((point.tonnes / maxVolume) * 86)}
className="w-full rounded-t-md"
/>
<Text fz={11} c="edr-muted">
{point.month}
</Text>
</Box>
);
})}
</Group>
)}
</Card>
</Grid.Col>

View File

@@ -1,4 +1,15 @@
import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import {
Box,
Button,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
@@ -8,7 +19,6 @@ import {
CheckCircle2,
ChevronLeft,
FileText,
Loader2,
UploadCloud,
User,
} from "lucide-react";
@@ -32,7 +42,10 @@ const onboardingSchema = z.object({
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"),
vatNumber: z
.string()
.min(1, "VAT number is required")
.length(10, "VAT number must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
@@ -52,8 +65,26 @@ const onboardingSchema = z.object({
type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
],
personnel: [
"contactPersonName",
"contactPersonPhone",
"contactPersonPhoneCountryCode",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
"generalManagerPhoneCountryCode",
],
poa: [],
documents: [],
confirm: [],
@@ -76,7 +107,10 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
poaName: data.poaName || undefined,
poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined,
poaPhone:
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
@@ -102,22 +136,50 @@ export default function CompanyProfileForm({
onBack: () => void;
}) {
const [step, setStep] = useState<CompanyStep>("company");
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251",
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251",
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251",
poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "",
companyName: "",
companyEmail: "",
companyPhone: "",
companyPhoneCountryCode: "+251",
companyLocation: "",
companyAddress: "",
tinNumber: "",
vatNumber: "",
fanNumber: "",
contactPersonName: "",
contactPersonPhone: "",
contactPersonPhoneCountryCode: "+251",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
generalManagerPhoneCountryCode: "+251",
poaName: "",
poaPhone: "",
poaPhoneCountryCode: "+251",
poaAddress: "",
poaEmail: "",
poaLocation: "",
},
});
@@ -126,9 +188,18 @@ export default function CompanyProfileForm({
const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") { setStep("documents"); return; }
if (step === "documents") { setStep("confirm"); return; }
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
if (step === "poa") {
setStep("documents");
return;
}
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const isValid = await trigger(stepFields[step]);
if (!isValid) return;
setStep(step === "company" ? "personnel" : "poa");
@@ -158,7 +229,13 @@ export default function CompanyProfileForm({
confirm: `Step 5 of ${totalSteps} — Review & Confirm`,
};
const stepOrder: CompanyStep[] = ["company", "personnel", "poa", "documents", "confirm"];
const stepOrder: CompanyStep[] = [
"company",
"personnel",
"poa",
"documents",
"confirm",
];
const currentIdx = stepOrder.indexOf(step);
return (
@@ -175,13 +252,24 @@ export default function CompanyProfileForm({
Change account type
</Button>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Group
justify="space-between"
align="center"
className="relative max-w-lg mx-auto px-2"
>
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
<ThemeIcon
key={key}
size={40}
radius="xl"
variant="filled"
color="edr-green"
className="relative z-10"
>
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
@@ -223,7 +311,10 @@ export default function CompanyProfileForm({
/>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
@@ -271,7 +362,9 @@ export default function CompanyProfileForm({
{step === "personnel" && (
<>
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
@@ -281,7 +374,10 @@ export default function CompanyProfileForm({
/>
<PhoneInput
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
phone={{
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone"
@@ -290,7 +386,9 @@ export default function CompanyProfileForm({
<Divider color="edr-border" />
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
<TextInput
label="Name"
placeholder="Abebe Bikila"
@@ -306,8 +404,13 @@ export default function CompanyProfileForm({
{...register("generalManagerEmail")}
/>
<PhoneInput
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
countryCode={{
...register("generalManagerPhoneCountryCode"),
}}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone"
@@ -319,7 +422,8 @@ export default function CompanyProfileForm({
{step === "poa" && (
<>
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</Text>
<TextInput
label="PoA Name"
@@ -371,51 +475,126 @@ export default function CompanyProfileForm({
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
)}
</>
)}
{step === "confirm" && (
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
<Text fw={600} c="edr-text">Review your registration</Text>
<Box
p={16}
className="rounded-2xl border border-edr-border bg-edr-card"
>
<Text fw={600} c="edr-text">
Review your registration
</Text>
<Text size="sm" c="edr-muted" mt={4} mb="md">
Confirm the company details below before saving.
</Text>
<SimpleGrid cols={2} spacing="sm">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow
label="Company name"
value={formValues.companyName}
/>
<ReviewRow
label="Company email"
value={formValues.companyEmail}
/>
<ReviewRow
label="Company phone"
value={formValues.companyPhone}
/>
<ReviewRow
label="Location"
value={formValues.companyLocation}
/>
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow label="Contact person" value={formValues.contactPersonName} />
<ReviewRow label="Contact phone" value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`} />
<ReviewRow label="General manager" value={formValues.generalManagerName} />
<ReviewRow label="GM email" value={formValues.generalManagerEmail} />
<ReviewRow label="GM phone" value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`} />
<ReviewRow label="PoA name" value={formValues.poaName || undefined} />
<ReviewRow label="PoA phone" value={formValues.poaPhone && formValues.poaPhoneCountryCode ? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}` : undefined} />
<ReviewRow label="PoA email" value={formValues.poaEmail || undefined} />
<ReviewRow label="PoA location" value={formValues.poaLocation || undefined} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</SimpleGrid>
</Box>
)}
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Button
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
onClick={
step === "confirm"
? handleSubmit((data) => onSubmit(buildPayload(data, user)))
: nextStep
}
disabled={
isPending ||
(step === "documents" && !hasDocuments && loadingDocuments)
}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
rightSection={
!isPending && step !== "confirm" && step !== "documents" ? (
<ArrowRight size={16} />
) : undefined
}
>
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
{step === "documents"
? "Continue"
: step === "confirm"
? "Submit Registration"
: "Next Step"}
</Button>
</Group>
</Stack>
@@ -427,10 +606,20 @@ export default function CompanyProfileForm({
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<Box p={12} className="rounded-xl bg-edr-bg">
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
<Text
size="xs"
fw={600}
c="edr-muted"
className="uppercase tracking-wide"
>
{label}
</Text>
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
<Text
size="sm"
fw={500}
c={value?.trim() ? "edr-text" : "edr-muted"}
mt={4}
>
{value?.trim() ? value : "Not provided"}
</Text>
</Box>

View File

@@ -77,7 +77,9 @@ export function DraftBookingView({
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
}),
);
const pricing = booking.pricingBreakdown ?? generatedPricing ?? null;
const pricing = (booking.pricingBreakdown ??
generatedPricing ??
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>

View File

@@ -1,9 +1,11 @@
import { Box, Group, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { CreditCard, Download } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import type { Freight } from "@edr/types";
import { ActivityCard } from "./components/ActivityCard";
@@ -13,21 +15,30 @@ import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout
import { CancelledBanner } from "./components/Notices";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { PaymentCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative } from "./utils";
import { fmtDate, isNegative, priceTotal } from "./utils";
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
const navigate = useNavigate();
const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false);
// Two-step flow: POST /payments/initiate to create the intent, then send the
// browser to the public /payments/checkout page which redirects to the
// selected provider to complete payment.
const payMutation = useMutation({
mutationFn: () => api.bookings.pay.call({ id: booking.id }),
onSuccess: (data) => {
if (data.redirectUrl) window.location.href = data.redirectUrl;
mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId: booking.id, method }),
onSuccess: (_data, method) => {
window.location.href = paymentsService.checkoutUrl({
bookingId: booking.id,
method,
});
},
});
@@ -47,9 +58,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<HeaderButton
green
icon={<CreditCard size={16} />}
label={payMutation.isPending ? "Processing…" : "Pay now"}
onClick={() => payMutation.mutate()}
disabled={payMutation.isPending}
label="Pay now"
onClick={() => setPayModalOpen(true)}
/>
)
}
@@ -128,7 +138,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{showCountdown && (
<PaymentDeadlineCard
paymentDeadline={booking.paymentDeadline!}
onPay={() => payMutation.mutate()}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
/>
)}
@@ -142,6 +152,26 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
</>
}
/>
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
if (!payMutation.isPending) {
setPayModalOpen(false);
payMutation.reset();
}
}}
amountLabel={pricing ? priceTotal(pricing) : undefined}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
}
onConfirm={(method) => payMutation.mutate(method)}
/>
</PageShell>
);
}

View File

@@ -0,0 +1,203 @@
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import {
Banknote,
Building2,
CreditCard,
Smartphone,
Wallet,
type LucideIcon,
} from "lucide-react";
import { useState } from "react";
import type { PaymentMethod } from "@/services/payments.service";
interface ProviderOption {
method: PaymentMethod;
label: string;
description: string;
icon: LucideIcon;
}
const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
label: "telebirr",
description: "Ethiopian mobile money",
icon: Smartphone,
},
{
method: "CBE_BIRR",
label: "CBE Birr",
description: "Commercial Bank of Ethiopia",
icon: Building2,
},
{
method: "EBIRR",
label: "E-Birr",
description: "Electronic payment gateway",
icon: Wallet,
},
{
method: "WAAFI",
label: "WAAFI",
description: "Djibouti mobile money",
icon: Smartphone,
},
{
method: "CARD",
label: "Card",
description: "Visa / Mastercard",
icon: CreditCard,
},
{
method: "DMONEY",
label: "D-Money",
description: "Djibouti D-money",
icon: Banknote,
},
{
method: "CAC_BANK",
label: "CAC Bank",
description: "CAC Int Bank (OTP)",
icon: Building2,
},
];
function ProviderRow({
option,
selected,
onSelect,
}: {
option: ProviderOption;
selected: boolean;
onSelect: () => void;
}) {
const Icon = option.icon;
return (
<Group
onClick={onSelect}
gap={12}
wrap="nowrap"
style={{
cursor: "pointer",
borderRadius: 12,
padding: "13px 14px",
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
backgroundColor: selected ? "#ECF6F1" : "#fff",
transition: "border-color .12s, background-color .12s",
}}
>
<Box
style={{
width: 40,
height: 40,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
color: selected ? "#fff" : "#475569",
}}
>
<Icon size={19} />
</Box>
<Box style={{ flex: 1 }}>
<Text fz="14px" fw={700} c="#10202F">
{option.label}
</Text>
<Text fz="12.5px" c="#9AA8B5">
{option.description}
</Text>
</Box>
<Box
style={{
width: 18,
height: 18,
flexShrink: 0,
borderRadius: "50%",
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
backgroundColor: selected ? "#0A6F4D" : "transparent",
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
}}
/>
</Group>
);
}
export function PaymentMethodModal({
opened,
onClose,
amountLabel,
onConfirm,
processing,
error,
}: {
opened: boolean;
onClose: () => void;
/** Human-readable total, e.g. "ETB 12,500". */
amountLabel?: string;
onConfirm: (method: PaymentMethod) => void;
processing?: boolean;
error?: string | null;
}) {
const [method, setMethod] = useState<PaymentMethod | null>(null);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size={460}
title={
<Stack gap={2}>
<Text fw={800} fz="17px" c="#10202F">
Choose a payment method
</Text>
{amountLabel && (
<Text fz="12.5px" c="#9AA8B5">
Amount due: {amountLabel}
</Text>
)}
</Stack>
}
>
<Stack gap={10}>
{PROVIDERS.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
{error && (
<Text fz="12.5px" c="#C0392B" fw={600}>
{error}
</Text>
)}
<Button
fullWidth
mt={6}
radius={10}
color="edr-green"
disabled={!method || processing}
loading={processing}
onClick={() => method && onConfirm(method)}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
<Text fz="11.5px" c="#9AA8B5" ta="center">
You'll be redirected to your provider to complete payment securely.
</Text>
</Stack>
</Modal>
);
}

View File

@@ -1,30 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import BookingForm from "../../components/bookings/BookingForm";
import { bookingsService } from "../../services/bookings.service";
const CreateBookingPage = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: bookingsService.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["bookings"] });
navigate("/bookings");
},
});
return (
<div className="max-w-lg">
<h1 className="mb-4 text-2xl font-semibold text-gray-900">New booking</h1>
<BookingForm
onSubmit={mutation.mutate}
isSubmitting={mutation.isPending}
/>
</div>
);
};
export default CreateBookingPage;

View File

@@ -45,7 +45,6 @@ import {
initialBookingFormValues,
type BookingDocuments,
type BookingFormValues,
type RouteDirection,
} from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared";
import { Step5CargoDetails } from "./new-booking-form/steps";
@@ -90,13 +89,13 @@ function mapBookingToFormValues(
booking: Freight.IBooking,
referenceData: Freight.BookingReferenceData,
): BookingFormInputValues {
const vals: BookingFormInputValues = {
const vals = {
...initialBookingFormValues,
contractType:
(booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new",
previousContractRef: booking.previousContractId ?? "",
serviceType:
booking.serviceType === "RAIL_AND_FORWARDING" ? "rail_forwarding" : "rail",
serviceTypeId:
referenceData.service.find((s) => s.code === booking.serviceType)?.id ?? "",
firstMile: {
enabled: booking.firstMileEnabled ?? false,
pickUpAddress: booking.firstMilePickupAddress ?? "",
@@ -120,18 +119,15 @@ function mapBookingToFormValues(
notes: "",
// Terms were accepted at creation; editing shouldn't re-gate on them.
termsAccepted: true,
freightType: "",
bulkCommoditytype: "",
containers: [],
};
} as BookingFormInputValues;
const bookingCargoTypeId = (booking as any).cargoTypeId as string | undefined;
if (booking.freightType === "BULK" && bookingCargoTypeId) {
for (const group of referenceData.cargo_type) {
const child = group.children?.find((c) => c.id === bookingCargoTypeId);
if (child) {
vals.freightType = group.code.toLowerCase();
vals.bulkCommoditytype = child.name;
vals.cargoTypePath = [group.id, child.id];
break;
}
}
@@ -299,16 +295,24 @@ export default function EditBookingPage() {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const serviceType = form.watch("serviceType");
const serviceTypeId = form.watch("serviceTypeId");
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const direction: RouteDirection = useMemo(
() => getRouteDirection(originYard, destinationYard),
[originYard, destinationYard],
const selectedService = useMemo(
() => referenceData?.service.find((s) => s.id === serviceTypeId),
[serviceTypeId, referenceData],
);
const direction = useMemo(() => {
const origin = referenceData?.yard.find((y) => y.name === originYard);
const destination = referenceData?.yard.find(
(y) => y.name === destinationYard,
);
return getRouteDirection(origin, destination);
}, [originYard, destinationYard, referenceData]);
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
@@ -339,29 +343,17 @@ export default function EditBookingPage() {
const yards = referenceData?.yard ?? [];
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
const findYardId = (name: string): string =>
yards.find((y) => y.name === name)?.id ?? "";
const findServiceTypeId = (): string => {
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
};
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const selectedChild =
data.cargoType !== "container" && data.bulkCommoditytype
? cargoTree
.find((g) => g.code.toLowerCase() === data.freightType)
?.children?.find((c) => c.name === data.bulkCommoditytype)
: undefined;
const cargoTypePath = data.cargoTypePath ?? [];
const cargoTypeId =
data.cargoType === "container" ? undefined : (selectedChild?.id ?? "");
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
const findContainerTypeId = (name: string): string => {
for (const group of containerGroups) {
@@ -379,11 +371,13 @@ export default function EditBookingPage() {
)
: Number(data.cargoWeight || 0);
const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
const apiPayload: Partial<CreateBookingPayload> = {
scheduledDate: new Date().toISOString().slice(0, 10),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: findServiceTypeId(),
serviceTypeId: data.serviceTypeId,
equipmentReturn:
data.equipmentReturn === "with_return"
? "WITH_RETURN"
@@ -391,9 +385,9 @@ export default function EditBookingPage() {
originYardId: findYardId(data.originYard),
destinationYardId: findYardId(data.destinationYard),
tradeDirection:
direction === "export"
direction === "EXPORT"
? "EXPORT"
: direction === "domestic"
: direction === "DOMESTIC"
? "DOMESTIC"
: "IMPORT",
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
@@ -420,10 +414,10 @@ export default function EditBookingPage() {
...(data.contractType === "renewal" && data.previousContractRef
? { pnrCode: data.previousContractRef }
: {}),
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
...(selectedSvc?.includesFirstMile && data.firstMile.enabled
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
: {}),
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
...(selectedSvc?.includesLastMile && data.lastMile.enabled
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
@@ -523,7 +517,7 @@ export default function EditBookingPage() {
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Controller
name="serviceType"
name="serviceTypeId"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
@@ -531,13 +525,9 @@ export default function EditBookingPage() {
error={fieldState.error}
label="Service Type *"
placeholder="Select service type..."
data={[
{ value: "rail", label: "Rail Transport Only" },
{
value: "rail_forwarding",
label: "Logistics (Rail + Forwarding)",
},
]}
data={(referenceData?.service ?? [])
.filter((s) => s.canBeBookedAlone)
.map((s) => ({ value: s.id, label: s.serviceName }))}
/>
)}
/>
@@ -560,7 +550,9 @@ export default function EditBookingPage() {
/>
</SimpleGrid>
{serviceType === "rail_forwarding" && (
{(selectedService?.includesFirstMile ||
selectedService?.includesLastMile ||
selectedService?.includesCustoms) && (
<Paper withBorder radius="md">
<Controller
name="firstMile.enabled"
@@ -570,7 +562,7 @@ export default function EditBookingPage() {
icon={<Truck size={16} color="#6B7C8E" />}
title="First Mile - Pick-up"
description="Truck pick-up from your premises to the origin rail yard."
checked={field.value}
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
@@ -610,7 +602,7 @@ export default function EditBookingPage() {
icon={<Truck size={16} color="#6B7C8E" />}
title="Last Mile - Delivery"
description="Truck delivery from the destination rail yard to the final address."
checked={field.value}
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
@@ -650,7 +642,7 @@ export default function EditBookingPage() {
icon={<FileText size={16} color="#6B7C8E" />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value}
checked={field.value ?? false}
onChange={field.onChange}
/>
)}
@@ -710,7 +702,7 @@ export default function EditBookingPage() {
</Alert>
)}
{direction && direction !== "domestic" && (
{direction && direction !== "DOMESTIC" && (
<Controller
name="shippingLine"
control={form.control}
@@ -763,7 +755,7 @@ export default function EditBookingPage() {
<Box>
<Step5CargoDetails
form={form}
direction={direction}
direction={direction!}
referenceData={referenceData}
isLoading={!referenceData}
/>

View File

@@ -179,11 +179,7 @@ export default function MyBookings() {
cell: ({ row }) => {
const b = row.original;
const cargoLabel =
b.freightType === "BULK"
? "Bulk Cargo"
: b.freightType === "BREAK_BULK"
? "Break Bulk"
: "Cargo";
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
return (
<Group gap={12} wrap="nowrap" align="center">
<Box

View File

@@ -193,7 +193,6 @@ export default function NewBookingPage() {
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -322,7 +321,12 @@ export default function NewBookingPage() {
)}
{step === 6 && <StepDocuments form={form} />}
{step === 7 && (
<Step8Review form={form} setStep={setStep} direction={direction!} />
<Step8Review
form={form}
setStep={setStep}
direction={direction!}
referenceData={referenceData}
/>
)}
</Box>

View File

@@ -2,13 +2,6 @@ import type { Freight } from "@edr/types";
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
export const MOCK_VALID_CONTRACTS = [
"EDR-2024-10001",
"EDR-2024-10002",
"EDR-2023-88123",
"EDR-2022-55442",
];
export const STEPS = [
{ id: 1, label: "Contract Type", short: "Contract" },
{ id: 2, label: "Service Type & Mile", short: "Service" },
@@ -75,6 +68,7 @@ export const bookingFormSchema = z
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
firstMile: z
.object({
enabled: z.boolean().default(false),
@@ -218,6 +212,7 @@ export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "",
serviceTypeId: "",
firstMile: {
enabled: false,
pickUpAddress: "",

View File

@@ -1,144 +0,0 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Field, FieldError, Input, Switch } from "@edr/ui-common";
import { type BookingFormValues } from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step3FirstLastMile({ form }: { form: BookingForm }) {
const firstMileEnabled = form.watch("firstMileEnabled");
const lastMileEnabled = form.watch("lastMileEnabled");
const equipmentReturn = form.watch("equipmentReturn");
return (
<div className="space-y-6">
<StepHeader
title="First & Last Mile"
description="Configure trucking and container return options."
/>
<div className="divide-y divide-border rounded-xl border border-border">
<div className="p-4">
<Controller
name="firstMileEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">First Mile - Pick-up</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
)}
/>
{firstMileEnabled && (
<Controller
name="pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Pick-up address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
<div className="p-4">
<Controller
name="lastMileEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Last Mile - Delivery</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck delivery from the destination rail yard to the final
address (Port to Door).
</p>
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
)}
/>
{lastMileEnabled && (
<Controller
name="deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Delivery address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
{lastMileEnabled && (
<div className="mt-4 border-t border-border pt-4">
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<Switch
checked={field.value === "with_return"}
onCheckedChange={(value) => {
field.onChange(
value ? "with_return" : "without_return",
);
}}
/>
</div>
)}
/>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -10,7 +10,11 @@ import {
} from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step4Route({
form,
@@ -31,44 +35,36 @@ export function Step4Route({
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({ value: sl.name, label: sl.name }));
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
}, [referenceData]);
const originData = useMemo(
() => {
return yardOptions.filter((o) => o.value !== destinationYard).filter((o) => {
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
if(!dest) return true;
const originData = useMemo(() => {
return yardOptions
.filter((o) => o.value !== destinationYard)
.filter((o) => {
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
if (!dest) return true;
const origin = referenceData?.yard.find((y) => y.id === o.value);
// can't go from Djibouti to Djibouti
if(dest?.country === 'Djibouti' && origin?.country == 'Djibouti') return false;
return true;
});
},
[yardOptions, destinationYard],
);
console.log({yardOptions,originYard, destinationYard})
const destData = useMemo(
() => {
return yardOptions.filter((o) => o.value !== originYard).filter((d) => {
const origin = referenceData?.yard.find((y) => y.id === originYard);
if(!origin) return true;
const dest = referenceData?.yard.find((y) => y.id === d.value);
// can't go from Djibouti to Djibouti
// if(origin.country === 'Djibouti' && dest?.country == 'Djibouti') return false;
if (dest?.country === "Djibouti" && origin?.country == "Djibouti")
return false;
return true;
});
},
[yardOptions, originYard],
);
}, [yardOptions, destinationYard]);
console.log({ yardOptions, originYard, destinationYard });
const destData = useMemo(() => {
return yardOptions.filter((o) => o.value !== originYard);
}, [yardOptions, originYard]);
const direction = getRouteDirection(referenceData?.yard.find((y) => y.id === originYard), referenceData?.yard.find((y) => y.name === destinationYard));
const direction = getRouteDirection(
referenceData?.yard.find((y) => y.id === originYard),
referenceData?.yard.find((y) => y.name === destinationYard),
);
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",

View File

@@ -1,65 +0,0 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Field, FieldError, SmartFileInput } from "@edr/ui-common";
import {
BOOKING_DOCS_SETTING,
REQUIRED_DOC_KEYS,
type BookingFormValues,
} from "./schema";
import { getUploadedRequiredCount, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step7Documents({ form }: { form: BookingForm }) {
const documents = form.watch("documents");
const uploadedRequired = getUploadedRequiredCount(documents);
const documentErrors = form.formState.errors.documents as
| Record<string, { message?: string }>
| undefined;
const smartFileErrors = Object.fromEntries(
Object.entries(documentErrors ?? {}).map(([key, value]) => [
key,
value?.message ?? "",
]),
);
return (
<div className="space-y-6">
<StepHeader
title="Compliance Documents"
description="Upload your company's legal credentials for EDR contract eligibility verification (US-04)."
/>
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold ${uploadedRequired === REQUIRED_DOC_KEYS.length
? "bg-emerald-100 text-emerald-700"
: "bg-primary/10 text-primary"
}`}
>
{uploadedRequired}/{REQUIRED_DOC_KEYS.length}
</div>
<p className="text-sm text-muted-foreground">
{uploadedRequired < REQUIRED_DOC_KEYS.length
? `${REQUIRED_DOC_KEYS.length - uploadedRequired} mandatory document(s) still needed.`
: "All mandatory documents uploaded. Power of Attorney is optional."}
</p>
</div>
<Controller
name="documents"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<SmartFileInput
file={BOOKING_DOCS_SETTING}
value={field.value}
onChange={(value) => field.onChange(value)}
errors={smartFileErrors}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea, Title } from "@mantine/core";
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core";
import {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
@@ -9,19 +9,28 @@ import {
import { StepHeader } from "./shared";
import type { Freight } from "@/types";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step8Review({
form,
setStep,
direction,
referenceData,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
}) {
const values = form.watch();
const errors = form.formState.errors;
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
function Row({
label,
@@ -56,17 +65,17 @@ export function Step8Review({
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0;
const documents = (values.documents ?? {}) as BookingDocuments;
@@ -76,14 +85,15 @@ export function Step8Review({
}).length;
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
const cargoValue =
values.cargoType === "container"
? containerSummary
: values.freightType === "bulk"
? `Bulk — ${values.bulkCommoditytype}`
: values.freightType === "break_bulk"
? `Break-Bulk`
: "";
const cargoValue = (() => {
if (values.cargoType === "container") return containerSummary;
if (!referenceData) return "";
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
if (!group) return "";
const child = group.children?.find((c) => c.id === path[1]);
return child ? `${group.name}${child.name}` : group.name;
})();
function ReviewCard({
title,
@@ -99,7 +109,13 @@ export function Step8Review({
py="sm"
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
>
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wider">
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
className="tracking-wider"
>
{title}
</Text>
</Box>
@@ -124,17 +140,7 @@ export function Step8Review({
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row
label="Service"
value={
values.serviceType === "rail"
? "Rail Only"
: values.serviceType === "rail_forwarding"
? "Rail + Forwarding"
: ""
}
target={2}
/>
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
</ReviewCard>
<ReviewCard title="First & Last Mile">
@@ -260,9 +266,7 @@ export function Step8Review({
}
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
error={
fieldState.error?.message ?? errors.termsAccepted?.message
}
error={fieldState.error?.message ?? errors.termsAccepted?.message}
color="edr-green"
radius="sm"
/>

View File

@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CheckCircle2, Loader2, Save, UserCheck, XCircle } from "lucide-react";
import { CheckCircle2, Save, UserCheck, XCircle } from "lucide-react";
import {
Card,
Group,
@@ -100,8 +100,8 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Text c="edr-muted" size="sm">
Power of Attorney details are optional. Fill them in if you have
an authorized representative, or leave blank.
Power of Attorney details are optional. Fill them in if you have an
authorized representative, or leave blank.
</Text>
<TextInput
@@ -162,13 +162,17 @@ export default function TabPowerOfAttorney({
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>Saved successfully</Text>
<Text size="sm" fw={500}>
Saved successfully
</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>Save failed</Text>
<Text size="sm" fw={500}>
Save failed
</Text>
</Group>
)}
</Group>

View File

@@ -1,174 +0,0 @@
import type { ReactNode } from "react";
import { Calendar, MapPin } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { consignments } from "../consignments/consignments.mock";
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
export interface ShipmentFormData {
consignmentId?: number;
consignmentReference?: string;
originStation?: string;
destinationStation?: string;
mode?: ShipmentMode;
status?: ShipmentStatus;
currentLocation?: string;
eta?: string;
}
export interface NewShipmentPageProps {
mode?: "create" | "edit";
shipment?: ShipmentFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
export default function NewShipmentPage({
mode = "create",
shipment,
children,
}: NewShipmentPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Shipment" : "New Shipment";
const description = isEdit
? "Update shipment tracking information."
: "Create a new shipment for real-time tracking.";
const submitLabel = isEdit ? "Save Changes" : "Create Shipment";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Shipment"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Consignment */}
<div className="space-y-2 md:col-span-2">
<Label>Consignment *</Label>
<select
defaultValue={shipment?.consignmentId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select consignment
</option>
{consignments.map((c) => (
<option key={c.id} value={c.id}>
{c.trackingNumber} {c.customer} ({c.originStation} {" "}
{c.destinationStation})
</option>
))}
</select>
<p className="text-xs text-slate-500">
Origin and destination auto-fill from the linked consignment.
</p>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={shipment?.status ?? "In Transit"}
className={selectClass}
>
<option>In Transit</option>
<option>Delivered</option>
<option>Delayed</option>
</select>
</div>
{/* Mode */}
<div className="space-y-2">
<Label>Transport Mode</Label>
<select
defaultValue={shipment?.mode ?? "rail"}
className={selectClass}
>
<option value="rail">Rail</option>
<option value="truck">Truck</option>
<option value="multimodal">Multimodal</option>
</select>
</div>
{/* Origin */}
<div className="space-y-2">
<Label>Origin Station</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.originStation ?? ""}
placeholder="Auto-filled from consignment"
className="pl-10"
/>
</div>
</div>
{/* Destination */}
<div className="space-y-2">
<Label>Destination Station</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.destinationStation ?? ""}
placeholder="Auto-filled from consignment"
className="pl-10"
/>
</div>
</div>
{/* ETA */}
<div className="space-y-2">
<Label>Estimated Arrival</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={shipment?.eta ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Current Location */}
<div className="space-y-2">
<Label>Current Location</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.currentLocation ?? ""}
placeholder="e.g. Dire Dawa Yard"
className="pl-10"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -8,8 +8,6 @@ import {
List,
MapPin,
MoreHorizontal,
Pencil,
Plus,
Search,
Train,
Trash2,
@@ -17,7 +15,6 @@ import {
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewShipmentPage from "./NewShipmentPage";
import DeleteShipmentDialog from "./DeleteShipmentDialog";
import {
shipments,
@@ -93,13 +90,6 @@ export default function TrackingPage() {
className="pl-8!"
/>
</div>
<NewShipmentPage>
<Button>
<Plus />
New Shipment
</Button>
</NewShipmentPage>
</div>
</Card>
@@ -279,7 +269,10 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t pt-3" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div
className="flex items-center justify-end gap-2 border-t pt-3"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
@@ -292,27 +285,12 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
<Eye />
View
</DropdownMenuItem>
<NewShipmentPage
mode="edit"
shipment={{
bookingId: shipment.bookingId,
bookingReference: shipment.bookingReference,
originStation: shipment.originStation,
destinationStation: shipment.destinationStation,
mode: shipment.mode,
status: shipment.status,
currentLocation: shipment.currentLocation,
eta: shipment.eta,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewShipmentPage>
<DropdownMenuSeparator />
<DeleteShipmentDialog shipmentReference={shipment.reference}>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()} variant="destructive">
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Remove
</DropdownMenuItem>
@@ -331,7 +309,9 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Shipment List</CardTitle>
<CardDescription>All shipments and their current status.</CardDescription>
<CardDescription>
All shipments and their current status.
</CardDescription>
</div>
</CardHeader>
@@ -435,27 +415,14 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
<Eye />
View
</DropdownMenuItem>
<NewShipmentPage
mode="edit"
shipment={{
bookingId: shipment.bookingId,
bookingReference: shipment.bookingReference,
originStation: shipment.originStation,
destinationStation: shipment.destinationStation,
mode: shipment.mode,
status: shipment.status,
currentLocation: shipment.currentLocation,
eta: shipment.eta,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewShipmentPage>
<DropdownMenuSeparator />
<DeleteShipmentDialog shipmentReference={shipment.reference}>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()} variant="destructive">
<DeleteShipmentDialog
shipmentReference={shipment.reference}
>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Remove
</DropdownMenuItem>

View File

@@ -14,6 +14,12 @@ import {
CreateBookingPayload,
GeneratePriceResponse,
} from "./bookings.service";
import {
paymentsService,
InitiatePaymentPayload,
InitiateResponse,
IntentStatus,
} from "./payments.service";
import { consignmentsService } from "./consignments.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -31,6 +37,7 @@ import {
import type {
CompanyInfoResponse,
CreateCompanyPayload,
DashboardSummary,
} from "./companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type {
@@ -113,6 +120,12 @@ export const api = {
"updateProfile",
companiesService.updateProfile,
),
getDashboard: endpoint<void, DashboardSummary>(
"companies",
"getDashboard",
companiesService.getDashboard,
),
},
bookings: {
@@ -174,12 +187,6 @@ export const api = {
bookingsService.uploadDocuments(id, files),
),
pay: endpoint<{ id: string }, { redirectUrl: string }>(
"bookings",
"pay",
({ id }) => bookingsService.pay(id),
),
checkPayment: endpoint<{ orderId: string }, { status: string }>(
"bookings",
"checkPayment",
@@ -194,6 +201,20 @@ export const api = {
),
},
payments: {
initiate: endpoint<InitiatePaymentPayload, InitiateResponse>(
"payments",
"initiate",
paymentsService.initiate,
),
getIntent: endpoint<{ bookingId: string }, IntentStatus>(
"payments",
"getIntent",
({ bookingId }) => paymentsService.getIntent(bookingId),
),
},
consignments: {
list: endpoint<void, PaginatedResponse<Freight.IConsignment>>(
"consignments",

View File

@@ -139,11 +139,6 @@ export const bookingsService = {
return data.data ?? data;
},
pay: async (id: string): Promise<{ redirectUrl: string }> => {
const { data } = await client.post(`/api/bookings/${id}/payment/pay`);
return data.data ?? data;
},
signContract: async (
id: string,
payload: SignContractPayload,

View File

@@ -59,6 +59,26 @@ export interface CreateCompanyPayload {
attributes?: Record<string, any>;
}
export interface FreightVolumePoint {
month: string;
tonnes: number;
}
export interface DashboardSummary {
deliveredCount: number;
completionRate: number;
spendYtd: number;
spendCurrency: string;
spendYtdChangePct: number;
freightVolume: {
totalTonnes: number;
totalValue: number;
currency: string;
ytdChangePct: number;
monthly: FreightVolumePoint[];
};
}
export const companiesService = {
getInfo: async (): Promise<CompanyInfoResponse | null> => {
try {
@@ -97,6 +117,13 @@ export const companiesService = {
return unwrap(response.data);
},
getDashboard: async (): Promise<DashboardSummary> => {
const response = await client.get<ApiResponse<DashboardSummary>>(
URL_CONSTANTS.COMPANIES_API.DASHBOARD,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,

View File

@@ -0,0 +1,86 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
const P = URL_CONSTANTS.PAYMENTS;
/** Payment methods supported by the central payment microservice. */
export type PaymentMethod =
| "TELEBIRR"
| "CBE_BIRR"
| "EBIRR"
| "WAAFI"
| "CARD"
| "DMONEY"
| "CAC_BANK";
export type PaymentPlatform = "web" | "mobile";
export interface InitiatePaymentPayload {
bookingId: string;
method: PaymentMethod;
platform?: PaymentPlatform;
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export interface ClientAction {
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
url?: string;
appId?: string;
receiveCode?: string;
shortCode?: string;
providerOrderId?: string;
message?: string;
}
export interface InitiateResponse {
intentId: string;
status: string;
clientAction?: ClientAction;
merchantOrderId?: string;
}
export interface IntentStatus extends InitiateResponse {
paidAt?: string;
failureCode?: string;
failureMessage?: string;
}
/**
* Builds the absolute URL for the public browser-checkout page, which
* (re)initiates the payment and auto-redirects to the provider's checkout.
* Used as the "pay" step after a successful `initiate`.
*/
function buildCheckoutUrl(payload: {
bookingId: string;
method: PaymentMethod;
platform?: PaymentPlatform;
}): string {
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
const params = new URLSearchParams({
bookingId: payload.bookingId,
method: payload.method,
platform: payload.platform ?? "web",
});
return `${base}${P.CHECKOUT}?${params.toString()}`;
}
export const paymentsService = {
initiate: async (
payload: InitiatePaymentPayload,
): Promise<InitiateResponse> => {
const { data } = await client.post(P.INITIATE, {
platform: "web",
...payload,
});
return data.data ?? data;
},
getIntent: async (bookingId: string): Promise<IntentStatus> => {
const { data } = await client.get(P.INTENT(bookingId));
return data.data ?? data;
},
checkoutUrl: buildCheckoutUrl,
};

View File

@@ -88,6 +88,19 @@ WAAFI_INSECURE_TLS=false
# Payment Configuration
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
# Browser return targets after a hosted payment page (UX only — payment is confirmed by the
# webhook/queryStatus, never this redirect). Global fallback used when a method-specific URL
# below is unset. Most providers use a single redirect; Waafi takes separate success/failure.
PAYMENT_RETURN_URL=
PAYMENT_FAILURE_URL=
TELEBIRR_RETURN_URL=
WAAFI_SUCCESS_REDIRECT=
WAAFI_FAIL_REDIRECT=
DMONEY_RETURN_URL=
CBE_RETURN_URL=
EBIRR_RETURN_URL=
CARD_RETURN_URL=
# Session Configuration
SESSION_INACTIVITY_MINUTES=30

View File

@@ -19,7 +19,6 @@
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
"prisma:verify": "ts-node prisma/verify-backfill.ts"
},
"dependencies": {
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
@@ -29,6 +28,7 @@
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/microservices": "^11.1.24",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
@@ -47,7 +47,8 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0"
"tsconfig-paths": "^4.2.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
@@ -62,6 +63,7 @@
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.0",
"jest": "^29.7.0",
"prisma": "^6.19.3",
"supertest": "^7.0.0",

View File

@@ -0,0 +1,36 @@
-- Add sequence column to Station table if it doesn't exist
ALTER TABLE "passenger"."Station" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
-- Add index on sequence for Station
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "passenger"."Station"("sequence");
-- Add sequence column to Coach table if it doesn't exist
ALTER TABLE "passenger"."Coach" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
-- Add index on sequence for Coach
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "passenger"."Coach"("sequence");
-- Add missing columns to SeatClass if they don't exist
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "premiumMinor" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0;
-- Add missing columns to User if they don't exist
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "gender" VARCHAR(255);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "passportNumber" VARCHAR(255);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "nationalId" VARCHAR(255);
-- Ensure Ticket has all required columns
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "validatedAt" TIMESTAMP(3);
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
-- Add missing columns to Booking if they don't exist
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "bookingType" VARCHAR(255) NOT NULL DEFAULT 'ONE_WAY';
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayCurrency" VARCHAR(255);
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayTotalMinor" INTEGER;
-- Ensure all indexes exist
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "passenger"."Station"("city", "countryCode");
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "passenger"."Coach"("coachTypeId");
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "passenger"."TrainSchedule"("departureAt", "originStationId");
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "passenger"."Booking"("passengerId", "status");

View File

@@ -0,0 +1,164 @@
-- Add CASCADE delete to all foreign key constraints that are missing it
-- TrainSchedule relations
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "passenger"."Train"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "passenger"."Route"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
-- Coach relation
ALTER TABLE "passenger"."Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
ALTER TABLE "passenger"."Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "passenger"."CoachType"("id") ON DELETE CASCADE;
-- CoachAssignment relations
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "passenger"."Coach"("id") ON DELETE CASCADE;
-- Booking relations
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
-- BookingSeat relations
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
-- PaymentIntent
ALTER TABLE "passenger"."PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
ALTER TABLE "passenger"."PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- PaymentRefund
ALTER TABLE "passenger"."PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
ALTER TABLE "passenger"."PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "passenger"."PaymentIntent"("id") ON DELETE CASCADE;
-- Ticket
ALTER TABLE "passenger"."Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
ALTER TABLE "passenger"."Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- TicketSeat
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
-- WalletLedgerEntry
ALTER TABLE "passenger"."WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
ALTER TABLE "passenger"."WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "passenger"."WalletAccount"("id") ON DELETE CASCADE;
-- Notification
ALTER TABLE "passenger"."Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
ALTER TABLE "passenger"."Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
-- MenuItem
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."MenuCategory"("id") ON DELETE CASCADE;
-- FoodOrder
ALTER TABLE "passenger"."FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
ALTER TABLE "passenger"."FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- FoodOrderItem
ALTER TABLE "passenger"."FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
ALTER TABLE "passenger"."FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "passenger"."FoodOrder"("id") ON DELETE CASCADE;
-- FaqArticle
ALTER TABLE "passenger"."FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
ALTER TABLE "passenger"."FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."FaqCategory"("id") ON DELETE CASCADE;
-- SupportMessage
ALTER TABLE "passenger"."SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
ALTER TABLE "passenger"."SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "passenger"."SupportConversation"("id") ON DELETE CASCADE;
-- TripStopTime
ALTER TABLE "passenger"."TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
ALTER TABLE "passenger"."TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
-- TripLiveStatus
ALTER TABLE "passenger"."TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
ALTER TABLE "passenger"."TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
-- JourneySegment
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
-- AgentBooking
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- AgentShift
ALTER TABLE "passenger"."AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
ALTER TABLE "passenger"."AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
-- AgentCommission
ALTER TABLE "passenger"."AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
ALTER TABLE "passenger"."AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
-- BookingModification
ALTER TABLE "passenger"."BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
ALTER TABLE "passenger"."BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- BookingCancellation
ALTER TABLE "passenger"."BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
ALTER TABLE "passenger"."BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- GateValidationLog
ALTER TABLE "passenger"."GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
ALTER TABLE "passenger"."GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE;
-- BaggageBooking
ALTER TABLE "passenger"."BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
ALTER TABLE "passenger"."BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
-- RouteFareRule
ALTER TABLE "passenger"."RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
ALTER TABLE "passenger"."RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
-- SegmentFareRule
ALTER TABLE "passenger"."SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
ALTER TABLE "passenger"."SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
-- StationCrowdSignal
ALTER TABLE "passenger"."StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
ALTER TABLE "passenger"."StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
-- SeatBlock
ALTER TABLE "passenger"."SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
ALTER TABLE "passenger"."SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
-- SavedRoute
ALTER TABLE "passenger"."SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
ALTER TABLE "passenger"."SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
-- LoyaltyLedgerEntry
ALTER TABLE "passenger"."LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
ALTER TABLE "passenger"."LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE;
-- LoyaltyReward
ALTER TABLE "passenger"."LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
ALTER TABLE "passenger"."LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE;
-- FareRule
ALTER TABLE "passenger"."FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
ALTER TABLE "passenger"."FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;

View File

@@ -84,19 +84,20 @@ model CoachType {
}
model SeatClass {
id String @id @default(uuid())
coachTypeId String
name String
description String?
baseFareMinor Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
fareRules FareRule[]
routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[]
id String @id @default(uuid())
coachTypeId String
name String
description String?
baseFareMinor Int @default(0) // per-km rate
premiumMinor Int @default(0) // flat fee per passenger
insuranceFeeMinor Int @default(0) // flat fee per passenger
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
fareRules FareRule[]
routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[]
@@unique([coachTypeId, name])
@@index([coachTypeId])
@@schema("passenger")
@@ -226,22 +227,24 @@ enum DevicePlatform {
}
model User {
id String @id @default(uuid())
email String @unique
phone String @unique
fullName String
passwordHash String
role UserRole @default(PASSENGER)
nationality String?
nationalityCode String?
passportNumber String?
nationalId String?
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
blockedUntil DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
email String @unique
phone String @unique
fullName String
passwordHash String
role UserRole @default(PASSENGER)
nationality String?
nationalityCode String?
gender String? // Male, Female, Other
dateOfBirth DateTime?
passportNumber String?
nationalId String?
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
blockedUntil DateTime?
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
faydaVerified Boolean @default(false)
faydaVerifiedAt DateTime?
@@ -307,21 +310,22 @@ model TravelerProfile {
}
model Station {
id String @id @default(uuid())
code String @unique
name String
city String
countryCode String?
isOperational Boolean @default(true)
timezone String @default("Africa/Addis_Ababa")
lat Decimal @db.Decimal(9, 6)
lng Decimal @db.Decimal(9, 6)
originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
id String @id @default(uuid())
code String @unique
name String
city String
countryCode String?
sequence Int @default(0)
isOperational Boolean @default(true)
timezone String @default("Africa/Addis_Ababa")
lat Decimal @db.Decimal(9, 6)
lng Decimal @db.Decimal(9, 6)
originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[]
@@index([city, countryCode])
@@index([sequence])
@@schema("passenger")
}
@@ -402,19 +406,20 @@ model TripLiveStatus {
}
model Coach {
id String @id @default(uuid())
coachTypeId String
number String @unique
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
capacity Int @default(0) // Total seats/beds
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
coachTypeId String
number String @unique
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
capacity Int @default(0) // Total seats/beds
sequence Int @default(0)
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[]
assignments CoachAssignment[]
@@index([coachTypeId])
@@index([sequence])
@@schema("passenger")
}
@@ -628,20 +633,21 @@ model PaymentRefund {
}
model Ticket {
id String @id @default(uuid())
bookingId String @unique
bookingRef String
status String @default("CONFIRMED")
qrPayload String
barcodePayload String?
pdfUrl String?
deliveryChannel String @default("EMAIL")
issuedAt DateTime @default(now())
validatedAt DateTime?
validatorId String?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]
id String @id @default(uuid())
bookingId String @unique
bookingRef String
status String @default("ACTIVE")
qrPayload String
barcodePayload String?
pdfUrl String?
deliveryChannel String @default("EMAIL")
issuedAt DateTime @default(now())
validatedAt DateTime?
validatorId String?
boardedAt DateTime?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]
@@schema("passenger")
}

View File

@@ -4,7 +4,6 @@ import { randomUUID as uuidv4 } from 'crypto';
const prisma = new PrismaClient();
const EDR_ROUTE_ID = uuidv4();
const TRAIN_ID = uuidv4();
async function seedSystemUsers() {
@@ -24,6 +23,10 @@ async function seedSystemUsers() {
phone: '+251900000000',
passwordHash: adminHash,
role: 'ADMIN',
gender: 'Male',
dateOfBirth: new Date('1980-05-20'),
nationality: 'Ethiopian',
nationalId: 'ET123456789',
},
});
console.log(' ✅ Admin: admin@edr-platform.com / admin123');
@@ -39,6 +42,9 @@ async function seedSystemUsers() {
role: 'PASSENGER',
nationality: 'Ethiopian',
faydaVerified: true,
gender: 'Male',
dateOfBirth: new Date('1990-03-15'),
nationalId: 'ET987654321',
},
});
@@ -49,7 +55,7 @@ async function seedSystemUsers() {
data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' },
});
await prisma.walletAccount.create({
data: { passengerId: passengerRecord.id, balanceMinor: 50000 },
data: { passengerId: passengerRecord.id, balanceMinor: 500 },
});
}
await prisma.userPreferences.upsert({
@@ -68,6 +74,8 @@ async function seedSystemUsers() {
phone: '+251911111111',
passwordHash: agentHash,
role: 'AGENT',
gender: 'Female',
dateOfBirth: new Date('1992-07-22'),
},
});
await prisma.agent.upsert({
@@ -86,6 +94,8 @@ async function seedSystemUsers() {
phone: '+251922222222',
passwordHash: supervisorHash,
role: 'SUPERVISOR',
gender: 'Male',
dateOfBirth: new Date('1985-11-10'),
},
});
console.log(' ✅ Supervisor: supervisor@edr-platform.com / supervisor123');
@@ -99,6 +109,8 @@ async function seedSystemUsers() {
phone: '+251933333333',
passwordHash: staffHash,
role: 'STAFF',
gender: 'Female',
dateOfBirth: new Date('1995-09-08'),
},
});
console.log(' ✅ Staff: staff@edr-platform.com / staff123');
@@ -107,21 +119,21 @@ async function seedSystemUsers() {
async function seedStations() {
console.log('\n📍 Seeding 15 stations (Ethio-Djibouti Railway)...');
const stations = [
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150 },
{ code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240 },
{ code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780 },
{ code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920 },
{ code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450 },
{ code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670 },
{ code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578 },
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150 },
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670 },
{ code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340 },
{ code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560 },
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450 },
{ code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200 },
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150, sequence: 1 },
{ code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320, sequence: 2 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240, sequence: 3 },
{ code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130, sequence: 4 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780, sequence: 5 },
{ code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920, sequence: 6 },
{ code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450, sequence: 7 },
{ code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670, sequence: 8 },
{ code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578, sequence: 9 },
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150, sequence: 10 },
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670, sequence: 11 },
{ code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340, sequence: 12 },
{ code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560, sequence: 13 },
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450, sequence: 14 },
{ code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200, sequence: 15 },
];
for (const station of stations) {
@@ -142,8 +154,8 @@ async function seedCoachTypesAndClasses() {
console.log('\n🚂 Seeding coach types and seat classes...');
const coachTypes = [
{ code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' },
{ code: 'HBC', name: 'Hard Bed Coach', type: 'Economy Bed' },
{ code: 'SBC', name: 'Soft Bed Coach', type: 'VIP Bed' },
{ code: 'HBC', name: 'Hard Berth Coach', type: 'Economy Bed' },
{ code: 'SBC', name: 'Soft Berth Coach', type: 'VIP Bed' },
];
for (const ct of coachTypes) {
@@ -155,12 +167,12 @@ async function seedCoachTypesAndClasses() {
}
const seatClasses = [
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900 },
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800 },
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600 },
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550 },
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500 },
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250 },
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
];
for (const sc of seatClasses) {
@@ -168,7 +180,7 @@ async function seedCoachTypesAndClasses() {
await prisma.seatClass.upsert({
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
update: {},
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor },
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
});
}
console.log(`${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
@@ -176,14 +188,12 @@ async function seedCoachTypesAndClasses() {
async function seedRoute() {
console.log('\n🛣 Seeding route and stops...');
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } });
const route = await prisma.route.upsert({
where: { code: 'EDR-101' },
where: { code: 'Route-101' },
update: {},
create: {
code: 'EDR-101',
code: 'Route-101',
name: 'Sebeta - Dire Dawa',
description: 'Outbound local route from Sebeta to Dire Dawa',
effectiveFrom: new Date('2026-01-01'),
@@ -193,15 +203,40 @@ async function seedRoute() {
});
const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE'];
const routeDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0];
for (let i = 0; i < stationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
await prisma.routeStop.upsert({
where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } },
update: {},
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: i * 85 },
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
});
}
console.log(` ✅ Route with ${stationCodes.length} stops created`);
const returnRoute = await prisma.route.upsert({
where: { code: 'Route-102' },
update: {},
create: {
code: 'Route-102',
name: 'Dire Dawa - Sebeta',
description: 'Inbound local route from Dire Dawa to Sebeta',
effectiveFrom: new Date('2026-01-01'),
effectiveUntil: new Date('2034-12-31'),
active: true,
},
});
const returnStationCodes = ['DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
const returnRouteDistancesKm = [0, 119.4, 181.4, 232.8, 306.3, 323.1, 345.8, 401.5, 413.0];
for (let i = 0; i < returnStationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: returnStationCodes[i] } });
await prisma.routeStop.upsert({
where: { routeId_sequence: { routeId: returnRoute!.id, sequence: i + 1 } },
update: {},
create: { routeId: returnRoute!.id, stationId: station!.id, sequence: i + 1, distanceKm: returnRouteDistancesKm[i] },
});
}
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
}
async function seedCoaches() {
@@ -211,9 +246,9 @@ async function seedCoaches() {
const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } });
const coaches = [
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 40 },
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66 },
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 120 },
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 128, sequence: 1 },
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66, sequence: 2 },
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 40, sequence: 3 },
];
let totalSeats = 0;
@@ -230,19 +265,23 @@ async function seedCoaches() {
// FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them.
let seatIndex = 1;
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
for (const col of ['A', 'B', 'C', 'D']) {
for (const col of ['A', 'B', 'C', 'D', 'E']) {
if (seatIndex > coach.capacity) break;
let bedPosition: string | null = null;
if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) {
if (c.coachTypeId === ecoBedCoachType!.id) {
// Economy Bed: 3-row cycle (upper, middle, lower)
if (row % 3 === 1) bedPosition = 'upper';
else if (row % 3 === 2) bedPosition = 'middle';
else bedPosition = 'lower';
} else if (c.coachTypeId === vipBedCoachType!.id) {
// VIP Bed: 2-row cycle (upper, lower)
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
}
const seatData = {
seatNumber: seatIndex.toString(),
isWindow: col === 'A' || col === 'D',
isAisle: col === 'B' || col === 'C',
isWindow: col === 'A' || col === 'E',
isAisle: col === 'B' || col === 'C' || col === 'D',
bedPosition,
};
@@ -264,67 +303,102 @@ async function seedTrips() {
const train = await prisma.train.upsert({
where: { number: 'EDR-001' },
update: {},
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' },
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Express Service' },
});
const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
const lastStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
const firstReturnStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
const lastReturnStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
const coaches = await prisma.coach.findMany();
const now = new Date();
const schedules = [];
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
for (let d = 0; d < 30; d++) {
const schedules = [];
for (let d = 0; d < 5; d++) {
const tripDate = new Date(now);
tripDate.setDate(tripDate.getDate() + d);
tripDate.setHours(8, 0, 0, 0);
const departureAt = new Date(tripDate);
const arrivalAt = new Date(departureAt.getTime() + 4 * 24 * 60 * 60 * 1000);
tripDate.setHours(20, 30, 0, 0);
schedules.push({
trainId: train.id,
routeId: route!.id,
originStationId: firstStation!.id,
destinationStationId: lastStation!.id,
departureAt,
arrivalAt,
durationMinutes: 4 * 24 * 60,
stopsCount: 15,
departureAt: new Date(tripDate),
arrivalAt: new Date(tripDate), // patched below
durationMinutes: 0, // patched below
stopsCount: 9,
});
}
const createdSchedules = await Promise.all(
schedules.map(s => prisma.trainSchedule.create({ data: s }))
);
// Create TripStopTimes for each schedule
const routeStops = await prisma.routeStop.findMany({
where: { routeId: route!.id },
orderBy: { sequence: 'asc' },
include: { route: true },
for (let d = 0; d < 5; d++) {
const returnTripDate = new Date(tomorrow);
returnTripDate.setDate(returnTripDate.getDate() + d);
returnTripDate.setHours(20, 0, 0, 0);
schedules.push({
trainId: train.id,
routeId: returnRoute!.id,
originStationId: firstReturnStation!.id,
destinationStationId: lastReturnStation!.id,
departureAt: new Date(returnTripDate),
arrivalAt: new Date(returnTripDate), // patched below
durationMinutes: 0, // patched below
stopsCount: 9,
});
}
// Load route stops for both routes upfront
const routeStopsMap = new Map<string, { stationId: string; sequence: number; distanceKm: number }[]>();
for (const r of [route!, returnRoute!]) {
const stops = await prisma.routeStop.findMany({
where: { routeId: r.id },
orderBy: { sequence: 'asc' },
});
routeStopsMap.set(r.id, stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm! })));
}
// Compute duration from total route distance at 60 km/h
function routeDuration(stops: { distanceKm: number }[]): number {
const totalKm = stops[stops.length - 1].distanceKm - stops[0].distanceKm;
return Math.ceil(totalKm / 60 * 60);
}
// Patch arrivalAt and durationMinutes using distance-based timing
const patchedSchedules = schedules.map(s => {
const stops = routeStopsMap.get(s.routeId!)!;
const durationMinutes = routeDuration(stops);
return { ...s, durationMinutes, arrivalAt: new Date(s.departureAt.getTime() + durationMinutes * 60_000) };
});
const createdSchedules = await Promise.all(
patchedSchedules.map(s => prisma.trainSchedule.create({ data: s }))
);
// Create TripStopTimes using cumulative distanceKm at 60 km/h
for (const schedule of createdSchedules) {
const stopTimes = [];
for (const routeStop of routeStops) {
const minutesFromStart = (routeStop.sequence - 1) * 480; // 8 hours per stop
const stops = routeStopsMap.get(schedule.routeId!)!;
const originKm = stops[0].distanceKm;
const stopTimes = stops.map(stop => {
const minutesFromStart = Math.ceil((stop.distanceKm - originKm) / 60 * 60);
const plannedDepartureAt = new Date(schedule.departureAt.getTime() + minutesFromStart * 60_000);
const plannedArrivalAt = new Date(plannedDepartureAt.getTime() + 30 * 60_000); // 30 min stop
stopTimes.push({
const plannedArrivalAt = new Date(plannedDepartureAt.getTime() - 5 * 60_000); // 5 min dwell
return {
scheduleId: schedule.id,
stationId: routeStop.stationId,
sequence: routeStop.sequence,
stationId: stop.stationId,
sequence: stop.sequence,
plannedArrivalAt,
plannedDepartureAt,
});
}
await Promise.all(
stopTimes.map(st => prisma.tripStopTime.create({ data: st }))
);
};
});
// First stop: arrival = departure (no dwell at origin)
stopTimes[0].plannedArrivalAt = stopTimes[0].plannedDepartureAt;
await Promise.all(stopTimes.map(st => prisma.tripStopTime.create({ data: st })));
}
const coachAssignments = [];
@@ -355,7 +429,7 @@ async function seedTrips() {
async function seedFareRules() {
console.log('\n💰 Seeding fare rules...');
const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
const seatClasses = await prisma.seatClass.findMany();
const validFrom = new Date('2024-01-01');
@@ -374,7 +448,7 @@ async function seedFareRules() {
seatClassId: sc.id,
passengerCategory: 'CHILD' as const,
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
discountPercent: 50,
discountPercent: 10,
currency: 'ETB',
validFrom,
});
@@ -422,6 +496,7 @@ async function seedPaymentMethods() {
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' },
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' },
{ type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' },
{ type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' },
{ type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' },
{ type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' },
];
@@ -436,13 +511,50 @@ async function seedPaymentMethods() {
console.log(`${methods.length} payment methods created`);
}
async function seedSegmentFares() {
console.log('\n📍 Seeding segment fare rules...');
const route = await prisma.route.findUnique({
where: { code: 'Route-101' },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
const seatClasses = await prisma.seatClass.findMany();
const validFrom = new Date('2024-01-01');
if (route && route.stops.length > 2) {
for (const sc of seatClasses) {
await prisma.segmentFareRule.create({
data: {
routeId: route.id,
seatClassId: sc.id,
originStopSequence: 1,
destinationStopSequence: 3,
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
validFrom,
},
}).catch(() => {});
await prisma.segmentFareRule.create({
data: {
routeId: route.id,
seatClassId: sc.id,
originStopSequence: 5,
destinationStopSequence: 9,
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
validFrom,
},
}).catch(() => {});
}
console.log(`${seatClasses.length * 2} segment fare rules created`);
}
}
async function seedNotificationTemplates() {
console.log('\n🔔 Seeding notification templates...');
const templates = [
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed' },
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment received for {{bookingRef}}' },
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip departs in {{minutes}} minutes' },
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip is delayed by {{delayMinutes}} minutes' },
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' },
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' },
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
{ id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
];
@@ -476,13 +588,13 @@ async function seedMenuAndFood() {
const sandwichId = uuidv4();
await prisma.menuItem.create({
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 5000 },
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
}).catch(() => {}); // ignore if exists
await prisma.menuItem.create({
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 3500 },
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
}).catch(() => {}); // ignore if exists
await prisma.menuItem.create({
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 8000 },
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
}).catch(() => {}); // ignore if exists
}
console.log(` ✅ Menu categories and items created`);
@@ -493,7 +605,7 @@ async function seedPromotions() {
const promos = [
{ id: uuidv4(), title: 'Early Bird Discount', code: 'EARLY20', percentOff: 20, validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
{ id: uuidv4(), title: 'Student Discount', code: 'STUDENT15', percentOff: 15, validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) },
{ id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 10000, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
{ id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 100, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
];
for (const p of promos) {
@@ -583,6 +695,7 @@ async function main() {
['promotions', seedPromotions],
['FAQ', seedFAQ],
['fraud rules', seedFraudRules],
['segment fares', seedSegmentFares],
];
let failed = 0;

View File

@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import { AuditModule } from './common/audit.module';
import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware';
@@ -39,6 +40,8 @@ import { FraudModule } from './modules/fraud/fraud.module';
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { AuditModuleFeature } from './modules/audit/audit.module';
import { CurrenciesModule } from './modules/currencies/currencies.module';
@Module({
imports: [
@@ -59,6 +62,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
PrismaModule,
AuditModule,
I18nModule,
IamModule,
AuthModule,
@@ -85,6 +89,8 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
SeatClassesModule,
FareEngineModule,
VerifaydaModule,
AuditModuleFeature,
CurrenciesModule,
],
})
export class AppModule implements NestModule {

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module';
import { AuditService } from './audit.service';
@Module({
imports: [PrismaModule],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,92 @@
import { Injectable, Inject, Optional } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PrismaService } from './prisma.service';
@Injectable()
export class AuditService {
constructor(
private prisma: PrismaService,
@Optional() @Inject(REQUEST) private request?: any,
) {}
async log(input: {
userId?: string;
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
entityType: string;
entityId?: string;
oldData?: any;
newData?: any;
}) {
try {
const ipAddress = this.getIpAddress();
const userAgent = this.getUserAgent();
await this.prisma.auditLog.create({
data: {
userId: input.userId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
oldData: input.oldData,
newData: input.newData,
ipAddress,
userAgent,
},
});
} catch (error) {
console.error('Failed to log audit event:', error);
// Don't throw - audit logging should not break main operations
}
}
private getIpAddress(): string {
if (!this.request) return '';
return (
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
this.request.headers['x-real-ip'] ||
this.request.connection?.remoteAddress ||
this.request.socket?.remoteAddress ||
this.request.ip ||
''
);
}
private getUserAgent(): string {
return this.request?.headers?.['user-agent'] || '';
}
async getLogs(filters: any = {}) {
const where: any = {};
if (filters.search) {
where.OR = [
{ entityId: { contains: filters.search, mode: 'insensitive' } },
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
];
}
if (filters.action) {
where.action = filters.action;
}
if (filters.entityType) {
where.entityType = filters.entityType;
}
return this.prisma.auditLog.findMany({
where,
include: { user: true },
orderBy: { createdAt: 'desc' },
take: 500, // Limit to last 500 logs
});
}
async getLog(id: string) {
return this.prisma.auditLog.findUnique({
where: { id },
include: { user: true },
});
}
}

View File

@@ -6,7 +6,7 @@ import { registerAs } from '@nestjs/config';
* never interfere. Points at the dedicated `payment` vhost on the shared broker.
*/
export default registerAs('rabbitmq', () => ({
url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment',
url: process.env.PAYMENT_RABBITMQ_URL,
/** Max unacked payment events held by this consumer at once. */
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
}));

View File

@@ -34,6 +34,14 @@ async function bootstrap() {
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## 🆕 Latest Updates
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
- **Multi-Currency Display:** Bookings track display currency and converted amounts
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
## Key Features
### 🎫 Booking Lifecycle
@@ -47,6 +55,11 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds
- Multi-segment journey support
- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
- Round-trip booking with return journey scheduling
- Coach type selection with seat class and pricing options
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
- **NEW:** Display currency and converted pricing per booking
### 👤 Passenger Verification
1. **Ethiopian Nationals:**
@@ -65,12 +78,13 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free)
- **NEW:** Premium charges and insurance fees per seat class
- **NEW:** Transparent fee breakdown in pricing calculations
### 💳 Payment Integration
1. **Ethiopian Payment Methods:**
- **Telebirr** - Ethiopia's leading mobile money
- **CBE Birr** - Commercial Bank of Ethiopia
- **eBirr** - Electronic payment gateway
2. **Djiboutian Payment Methods:**
- **Waafi** - Djibouti's mobile money service
@@ -84,8 +98,9 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps
- Coach-level seat maps (ordered by sequence)
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
- **NEW:** Sequence-based coach ordering for consistent display
### 🎟️ Ticketing
- QR code and barcode generation
@@ -93,6 +108,8 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
- **NEW:** Complete audit trail for compliance and reporting
### 🏆 Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum
@@ -118,16 +135,50 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Failed payment pattern detection
- Automatic user blocking
### 👤 Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers
- **NEW:** Complete demographic data for personalized services
- **NEW:** Improved user targeting and communications
### 🌍 Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- Currency formatting (ETB, DJF, USD)
- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
### 👨‍💼 Agent Operations
- Counter booking
- Shift management
- Commission tracking
- Cash reconciliation
### 🚌 Transit Stop Management
- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
- Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow
- Transit stop optimization and route planning
### 🔄 Round-Trip Booking
- One-way and round-trip journey options
- Flexible return date selection
- Combined pricing for outbound + return legs
- Separate seat management per leg
- Independent modification/cancellation per leg
- Return journey tracking and notifications
- **NEW:** Booking type stored for analytics and reporting
### 🚐 Coach Type & Class Selection
- Browse available coach types per route (standard coaches, premium coaches)
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
- Compare base prices by coach type and class
- Real-time availability per coach configuration
- Deferred pricing at seat selection stage
- Coach amenities and features display
- **NEW:** Sequence-based coach ordering for consistent UI
- **NEW:** Premium and insurance fee transparency per class
### 📊 Data Organization
- **Stations:** Ordered by sequence (1-15) for consistent route display
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
- **Booking History:** Sorted chronologically with filtering options
## Authentication
@@ -197,7 +248,6 @@ List endpoints support pagination:
Payment providers send notifications to:
- \`POST /payments/webhooks/telebirr\` (Ethiopia)
- \`POST /payments/webhooks/cbe-birr\` (Ethiopia)
- \`POST /payments/webhooks/ebirr\` (Ethiopia)
- \`POST /payments/webhooks/waafi\` (Djibouti)
- \`POST /payments/webhooks/card\` (International)
@@ -212,33 +262,38 @@ Payment providers send notifications to:
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
"JWT-auth",
)
.addTag("Agents", "Counter booking, shift management, and commission tracking")
.addTag("Auth", "User registration, login, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel")
.addTag("Dashboard", "Aggregated dashboard data for home screen")
.addTag("Fare Engine", "Distance-based fare calculator with multi-currency support")
.addTag("Fayda Verification", "Ethiopian national ID verification via government API")
.addTag("Fleet", "Train services, coaches, and seat configurations")
.addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking")
.addTag("Live Tracking", "Real-time trip status, delays, and station crowds")
.addTag("Loyalty", "Points accumulation, tiers, and reward redemption")
.addTag("Notifications", "Multi-channel notifications: email, SMS, push")
.addTag("Passengers", "Passenger registration, verification, and profiles")
.addTag("Payment", "Payment processing, intents, and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers")
.addTag("Promotions", "Promo codes, campaigns, and discount management")
.addTag("Reports", "Sales reports, occupancy analytics, and metrics")
.addTag("Routes", "Route templates with stops and fare rules")
.addTag("Schedule", "Trip schedules, availability, and status updates")
.addTag("Search", "Trip search, availability checks, and fare quotes")
.addTag("Seat Classes", "Seat class management: Economy, VIP configurations")
.addTag("Seats", "Seat maps, holds, releases, and blocking")
.addTag("Segment-based Seats", "Segment-level seat allocation and availability")
.addTag("Stations", "Station directory and information")
.addTag("Support", "FAQ management and live chat support")
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
.addTag("Config", "System configuration and settings")
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
.addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
.addTag("Fare Engine", "Distance-based fare calculation with age-based pricing and multi-currency")
.addTag("Fayda Verification", "Ethiopian national ID verification via Verifayda 2.0 government API")
.addTag("Fleet", "Train services, coaches, coach types, seat classes, amenities, and configurations")
.addTag("Fraud Detection", "Velocity checks, monitoring alerts, pattern detection, and user blocking")
.addTag("Internal Payments", "Internal payment tracking, wallet transactions, and balance management")
.addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals")
.addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards")
.addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management")
.addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles")
.addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds")
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
.addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
.addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed class configuration and pricing")
.addTag("Seats", "Seat maps, holds (15-min expiry), releases, blocking, and inventory")
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production")
.build();

View File

@@ -0,0 +1,41 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { AuditService } from '../../common/audit.service';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Audit')
@Controller('audit')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
export class AuditController {
constructor(private auditService: AuditService) {}
@Get('logs')
@ApiOperation({
summary: 'Get audit logs',
description: 'Retrieve system audit logs with optional filtering',
})
@ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' })
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' })
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' })
async getLogs(
@Query('search') search?: string,
@Query('action') action?: string,
@Query('entityType') entityType?: string,
) {
const filters = {
search: search || undefined,
action: action || undefined,
entityType: entityType || undefined,
};
const items = await this.auditService.getLogs(filters);
return { items };
}
@Get('logs/:id')
@ApiOperation({ summary: 'Get audit log by ID' })
async getLog(@Param('id') id: string) {
return this.auditService.getLog(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AuditModule } from '../../common/audit.module';
import { AuditController } from './audit.controller';
@Module({
imports: [AuditModule, HttpModule],
controllers: [AuditController],
})
export class AuditModuleFeature {}

View File

@@ -14,6 +14,18 @@ export class PassengerInputDto {
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
}
export class RoundTripPassengerDto {
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
}
export class CreateBookingDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() scheduleId: string;
@@ -29,6 +41,29 @@ export class CreateBookingDto {
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class CreateRoundTripBookingDto {
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class ModifyBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AuditModule } from '../../common/audit.module';
import { BookingsController } from './bookings.controller';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
@@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -296,7 +296,7 @@ export class BookingsService {
}
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
@@ -363,8 +363,60 @@ export class BookingsService {
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
originStopSeq?: number,
destStopSeq?: number,
): Promise<number> {
const now = new Date();
// Get schedule with route info
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { route: true },
});
// Try segment fare rule first (most specific) if route info available
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
// Try with nationality first
const segmentFare = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: nationality || null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
});
if (segmentFare) {
return segmentFare.baseFareMinor;
}
// If no segment fare with nationality, try without nationality filter
if (nationality) {
const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
});
if (segmentFareAny) return segmentFareAny.baseFareMinor;
}
}
// Fall back to fare rules if no segment fare found
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,

View File

@@ -0,0 +1,50 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { CurrenciesService } from './currencies.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
@ApiTags('Currencies')
@Controller('currencies')
export class CurrenciesController {
constructor(private currenciesService: CurrenciesService) {}
@Get()
getAllCurrencies() {
return this.currenciesService.getAllCurrencies();
}
@Post()
@UseGuards(IamGuard)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth')
@HttpCode(201)
createCurrency(@Body() dto: CreateCurrencyDto) {
return this.currenciesService.createCurrency(dto);
}
@Patch(':id')
@UseGuards(IamGuard)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth')
updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) {
return this.currenciesService.updateCurrency(id, dto);
}
@Delete(':id')
@UseGuards(IamGuard)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth')
deleteCurrency(@Param('id') id: string) {
return this.currenciesService.deleteCurrency(id);
}
@Post('sync-rates')
@UseGuards(IamGuard)
@IamRoles('ADMIN')
@ApiBearerAuth('IAM-auth')
@HttpCode(200)
syncRates() {
return this.currenciesService.syncExchangeRates();
}
}

View File

@@ -0,0 +1,47 @@
import { IsString, IsNumber, IsOptional, Min } from 'class-validator';
export class CreateCurrencyDto {
@IsString()
code: string;
@IsString()
name: string;
@IsString()
symbol: string;
@IsString()
@IsOptional()
baseCurrencyCode?: string;
@IsNumber()
@Min(0.0001)
exchangeRate: number;
}
export class UpdateCurrencyDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
symbol?: string;
@IsNumber()
@IsOptional()
@Min(0.0001)
exchangeRate?: number;
}
export class CurrencyResponseDto {
id: string;
code: string;
name: string;
symbol: string;
baseCurrencyCode: string;
exchangeRate: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { CurrenciesController } from './currencies.controller';
import { CurrenciesService } from './currencies.service';
@Module({
imports: [HttpModule],
controllers: [CurrenciesController],
providers: [CurrenciesService],
exports: [CurrenciesService],
})
export class CurrenciesModule {}

View File

@@ -0,0 +1,131 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
@Injectable()
export class CurrenciesService {
constructor(private prisma: PrismaService) {}
async getAllCurrencies() {
const rates = await this.prisma.currencyExchangeRate.findMany({
distinct: ['toCurrency'],
orderBy: { toCurrency: 'asc' },
});
return rates.map(rate => ({
id: rate.id,
code: rate.toCurrency,
name: this.getCurrencyName(rate.toCurrency),
symbol: this.getCurrencySymbol(rate.toCurrency),
baseCurrencyCode: rate.fromCurrency,
exchangeRate: Number(rate.rate),
isActive: true,
createdAt: rate.createdAt,
updatedAt: rate.createdAt,
}));
}
async createCurrency(dto: CreateCurrencyDto) {
const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto;
if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) {
throw new BadRequestException('Unsupported currency code');
}
if (exchangeRate <= 0) {
throw new BadRequestException('Exchange rate must be positive');
}
const rate = await this.prisma.currencyExchangeRate.create({
data: {
fromCurrency: baseCurrencyCode as any,
toCurrency: code.toUpperCase() as any,
rate: exchangeRate,
source: 'MANUAL',
},
});
return {
id: rate.id,
code: rate.toCurrency,
name,
symbol,
baseCurrencyCode: rate.fromCurrency,
exchangeRate: Number(rate.rate),
isActive: true,
createdAt: rate.createdAt,
updatedAt: rate.createdAt,
};
}
async updateCurrency(id: string, dto: UpdateCurrencyDto) {
const existing = await this.prisma.currencyExchangeRate.findUnique({
where: { id },
});
if (!existing) {
throw new NotFoundException('Currency not found');
}
if (dto.exchangeRate !== undefined && dto.exchangeRate <= 0) {
throw new BadRequestException('Exchange rate must be positive');
}
const updated = await this.prisma.currencyExchangeRate.update({
where: { id },
data: {
rate: dto.exchangeRate,
},
});
return {
id: updated.id,
code: updated.toCurrency,
name: dto.name || this.getCurrencyName(updated.toCurrency),
symbol: dto.symbol || this.getCurrencySymbol(updated.toCurrency),
baseCurrencyCode: updated.fromCurrency,
exchangeRate: Number(updated.rate),
isActive: true,
createdAt: updated.createdAt,
updatedAt: updated.createdAt,
};
}
async deleteCurrency(id: string) {
const existing = await this.prisma.currencyExchangeRate.findUnique({
where: { id },
});
if (!existing) {
throw new NotFoundException('Currency not found');
}
await this.prisma.currencyExchangeRate.delete({
where: { id },
});
return { message: 'Currency deleted successfully' };
}
async syncExchangeRates() {
return { message: 'Exchange rates synced successfully', synced: 0 };
}
private getCurrencyName(code: string): string {
const names: Record<string, string> = {
ETB: 'Ethiopian Birr',
USD: 'US Dollar',
DJF: 'Djiboutian Franc',
};
return names[code] || code;
}
private getCurrencySymbol(code: string): string {
const symbols: Record<string, string> = {
ETB: 'Br',
USD: '$',
DJF: 'Fdj',
};
return symbols[code] || code;
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { Body, Controller, Post, Get, Query, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { FareEngineService } from './fare-engine.service';
@@ -16,23 +16,10 @@ export class FareEngineController {
@Post('calculate')
@ApiOperation({
summary: 'Calculate fare for a journey leg',
description: `Computes fare using the formula:
**Fare = totalKm × ratePerKm × exchangeRate**
- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination
- \`ratePerKm\`\`SeatClass.basePrice\` (stored in ETB minor units per km)
- \`exchangeRate\` — derived from passenger nationality:
- **Ethiopian** → ETB (rate = 1.0)
- **Djiboutian** → DJF (rate ≈ 3.25)
- **Other / unspecified** → USD (rate ≈ 0.018)
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
5% tax applied after promo discount.
Returns a full breakdown including a human-readable calculation trace.`,
description: `Computes fare using the formula:\n\n**Fare = totalKm × ratePerKm × exchangeRate**`,
})
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
@ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' })
@ApiResponse({ status: 400, description: 'Invalid route/station combination' })
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
calculate(@Body() dto: FareCalculateDto) {
return this.service.calculate(dto);
@@ -41,15 +28,14 @@ Returns a full breakdown including a human-readable calculation trace.`,
@Get('compare')
@ApiOperation({
summary: 'Compare fares across all seat classes for a route leg',
description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.',
})
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' })
@ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' })
@ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' })
@ApiQuery({ name: 'nationality', required: false })
@ApiQuery({ name: 'adultCount', required: false, type: Number })
@ApiQuery({ name: 'childCount', required: false, type: Number })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns' })
compareClasses(
@Query('routeId') routeId: string,
@Query('originStationId') originStationId: string,
@@ -67,6 +53,8 @@ Returns a full breakdown including a human-readable calculation trace.`,
childCount ? parseInt(childCount) : 0,
);
}
}
@ApiTags('Config')
@@ -77,18 +65,10 @@ export class ConfigController {
@Get('fayda-status')
@ApiOperation({
summary: 'Check Verifayda 2.0 configuration status',
description: 'Returns whether Verifayda integration is enabled and ready to use'
})
@ApiResponse({
status: 200,
description: 'Verifayda status retrieved successfully',
schema: {
example: {
enabled: true,
mode: 'production',
apiUrl: 'https://api.verifayda.gov.et/v2'
}
}
})
getFaydaStatus() {
const faydaConfig = this.configService.get<FaydaConfig>('fayda');

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { FareEngineController, ConfigController } from './fare-engine.controller';
import { FareEngineService } from './fare-engine.service';
import { CurrencyController } from './currency.controller';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [CurrencyModule],
imports: [HttpModule, CurrencyModule],
controllers: [FareEngineController, CurrencyController, ConfigController],
providers: [FareEngineService],
exports: [FareEngineService],

View File

@@ -47,14 +47,22 @@ export class FareEngineService {
const ratePerKmMinor = seatClass.baseFareMinor;
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
// Premium and insurance fees applied per passenger
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0;
const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, 1);
const paidChildrenCount = Math.max(0, childCount - 1);
const subtotalMinor =
baseFarePerPassengerMinor * adultCount +
baseFarePerPassengerMinor * paidChildrenCount;
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
// First child is free, but pays premium and insurance
const adultSubtotal = farePerPassengerMinor * adultCount;
const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount;
const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount;
const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
let discountMinor = 0;
let promoLabel = 'none';
@@ -85,12 +93,20 @@ export class FareEngineService {
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
`Passengers: ${adultCount} adult(s) × ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`,
`Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`,
`Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
``,
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
``,
`Subtotal: ${subtotalMinor} ETB minor`,
`Promo: ${promoLabel} → -${discountMinor} ETB minor`,
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
`Tax (5%): +${taxMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`,
``,
`Nationality: ${dto.nationality ?? 'unspecified'}${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
@@ -104,6 +120,9 @@ export class FareEngineService {
totalDistanceKm,
ratePerKmMinor,
baseFarePerPassengerMinor,
premiumPerPassenger,
insurancePerPassenger,
farePerPassengerMinor,
adultCount,
childCount,
freeChildrenCount,
@@ -142,7 +161,6 @@ export class FareEngineService {
return results.filter(Boolean);
}
/** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -160,7 +178,6 @@ export class FareEngineService {
});
}
/** Calculate fares for all active seat classes on a schedule. */
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -168,7 +185,6 @@ export class FareEngineService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
// ── Route-based calculation (fare engine) ────────────────────────────────
if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
@@ -190,7 +206,6 @@ export class FareEngineService {
return results.filter(Boolean);
}
// ── Fallback: FareRule records scoped to this schedule ───────────────────
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {

View File

@@ -158,7 +158,34 @@ export class FleetController {
@ApiOperation({ summary: 'List coaches with seat status summary' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
@ApiResponse({ status: 200, description: 'Array of coaches' })
@ApiResponse({
status: 200,
description: 'Array of coaches',
schema: {
example: [
{
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
coachType: {
id: 'coach-type-uuid',
code: 'sleeper',
name: 'Sleeper Coach'
},
arrangement: '2+2',
capacity: 60,
status: 'ACTIVE',
totalSeats: 60,
availableSeats: 45,
occupiedSeats: 15,
blockedSeats: 0,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
]
}
})
listCoaches(
@Query('status') status?: string,
@Query('scheduleId') scheduleId?: string,
@@ -173,7 +200,40 @@ export class FleetController {
@Get('coaches/:id')
@ApiOperation({ summary: 'Get single coach with seat layout' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
@ApiResponse({
status: 200,
description: 'Coach detail with seats by row',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
coachType: {
id: 'coach-type-uuid',
code: 'sleeper',
name: 'Sleeper Coach'
},
arrangement: '2+2',
capacity: 60,
status: 'ACTIVE',
seats: [
{
id: 'seat-uuid-1',
seatNumber: '1A',
status: 'AVAILABLE',
class: {
id: 'class-uuid',
name: 'Economy',
baseFareMinor: 5000
}
}
],
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) {
return this.service.getCoach(id);
@@ -182,7 +242,23 @@ export class FleetController {
@Post('coaches')
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created' })
@ApiResponse({
status: 201,
description: 'Coach created',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
arrangement: '2+2',
capacity: 60,
status: 'ACTIVE',
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) {
return this.service.createCoach(dto);
@@ -192,7 +268,23 @@ export class FleetController {
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({
status: 200,
description: 'Coach updated',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
number: 'A-001',
sequence: 1,
coachTypeId: 'coach-type-uuid',
arrangement: '2+2',
capacity: 60,
status: 'ACTIVE',
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
return this.service.updateCoach(id, dto);
@@ -201,7 +293,7 @@ export class FleetController {
@Delete('coaches/:id')
@ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach deleted' })
@ApiResponse({ status: 200, description: 'Coach deleted successfully' })
@ApiResponse({ status: 404, description: 'Coach not found' })
deleteCoach(@Param('id') id: string) {
return this.service.deleteCoach(id);

View File

@@ -17,7 +17,10 @@ export class CreateCoachDto {
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
@ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering' })
@IsOptional() @IsInt() sequence?: number;
}
export class AssignCoachDto {
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;

View File

@@ -48,19 +48,16 @@ function buildSeats(coachId: string, coachNumber: string, arrangement: string, c
const col = cols[ci];
let bedPosition = null;
// Set bedPosition for bed coaches based on seat number cycling
// Set bedPosition for bed coaches based on ROW cycling (not seat number)
if (isBedCoach) {
if (totalCols === 3) {
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
const posMod = ((seatNumber - 1) % 3);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'middle';
else if (posMod === 2) bedPosition = 'upper';
// Economy bed (3-row cycle): upper, middle, lower
if (row % 3 === 1) bedPosition = 'upper';
else if (row % 3 === 2) bedPosition = 'middle';
else bedPosition = 'lower';
} else if (totalCols === 2) {
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
const posMod = ((seatNumber - 1) % 2);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'upper';
// VIP bed (2-row cycle): upper, lower
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
}
}
@@ -253,7 +250,7 @@ export class FleetService {
return this.prisma.coach.findMany({
where,
include: { coachType: true },
orderBy: { number: 'asc' },
orderBy: { sequence: 'asc' },
});
}
@@ -263,10 +260,18 @@ export class FleetService {
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
}
// Get the next sequence number for this coach type
const lastCoach = await this.prisma.coach.findFirst({
where: { coachTypeId: dto.coachTypeId },
orderBy: { sequence: 'desc' },
});
const nextSequence = (lastCoach?.sequence ?? 0) + 1;
const coach = await this.prisma.coach.create({
data: {
coachTypeId: dto.coachTypeId,
number: dto.number,
sequence: nextSequence,
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status || 'ACTIVE',
@@ -293,6 +298,7 @@ export class FleetService {
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status,
sequence: dto.sequence,
},
include: { coachType: true },
});
@@ -302,32 +308,9 @@ export class FleetService {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
// Get all seat IDs for this coach
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
const seatIds = seats.map(s => s.id);
// Delete in order of foreign key dependencies
if (seatIds.length > 0) {
// 1. Delete seat blocks (references seats)
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
// 2. Delete ticket seats (references seats)
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 3. Delete booking seats (references seats)
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 4. Delete journey segments with these seats
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
}
// 5. Delete all associated seats
// Delete related seats first
await this.prisma.seat.deleteMany({ where: { coachId: id } });
// 6. Delete coach assignments
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
// 7. Finally delete the coach
return this.prisma.coach.delete({ where: { id } });
}

View File

@@ -0,0 +1,8 @@
export class SendEmail {
to: string;
subject: string;
body: string;
html?: string;
templateKey?: string;
context?: Record<string, unknown>;
}

View File

@@ -0,0 +1,9 @@
export class SendMessage {
to: string;
message: string;
from?: string;
}
export class BulkMessagesDto {
messages: SendMessage[];
}

View File

@@ -0,0 +1,28 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { SendEmail } from './dtos/email.dto';
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name);
constructor(
@Inject('EMAIL_SERVICE')
private readonly emailServiceClient: ClientProxy,
) {}
async onApplicationBootstrap() {
this.emailServiceClient
.connect()
.then(() => this.logger.log('Connected to Email service'))
.catch((err) => this.logger.error('Error connecting to Email service', err));
}
async sendEmail(dto: SendEmail) {
this.emailServiceClient.emit('send-email', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
return {};
}
}

View File

@@ -1,16 +1,24 @@
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
import { SendMessage } from './dtos/sms.dto';
@ApiTags('Notifications')
@Controller('notifications')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class NotificationsController {
constructor(private service: NotificationsService) {}
constructor(
private service: NotificationsService,
private emailClient: EmailClientService,
private smsClient: SmsClientService,
) {}
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
@@ -30,6 +38,24 @@ export class NotificationsController {
return this.service.markAllRead(id);
}
@Post('send/email')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct email via the email microservice' })
@ApiBody({ type: SendEmail })
sendEmail(@Body() dto: SendEmail) {
return this.emailClient.sendEmail(dto);
}
@Post('send/sms')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
@ApiBody({ type: SendMessage })
sendSms(@Body() dto: SendMessage) {
return this.smsClient.sendSms(dto);
}
@Post('test')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')

View File

@@ -1,13 +1,56 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
@Module({
imports: [HttpModule.register({ timeout: 10_000 })],
imports: [
HttpModule.register({ timeout: 10_000 }),
ClientsModule.registerAsync([
{
name: 'EMAIL_SERVICE',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.RMQ,
options: {
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
queueOptions: { durable: true },
noAck: true,
},
}),
},
{
name: 'SMS_SERVICE',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.RMQ,
options: {
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
queueOptions: { durable: true },
noAck: true,
},
}),
},
]),
],
controllers: [NotificationsController],
providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter],
exports: [NotificationsService],
providers: [
NotificationsService,
EmailAdapter,
SmsAdapter,
PushAdapter,
EmailClientService,
SmsClientService,
],
exports: [NotificationsService, EmailClientService, SmsClientService],
})
export class NotificationsModule {}

View File

@@ -1,8 +1,10 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
import { PushAdapter, NotificationChannel } from './notification.adapters';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
@@ -13,13 +15,13 @@ export class NotificationsService {
constructor(
private prisma: PrismaService,
private emailAdapter: EmailAdapter,
private smsAdapter: SmsAdapter,
private emailClient: EmailClientService,
private smsClient: SmsClientService,
private pushAdapter: PushAdapter,
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', this.emailAdapter as NotificationChannel],
['SMS', this.smsAdapter as NotificationChannel],
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}
@@ -102,11 +104,11 @@ export class NotificationsService {
});
if (passenger?.user) {
await this.emailAdapter.send(
passenger.user.email,
this.sanitize(dto.title),
this.sanitize(dto.body),
);
await this.emailClient.sendEmail({
to: passenger.user.email,
subject: this.sanitize(dto.title),
body: this.sanitize(dto.body),
});
}
return notification;

View File

@@ -0,0 +1,36 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
@Inject('SMS_SERVICE')
private readonly smsClient: ClientProxy,
) {}
async onApplicationBootstrap() {
this.smsClient
.connect()
.then(() => this.logger.log('Connected to SMS service'))
.catch((err) => this.logger.error('Error connecting to SMS service', err));
}
async sendSms(dto: SendMessage) {
this.smsClient.emit('send-sms', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
return {};
}
async sendBulkMessages(dto: BulkMessagesDto) {
this.smsClient.emit('ozeking-bulk-sms', {
...dto,
appKey: 'EDR-PASSENGER-API',
});
return {};
}
}

View File

@@ -47,17 +47,9 @@ export class PassengersService {
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
fullName: true,
email: true,
phone: true,
nationalId: true,
nationality: true,
},
},
user: true,
loyalty: true,
wallet: true,
_count: {
select: {
bookings: true,
@@ -69,19 +61,30 @@ export class PassengersService {
]);
return {
items: items.map(passenger => ({
id: passenger.id,
fullName: passenger.user.fullName,
email: passenger.user.email,
phone: passenger.user.phone,
nationalId: passenger.user.nationalId,
nationality: passenger.user.nationality,
verified: !!passenger.user.nationalId,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt,
})),
items: items.map(passenger => {
const user = passenger.user as any;
return {
id: passenger.id,
userId: passenger.userId,
fullName: user.fullName,
email: user.email,
phone: user.phone,
nationalId: user.nationalId,
nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null,
gender: user.gender ?? null,
passportNumber: user.passportNumber,
passportCountry: user.passportCountry ?? null,
verified: !!user.nationalId,
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
totalBookings: passenger._count.bookings,
createdAt: passenger.createdAt,
updatedAt: user.updatedAt,
loyalty: passenger.loyalty,
wallet: passenger.wallet,
};
}),
meta: {
page,
pageSize,
@@ -95,9 +98,19 @@ export class PassengersService {
const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId },
include: {
user: { select: { fullName: true, email: true, phone: true } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
user: true,
bookings: {
orderBy: { createdAt: 'desc' },
take: 10,
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }
}
},
loyalty: true,
wallet: true,
travelerProfiles: true,
savedRoutes: true,
},
});
if (!passenger) throw new NotFoundException('Passenger not found');
@@ -108,14 +121,35 @@ export class PassengersService {
phone: passenger.user.phone,
createdAt: passenger.createdAt,
bookings: passenger.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalFare: b.totalMinor / 100,
createdAt: b.createdAt,
trip: {
number: b.schedule.train.number,
origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city },
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
origin: {
id: b.schedule.originStation.id,
name: b.schedule.originStation.name,
code: b.schedule.originStation.code,
city: b.schedule.originStation.city
},
destination: {
id: b.schedule.destinationStation.id,
name: b.schedule.destinationStation.name,
code: b.schedule.destinationStation.code,
city: b.schedule.destinationStation.city
},
departureAt: b.schedule.departureAt,
},
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
passengers: b.seats.map((bs) => ({
fullName: bs.passengerName,
seat: {
number: bs.seat.seatNumber,
coach: bs.seat.coach.number,
class: 'N/A'
}
})),
})),
};
}
@@ -171,14 +205,25 @@ export class PassengersService {
}
createTravelerProfile(dto: CreateTravelerProfileDto) {
return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } });
return this.prisma.travelerProfile.create({
data: {
...dto,
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
}
});
}
getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); }
getTravelerProfiles(passengerId: string) {
return this.prisma.travelerProfile.findMany({ where: { passengerId } });
}
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
createSavedRoute(dto: CreateSavedRouteDto) {
return this.prisma.savedRoute.create({ data: dto });
}
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
getSavedRoutes(passengerId: string) {
return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } });
}
async updatePassenger(id: string, dto: any) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
@@ -196,7 +241,7 @@ export class PassengersService {
},
},
include: {
user: { select: { fullName: true, email: true, phone: true, nationality: true } },
user: true,
loyalty: true,
},
});
@@ -290,9 +335,7 @@ export class PassengersService {
async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) throw new NotFoundException('Passenger not found');
await this.prisma.passenger.delete({ where: { id } });
return { deleted: true, passengerId: id };
return this.prisma.passenger.delete({ where: { id } });
}
async checkPassengerUsage(id: string) {

View File

@@ -42,13 +42,6 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
/**
* DEMO ONLY: when true, a WALLET "payment" is treated as instantly successful — the wallet
* balance check and debit are skipped and the booking is confirmed + ticket issued as if fully
* paid. Lets the happy-path be demoed while a real provider (e.g. Telebirr) is unavailable.
* Never enable in production. Toggle with WALLET_DEMO_AUTO_SUCCEED in the env.
*/
private readonly walletDemoAutoSucceed = true;
constructor(
@@ -136,9 +129,7 @@ export class PaymentsService {
return this.initiateWalletPayment(booking);
}
// Provider methods go through the payment microservice (docs/payment-service §7.1):
// it owns the intent, the provider session, and the single webhook per provider.
// Re-initiating is safe — the service returns the existing active intent (idempotent).
const { returnUrl, failureUrl } = this.resolveReturnUrls(method);
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.BOOKING,
@@ -148,10 +139,8 @@ export class PaymentsService {
currency: booking.currency,
provider: method as unknown as ProviderMethod,
platform: dto.platform,
// PASSENGER-owned browser bounce-back after the hosted page (freight passes its own).
// UX only — payment is confirmed by the webhook/mark-paid event, never this redirect.
returnUrl: process.env.PAYMENT_RETURN_URL || undefined,
failureUrl: process.env.PAYMENT_FAILURE_URL || undefined,
returnUrl,
failureUrl,
});
let intent = await this.syncIntentProjection(booking.id, snapshot);
@@ -168,6 +157,40 @@ export class PaymentsService {
}
return this.formatIntentResponse(intent);
}
private resolveReturnUrls(method: PaymentMethodType): {
returnUrl?: string;
failureUrl?: string;
} {
const perMethod: Partial<
Record<PaymentMethodType, { returnUrl?: string; failureUrl?: string }>
> = {
[PaymentMethodType.TELEBIRR]: {
returnUrl: process.env.TELEBIRR_RETURN_URL,
},
[PaymentMethodType.WAAFI]: {
returnUrl: process.env.WAAFI_SUCCESS_REDIRECT,
failureUrl: process.env.WAAFI_FAIL_REDIRECT,
},
[PaymentMethodType.DMONEY]: {
returnUrl: process.env.DMONEY_RETURN_URL,
},
[PaymentMethodType.CBE_BIRR]: {
returnUrl: process.env.CBE_RETURN_URL,
},
[PaymentMethodType.EBIRR]: {
returnUrl: process.env.EBIRR_RETURN_URL,
},
[PaymentMethodType.CARD]: {
returnUrl: process.env.CARD_RETURN_URL,
},
};
const m = perMethod[method] ?? {};
const returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
const failureUrl =
m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;
return { returnUrl, failureUrl };
}
private async syncIntentProjection(
bookingId: string,

View File

@@ -8,7 +8,10 @@ export class ReportsService {
async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom);
dateFrom.setHours(0, 0, 0, 0);
const dateTo = new Date(dto.dateTo);
dateTo.setHours(23, 59, 59, 999);
let data: any;
switch (dto.reportType) {
@@ -44,14 +47,16 @@ export class ReportsService {
}
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
// Fetch all bookings in date range, regardless of status
const bookings = await this.prisma.booking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
status: { in: ['CONFIRMED', 'COMPLETED'] }
createdAt: { gte: dateFrom, lte: dateTo }
},
include: { paymentIntent: true }
});
console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`);
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
const byPaymentMethod = bookings.reduce((acc, b) => {
const method = b.paymentIntent?.method ?? 'UNKNOWN';
@@ -59,12 +64,25 @@ export class ReportsService {
return acc;
}, {} as Record<string, number>);
// Group by date for charts
const byDate = bookings.reduce((acc, b) => {
const date = b.createdAt.toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
return {
totalBookings: bookings.length,
totalRevenueMinor: totalRevenue,
totalRevenue: totalRevenue / 100,
currency: 'ETB',
byPaymentMethod
byPaymentMethod,
byDate,
cancellationRate: 0
};
}
@@ -73,7 +91,7 @@ export class ReportsService {
where: { departureAt: { gte: dateFrom, lte: dateTo } },
include: {
coachAssignments: { include: { coach: { include: { seats: true } } } },
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } },
bookings: { include: { seats: true } },
},
});

View File

@@ -142,6 +142,14 @@ export class SchedulesController {
@Body() dto: UpdateStopTimeDto,
) { return this.service.updateStop(id, sequence, dto); }
@Get(':scheduleId/fares/stored')
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' })
getStoredFares(@Param('scheduleId') scheduleId: string) {
return this.service.getFareRules(scheduleId);
}
@Get(':scheduleId/fares')
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })

View File

@@ -1,7 +1,7 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { TripStatus, StopStatus } from '@prisma/client';
import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client';
export class PlannedStopTimeDto {
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;
@@ -51,6 +51,7 @@ export class CreateFareRuleDto {
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string;
@ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI for full route or ADD-ADM for segment)' }) @IsOptional() @IsString() route?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Scope fare rule to nationality: Ethiopian, Djiboutian, Other' }) @IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory;
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@@ -64,6 +65,7 @@ export class CreateSegmentFareRuleDto {
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
}

View File

@@ -329,50 +329,6 @@ export class SchedulesService {
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: id },
select: { id: true },
});
const bookingIds = bookings.map(b => b.id);
if (bookingIds.length > 0) {
const paymentIntents = await this.prisma.paymentIntent.findMany({
where: { bookingId: { in: bookingIds } },
select: { id: true },
});
const paymentIntentIds = paymentIntents.map(pi => pi.id);
if (paymentIntentIds.length > 0) {
await this.prisma.paymentRefund.deleteMany({
where: { paymentIntentId: { in: paymentIntentIds } },
});
}
await this.prisma.ticket.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingSeat.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingModification.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingCancellation.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.paymentIntent.deleteMany({
where: { bookingId: { in: bookingIds } },
});
}
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
return this.prisma.trainSchedule.delete({ where: { id } });
}
@@ -402,7 +358,7 @@ export class SchedulesService {
}
createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
return this.prisma.fareRule.create({
data: {
...rest,
@@ -415,7 +371,7 @@ export class SchedulesService {
}
createSegmentFareRule(dto: any) {
const { validFrom, validUntil, ...rest } = dto;
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
return this.prisma.segmentFareRule.create({
data: {
...rest,
@@ -439,7 +395,7 @@ export class SchedulesService {
}
updateSegmentFareRule(id: string, dto: any) {
const { validFrom, validUntil, ...rest } = dto;
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
return this.prisma.segmentFareRule.update({
where: { id },
data: {
@@ -451,12 +407,36 @@ export class SchedulesService {
});
}
async getFareRules(scheduleId?: string) {
const where: any = {};
if (scheduleId) where.tripId = scheduleId;
return this.prisma.fareRule.findMany({
where,
include: { seatClass: true },
orderBy: { createdAt: 'desc' },
});
}
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
}
getAllFaresFromEngine(scheduleId: string, nationality?: string) {
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
async getAllFaresFromEngine(scheduleId: string, nationality?: string) {
try {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
} catch (error) {
throw new BadRequestException(
error instanceof Error ? error.message : 'Failed to calculate fares for schedule'
);
}
}
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {

View File

@@ -11,17 +11,24 @@ export class SearchController {
@Post()
@ApiOperation({
summary: 'Search trips by origin, destination, date, passengers, and nationality',
description: `Finds all train schedules matching search criteria with real-time seat availability.
description: `Finds all train schedules matching search criteria with real-time seat availability and coach type options.
**Coach Type Selection Flow:**
- Users browse available coach types (Economy, VIP, etc.)
- Each coach type displays available seat classes and base fares
- Users select a coach type to proceed to seat selection
- At seat selection, users choose specific seat and class (actual price confirmed here)
- Final fare may adjust based on seat position/amenities selected
**Features:**
- Any origin→destination stop pair (not just terminals)
- Age-based passenger counts (adults ≥5 years, children <5 years)
- Nationality filtering (Ethiopian, Djiboutian, Other)
- Real-time seat availability per class
- Multi-currency fare display
- Example: Train A→B→C→D appears in results for A→B, A→C, A→D, B→C, B→D, C→D
- Availability: Segment-based (seat booked A→B is still available B→D)`
- Segment-based availability (seat booked A→B still available B→D)`
})
@ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' })
@ApiResponse({ status: 200, description: 'Matching schedules with coachTypes array showing available coach types with seat classes and base fares' })
searchTrips(@Body() dto: SearchTripsDto) {
return this.service.searchTrips(dto);
}

View File

@@ -21,6 +21,12 @@ export class SearchTripsDto {
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' })
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], description: 'Journey type: ONE_WAY or ROUND_TRIP' })
@IsOptional() @IsEnum(['ONE_WAY', 'ROUND_TRIP']) journeyType?: string;
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
@IsOptional() @IsDateString() returnDate?: string;
}
export class FareQuoteDto {
@@ -53,4 +59,39 @@ export class FareQuoteDto {
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' })
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Return schedule UUID (required for ROUND_TRIP journeys)' })
@IsOptional() @IsString() returnScheduleId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return origin station ID (required for ROUND_TRIP)' })
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return destination station ID (required for ROUND_TRIP)' })
@IsOptional() @IsString() returnDestinationStationId?: string;
}
export class CoachTypeOptionClass {
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
name: string;
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
baseFareMinor: number;
}
export class CoachTypeOption {
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
coachTypeId: string;
@ApiProperty({ example: 'Economy', description: 'Coach type display name' })
coachTypeName: string;
@ApiProperty({ example: 'ECO', description: 'Coach type code' })
coachTypeCode: string;
@ApiProperty({
type: 'array',
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
})
classes: CoachTypeOptionClass[];
}

View File

@@ -18,15 +18,57 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
const date = new Date(dto.date);
const nextDay = new Date(date.getTime() + 86_400_000);
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
const outbound = await this.searchSchedules(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
if (dto.journeyType === 'ROUND_TRIP') {
const allInbound = await this.searchSchedules(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
const latestOutboundArrival = outbound.length > 0
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
: Date.now();
const inbound = allInbound.filter((schedule) =>
new Date(schedule.departureAt).getTime() > latestOutboundArrival
);
return { journeyType: 'ROUND_TRIP', outbound, inbound };
}
return { journeyType: 'ONE_WAY', outbound };
}
private async searchSchedules(
originStationId: string,
destinationStationId: string,
dateStr: string,
adultCount: number,
childCount?: number,
nationality?: string,
) {
const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const totalPassengers = adultCount + (childCount ?? 0);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: dto.originStationId } },
stopTimes: { some: { stationId: originStationId } },
},
include: {
train: true,
@@ -42,8 +84,8 @@ export class SearchService {
const results = [];
for (const schedule of schedules) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
@@ -61,14 +103,14 @@ export class SearchService {
if (seat.bedPosition !== bedPosition) continue;
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) count++;
}
if (count > 0) {
const matchingClass = seatClassNames.find((className: string) => {
const classNameLower = className.toLowerCase();
@@ -89,14 +131,14 @@ export class SearchService {
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availableSeatsInCoach++;
}
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
availabilityByClass[seatClassName] += availableSeatsInCoach;
@@ -109,11 +151,13 @@ export class SearchService {
const faresByClass = await this.calculateFaresForSegment(
schedule,
dto.originStationId,
dto.destinationStationId,
dto.nationality,
originStationId,
destinationStationId,
nationality,
);
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
results.push({
scheduleId: schedule.id,
trainNumber: schedule.train.number,
@@ -150,6 +194,7 @@ export class SearchService {
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
faresByClass,
coachTypes,
});
}
@@ -258,14 +303,14 @@ export class SearchService {
.filter((id: any) => id)
)
);
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
}
const seatClasses = await this.prisma.seatClass.findMany({
where: {
where: {
isActive: true,
id: { in: seatClassIds }
},
@@ -307,7 +352,7 @@ export class SearchService {
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
if (originStation && destStation) {
const segmentRoute = `${originStation.code}-${destStation.code}`;
const now = new Date();
@@ -341,6 +386,62 @@ export class SearchService {
}));
}
private async buildCoachTypeDetails(
schedule: any,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
): Promise<Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
classes: Array<{ name: string; baseFareMinor: number }>;
}>> {
const coachTypeMap = new Map<
string,
{ coachType: any; classNames: Set<string> }
>();
for (const assignment of schedule.coachAssignments) {
const coachType = assignment.coach.coachType;
if (!coachType) continue;
if (!coachTypeMap.has(coachType.id)) {
coachTypeMap.set(coachType.id, {
coachType,
classNames: new Set(),
});
}
const entry = coachTypeMap.get(coachType.id)!;
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
}
const result = [];
for (const [, { coachType, classNames }] of coachTypeMap) {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
return {
name: className,
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
};
})
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({
coachTypeId: coachType.id,
coachTypeName: coachType.name,
coachTypeCode: coachType.code,
classes,
});
}
return result.sort((a, b) => {
const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor));
const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor));
return minPriceA - minPriceB;
});
}
private getDefaultFareForClass(className: string): number {
const defaults: Record<string, number> = {
'Economy Regular': 35000,

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
import { JwtGuard } from '../../common/jwt.guard';
@@ -17,6 +17,28 @@ export class StationsController {
@ApiQuery({ name: 'search', required: false, description: 'Search by station name or code' })
@ApiQuery({ name: 'country', required: false, description: 'Filter by country code (ET, DJ)' })
@ApiQuery({ name: 'operational', required: false, description: 'Filter by operational status (true, false)' })
@ApiResponse({
status: 200,
description: 'Array of stations',
schema: {
example: [
{
id: '550e8400-e29b-41d4-a716-446655440000',
code: 'AAA',
sequence: 1,
name: 'Addis Ababa',
city: 'Addis Ababa',
countryCode: 'ET',
lat: 9.0054,
lng: 38.7636,
timezone: 'Africa/Addis_Ababa',
isOperational: true,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
]
}
})
findAll(
@Query('search') search?: string,
@Query('country') country?: string,
@@ -30,18 +52,79 @@ export class StationsController {
summary: 'Get station details by ID',
description: 'Returns station information including name, code, country, coordinates, and facilities'
})
@ApiResponse({
status: 200,
description: 'Station details',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
code: 'AAA',
sequence: 1,
name: 'Addis Ababa',
city: 'Addis Ababa',
countryCode: 'ET',
lat: 9.0054,
lng: 38.7636,
timezone: 'Africa/Addis_Ababa',
isOperational: true,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create new station' })
@ApiResponse({
status: 201,
description: 'Station created',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
code: 'AAA',
sequence: 1,
name: 'Addis Ababa',
city: 'Addis Ababa',
countryCode: 'ET',
lat: 9.0054,
lng: 38.7636,
timezone: 'Africa/Addis_Ababa',
isOperational: true,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update station' })
@ApiResponse({
status: 200,
description: 'Station updated',
schema: {
example: {
id: '550e8400-e29b-41d4-a716-446655440000',
code: 'AAA',
sequence: 1,
name: 'Addis Ababa',
city: 'Addis Ababa',
countryCode: 'ET',
lat: 9.0054,
lng: 38.7636,
timezone: 'Africa/Addis_Ababa',
isOperational: true,
createdAt: '2024-01-15T10:30:00.000Z',
updatedAt: '2024-01-15T10:30:00.000Z'
}
}
})
@ApiResponse({ status: 404, description: 'Station not found' })
update(@Param('id') id: string, @Body() dto: Partial<CreateStationDto>) {
return this.service.update(id, dto);
}
@@ -50,6 +133,8 @@ export class StationsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete station' })
@ApiResponse({ status: 200, description: 'Station deleted successfully' })
@ApiResponse({ status: 404, description: 'Station not found' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsNumber, IsOptional } from 'class-validator';
import { IsString, IsNumber, IsOptional, IsInt, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateStationDto {
@@ -6,6 +6,9 @@ export class CreateStationDto {
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
@ApiProperty({ example: 9.0054 }) @IsNumber() lat: number;
@ApiProperty({ example: 38.7636 }) @IsNumber() lng: number;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isOperational?: boolean;
}

View File

@@ -1,6 +1,12 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../../common/audit.module';
import { StationsController } from './stations.controller';
import { StationsService } from './stations.service';
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
@Module({
imports: [AuditModule],
controllers: [StationsController],
providers: [StationsService],
exports: [StationsService],
})
export class StationsModule {}

View File

@@ -1,5 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, Inject, Optional, BadRequestException } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { CreateStationDto } from './stations.dto';
interface StationFilters {
@@ -10,7 +12,11 @@ interface StationFilters {
@Injectable()
export class StationsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private auditService: AuditService,
@Optional() @Inject(REQUEST) private request?: any,
) {}
findAll(filters: StationFilters = {}) {
const where: any = {};
@@ -33,7 +39,7 @@ export class StationsService {
return this.prisma.station.findMany({
where,
orderBy: { name: 'asc' }
orderBy: { sequence: 'asc' }
});
}
@@ -43,20 +49,57 @@ export class StationsService {
return s;
}
create(dto: CreateStationDto) {
return this.prisma.station.create({ data: dto });
async create(dto: CreateStationDto) {
const station = await this.prisma.station.create({ data: dto });
await this.auditService.log({
userId: this.request?.user?.id,
action: 'CREATE',
entityType: 'Station',
entityId: station.id,
newData: station,
});
return station;
}
async update(id: string, dto: Partial<CreateStationDto>) {
await this.findOne(id); // Check if exists
return this.prisma.station.update({
where: { id },
data: dto
const oldStation = await this.findOne(id);
const { code, name, city, timezone, lat, lng } = dto;
const data: any = { code, name, city, timezone, lat, lng };
if ('countryCode' in dto) data.countryCode = (dto as any).countryCode;
if ('sequence' in dto) data.sequence = (dto as any).sequence;
if ('isOperational' in dto) data.isOperational = (dto as any).isOperational;
const updatedStation = await this.prisma.station.update({
where: { id },
data,
});
await this.auditService.log({
userId: this.request?.user?.id,
action: 'UPDATE',
entityType: 'Station',
entityId: id,
oldData: oldStation,
newData: updatedStation,
});
return updatedStation;
}
async remove(id: string) {
await this.findOne(id); // Check if exists
return this.prisma.station.delete({ where: { id } });
const station = await this.findOne(id);
const deleted = await this.prisma.station.delete({ where: { id } });
await this.auditService.log({
userId: this.request?.user?.id,
action: 'DELETE',
entityType: 'Station',
entityId: id,
oldData: station,
});
return deleted;
}
}

View File

@@ -44,6 +44,17 @@ export class TicketsController {
});
}
@Get('by-order/:merchantOrderId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get ticket by merchant order ID',
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
})
getByMerchantOrderId(@Param('merchantOrderId') merchantOrderId: string) {
return this.service.getByMerchantOrderId(merchantOrderId);
}
@Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -49,12 +49,16 @@ export class TicketsService {
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
displayTotalMinor: t.booking.displayTotalMinor,
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
contactEmail: t.booking.contactEmail,
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.booking.status,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
})),
@@ -157,9 +161,14 @@ export class TicketsService {
return { success: true, updatedSeats: newSeatIds.length };
}
async getByRef(bookingRef: string) {
async getByMerchantOrderId(merchantOrderId: string) {
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
select: { bookingId: true },
});
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
where: { id: intent.bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
@@ -170,6 +179,36 @@ export class TicketsService {
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload,
};
}
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
ticket: true
},
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
return {
id: booking.ticket.id,
bookingId: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
fromStationName: booking.schedule.originStation.name,
toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt,
trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.number,
seatLabel: seat?.seat.seatNumber,
passengerName: seat?.passengerName,
priceMinor: booking.totalMinor,
currency: booking.currency,
qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
};
}

View File

@@ -1,3 +1,6 @@
{
"extends": ["next/core-web-vitals"]
"extends": ["next/core-web-vitals"],
"rules": {
"react/no-unescaped-entities": "off"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,27 +2,90 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Eye } from 'lucide-react';
import { Eye, Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { auditApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
export default function AuditLogsPage() {
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
const [selectedLog, setSelectedLog] = useState<any>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['audit-logs', filters],
queryFn: () => auditApi.getLogs(filters),
refetchInterval: 30000, // Refetch every 30 seconds
});
const getActionBadgeColor = (action: string) => {
switch (action) {
case 'CREATE':
return 'success';
case 'UPDATE':
return 'primary';
case 'DELETE':
return 'danger';
case 'LOGIN':
return 'info';
case 'LOGOUT':
return 'secondary';
default:
return 'secondary';
}
};
const formatJsonData = (data: any) => {
if (!data) return 'N/A';
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
};
const columns = [
{
key: 'createdAt',
label: 'Timestamp',
sortable: true,
render: (log: any) => (
<div className="text-sm">
<div className="font-medium">{formatDateTime(log.createdAt)}</div>
<div className="text-xs text-muted-foreground">{new Date(log.createdAt).toLocaleTimeString()}</div>
</div>
),
},
{
key: 'action',
label: 'Action',
sortable: true,
render: (log: any) => (
<Badge>{log.action}</Badge>
<Badge className={getActionBadgeColor(log.action)}>
{log.action}
</Badge>
),
},
{
key: 'entityType',
label: 'Entity Type',
sortable: true,
render: (log: any) => (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
{log.entityType}
</span>
),
},
{
key: 'entityId',
label: 'Entity ID',
render: (log: any) => (
<span className="font-mono text-xs text-muted-foreground">
{log.entityId ? log.entityId.substring(0, 12) : 'System'}
</span>
),
},
{
@@ -30,55 +93,74 @@ export default function AuditLogsPage() {
label: 'User',
render: (log: any) => (
<div>
<div className="font-medium">{log.user?.fullName || 'System'}</div>
<div className="text-sm text-muted-foreground">{log.user?.email || 'N/A'}</div>
<div className="font-medium text-sm">{log.user?.fullName || 'System'}</div>
<div className="text-xs text-muted-foreground">{log.user?.email || log.userId || 'N/A'}</div>
</div>
),
},
{
key: 'entityType',
label: 'Entity Type',
render: (log: any) => log.entityType,
},
{
key: 'entityId',
label: 'Entity ID',
key: 'ipAddress',
label: 'IP Address',
render: (log: any) => (
<span className="font-mono text-sm">{log.entityId?.substring(0, 8)}...</span>
<span className="text-xs text-muted-foreground font-mono">
{log.ipAddress || 'N/A'}
</span>
),
},
{
key: 'createdAt',
label: 'Timestamp',
sortable: true,
render: (log: any) => formatDateTime(log.createdAt),
},
];
const actions = [
{
label: 'View Details',
onClick: (log: any) => {
window.location.href = `/audit/${log.id}`;
setSelectedLog(log);
setShowDetailsModal(true);
},
variant: 'secondary' as const,
icon: Eye,
},
];
const logs = data?.items || [];
const stats = {
total: logs.length,
creates: logs.filter((l: any) => l.action === 'CREATE').length,
updates: logs.filter((l: any) => l.action === 'UPDATE').length,
deletes: logs.filter((l: any) => l.action === 'DELETE').length,
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground">Track all system activities and changes</p>
<div>
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="text-muted-foreground text-sm font-medium">Total Logs</div>
<div className="text-2xl font-bold mt-2">{stats.total}</div>
</div>
<div className="card">
<div className="text-green-600 text-sm font-medium">Created</div>
<div className="text-2xl font-bold mt-2">{stats.creates}</div>
</div>
<div className="card">
<div className="text-blue-600 text-sm font-medium">Updated</div>
<div className="text-2xl font-bold mt-2">{stats.updates}</div>
</div>
<div className="card">
<div className="text-red-600 text-sm font-medium">Deleted</div>
<div className="text-2xl font-bold mt-2">{stats.deletes}</div>
</div>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="label">Search</label>
<label className="label">Search (User/Entity ID)</label>
<input
type="text"
placeholder="Search logs..."
@@ -110,22 +192,172 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
>
<option value="">All Types</option>
<option value="Booking">Booking</option>
<option value="User">User</option>
<option value="Payment">Payment</option>
<option value="Ticket">Ticket</option>
<optgroup label="Master Data">
<option value="Station">Station</option>
<option value="Route">Route</option>
<option value="RouteStop">Route Stop</option>
<option value="Train">Train</option>
<option value="TrainSchedule">Train Schedule</option>
<option value="Coach">Coach</option>
<option value="CoachType">Coach Type</option>
<option value="SeatClass">Seat Class</option>
<option value="FareRule">Fare Rule</option>
<option value="RouteFareRule">Route Fare Rule</option>
<option value="SegmentFareRule">Segment Fare Rule</option>
<option value="BaggageAllowance">Baggage Allowance</option>
</optgroup>
<optgroup label="Operations">
<option value="Booking">Booking</option>
<option value="Payment">Payment</option>
<option value="Ticket">Ticket</option>
<option value="Seat">Seat</option>
<option value="SeatBlock">Seat Block</option>
</optgroup>
<optgroup label="Users & Access">
<option value="User">User</option>
<option value="Agent">Agent</option>
<option value="Passenger">Passenger</option>
</optgroup>
<optgroup label="System & Features">
<option value="Notification">Notification</option>
<option value="Promotion">Promotion</option>
<option value="Loyalty">Loyalty</option>
<option value="Wallet">Wallet</option>
<option value="FraudAlert">Fraud Alert</option>
<option value="FraudRule">Fraud Rule</option>
</optgroup>
</select>
</div>
<div className="flex items-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', action: '', entityType: '' })}
className="w-full"
>
Clear Filters
</ActionButton>
</div>
</div>
</div>
{/* Data Table */}
<DataTable
data={data?.items || []}
data={logs}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No audit logs found"
/>
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
size="lg"
>
<div className="space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Action</label>
<p className="text-sm mt-1">
<Badge className={getActionBadgeColor(selectedLog?.action)}>
{selectedLog?.action}
</Badge>
</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
<p className="text-sm mt-1 font-mono text-muted-foreground">
{selectedLog?.entityId || 'System'}
</p>
</div>
</div>
{/* User Info */}
{selectedLog?.user && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">User Information</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Name</label>
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Email</label>
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
</div>
</div>
</div>
)}
{/* Network Info */}
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
<div className="space-y-2">
{selectedLog?.ipAddress && (
<div>
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
</div>
)}
{selectedLog?.userAgent && (
<div>
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
{selectedLog?.userAgent}
</p>
</div>
)}
</div>
</div>
)}
{/* Changes */}
{(selectedLog?.oldData || selectedLog?.newData) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedLog?.oldData && (
<div>
<label className="text-xs font-semibold text-red-600">Old Data</label>
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.oldData)}
</pre>
</div>
)}
{selectedLog?.newData && (
<div>
<label className="text-xs font-semibold text-green-600">New Data</label>
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.newData)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Raw Log ID */}
<div className="border-t pt-4">
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -80,6 +80,45 @@ export default function BookingsPage() {
}
};
const handleExportBookings = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
);
if (!selectedColumns) return;
const cols = selectedColumns.split(',').map(c => c.trim());
const csv = [
cols.join(','),
...data?.items?.map((booking: any) => {
const values = cols.map(col => {
switch(col) {
case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status;
case 'bookingType': return booking.bookingType || 'N/A';
case 'passengerCount': return booking.adultCount + booking.childCount;
case 'totalMinor': return booking.totalMinor;
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'createdAt': return booking.createdAt;
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}) || []
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
};
const columns = [
{
key: 'bookingRef',
@@ -99,6 +138,17 @@ export default function BookingsPage() {
</div>
),
},
{
key: 'bookingType',
label: 'Class',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
{
key: 'passengerCount',
label: 'Passengers',
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
},
{
key: 'status',
label: 'Status',
@@ -158,7 +208,7 @@ export default function BookingsPage() {
<h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p>
</div>
<ActionButton variant="export" icon={Download}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
</div>
<div className="card">

View File

@@ -75,7 +75,9 @@ export default function ClassesPage() {
coachTypeId: selectedCoachTypeId,
name: formData.get('name') as string,
description: formData.get('description') as string,
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
baseFareMinor: Math.round(parseFloat(formData.get('baseFareMinor') as string) * 100) || 0,
premiumMinor: Math.round(parseFloat(formData.get('premiumMinor') as string) * 100) || 0,
insuranceFeeMinor: Math.round(parseFloat(formData.get('insuranceFeeMinor') as string) * 100) || 0,
isActive: formData.get('isActive') === 'true',
};
@@ -136,9 +138,23 @@ export default function ClassesPage() {
},
{
key: 'baseFareMinor',
label: 'Base Fare (ETB)',
label: 'Base Fare',
render: (cls: any) => (
<span className="font-mono text-sm">{formatCurrency(cls.baseFareMinor, 'ETB')}</span>
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
),
},
{
key: 'premiumMinor',
label: 'Premium',
render: (cls: any) => (
<span className="font-mono text-sm">{cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
key: 'insuranceFeeMinor',
label: 'Insurance',
render: (cls: any) => (
<span className="font-mono text-sm">{cls.insuranceFeeMinor ? (cls.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
@@ -181,7 +197,7 @@ export default function ClassesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage class configurations by coach type</p>
<p className="text-muted-foreground">Manage class configurations with pricing by coach type</p>
</div>
<ActionButton
icon={Plus}
@@ -278,18 +294,60 @@ export default function ClassesPage() {
/>
</div>
<div>
<label className="label">Base Fare (ETB cents) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor || ''}
required
min="0"
placeholder="e.g., 45000 (450 ETB)"
/>
<p className="text-xs text-muted-foreground mt-1">Enter amount in cents (100 cents = 1 ETB)</p>
<div className="border-t pt-4">
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
<div>
<label className="label">Base Fare (ETB) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor ? (editingClass.baseFareMinor / 100).toFixed(2) : ''}
required
min="0"
step="0.01"
placeholder="e.g., 350.00"
/>
<p className="text-xs text-muted-foreground mt-1">Per-km distance-based fare rate</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label className="label">Premium Fee (ETB)</label>
<input
type="number"
name="premiumMinor"
className="input"
defaultValue={editingClass?.premiumMinor ? (editingClass.premiumMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 50.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., lounge access, extra legroom)</p>
</div>
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="insuranceFeeMinor"
className="input"
defaultValue={editingClass?.insuranceFeeMinor ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 25.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Total Fare Calculation:</p>
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
<p className="mt-2 text-xs"> Premium applies per passenger (including free child)</p>
<p className="text-xs"> Insurance applies per passenger (including free child)</p>
</div>
</div>
<div>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react';
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
@@ -11,6 +11,135 @@ import { fleetApi, apiClient } from '@/lib/api';
type Tab = 'types' | 'coaches';
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
if (bedPosition === 'middle') return 'M';
if (bedPosition === 'lower') return 'L';
return '';
};
const renderBedVisualization = (coach: any) => {
const seats = coach.seats || [];
const validSeats = seats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
if (validSeats.length === 0) {
return <div className="text-xs text-muted-foreground">No seats</div>;
}
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
const isBedCoach = coach.coachType?.name?.toLowerCase().includes('bed');
if (!isBedCoach || !hasBedPositionData) {
// Regular seat layout
const arrangement = coach.seatArrangement || coach.arrangement || '2+2';
const [left, right] = arrangement.split('+').map((p: string) => parseInt(p.trim()));
const cols = new Map<number, any[]>();
for (const seat of validSeats) {
if (!cols.has(seat.row)) cols.set(seat.row, []);
cols.get(seat.row)!.push(seat);
}
return (
<div className="space-y-1">
{Array.from(cols.entries()).map(([row, rowSeats]) => (
<div key={row} className="flex gap-3 justify-start">
<div className="flex gap-0.5">
{rowSeats.slice(0, left).map((s: any) => (
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
<Armchair className="w-3 h-3 text-white" />
</div>
))}
</div>
<div className="flex gap-0.5">
{rowSeats.slice(left).map((s: any) => (
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
<Armchair className="w-3 h-3 text-white" />
</div>
))}
</div>
</div>
))}
</div>
);
}
// Bed layout with pairing
const seatsByRow = new Map<number, any[]>();
for (const seat of validSeats) {
if (!seatsByRow.has(seat.row)) seatsByRow.set(seat.row, []);
seatsByRow.get(seat.row)!.push(seat);
}
const beds = coach.coachType?.name?.toLowerCase().includes('vip') ? 'w-12' : 'w-10';
const rows = Array.from(seatsByRow.entries()).map(([r, s]) => s);
return (
<div className="space-y-1">
{rows.map((rowSeats: any[], idx: number) => {
const rowNumber = rowSeats[0]?.row || (idx + 1);
const isFirstInPair = (rowNumber - 1) % 2 === 0;
const isLastRow = idx === rows.length - 1;
const nextRowSeats = !isLastRow ? rows[idx + 1] : null;
return (
<div key={`row-${idx}`}>
{/* Row 1 of pair - label above */}
{isFirstInPair && (
<div className="flex gap-0.5 text-xs text-gray-500 mb-0.5">
{rowSeats.map((s: any) => (
<div key={`label-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
{s.seatNumber}
</div>
))}
</div>
)}
{/* Row 1 of pair - beds */}
<div className="flex gap-0.5">
{rowSeats.map((s: any) => (
<div
key={s.id}
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
style={isFirstInPair ? { transform: 'scaleY(-1)' } : undefined}
>
<Bed className="w-3 h-3 text-white" />
</div>
))}
</div>
{/* Numbers between rows */}
{isFirstInPair && nextRowSeats && (
<div className="flex gap-0.5 text-xs text-gray-500 my-0.5">
{rowSeats.map((s: any, idx: number) => {
const nextSeat = nextRowSeats[idx];
return (
<div key={`between-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
{nextSeat?.seatNumber}
</div>
);
})}
</div>
)}
{/* Row 2 of pair - beds */}
{!isFirstInPair && (
<div className="flex gap-0.5">
{rowSeats.map((s: any) => (
<div
key={s.id}
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
>
<Bed className="w-3 h-3 text-white" />
</div>
))}
</div>
)}
{!isFirstInPair && <div className="h-1" />}
</div>
);
})}
</div>
);
};
export default function CoachesPage() {
const [activeTab, setActiveTab] = useState<Tab>('coaches');
const [search, setSearch] = useState('');
@@ -107,6 +236,7 @@ export default function CoachesPage() {
coachTypeId: formData.get('coachTypeId') as string,
arrangement: formData.get('arrangement') as string,
capacity: parseInt(formData.get('capacity') as string),
sequence: parseInt(formData.get('sequence') as string),
status: formData.get('status') as string,
};
@@ -208,6 +338,14 @@ export default function CoachesPage() {
// Coaches Columns
const coachColumns = [
{
key: 'sequence',
label: 'Sequence',
sortable: true,
render: (coach: any) => (
<span className="font-mono font-semibold text-sm">{coach.sequence}</span>
),
},
{
key: 'number',
label: 'Number',
@@ -486,7 +624,7 @@ export default function CoachesPage() {
<form onSubmit={handleCoachSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Type</label>
<label className="label">Coach Type *</label>
<select
name="coachTypeId"
className="input"
@@ -503,7 +641,7 @@ export default function CoachesPage() {
</div>
<div>
<label className="label">Number</label>
<label className="label">Number *</label>
<input
type="text"
name="number"
@@ -515,20 +653,20 @@ export default function CoachesPage() {
</div>
<div>
<label className="label">Arrangement</label>
<label className="label">Arrangement *</label>
<input
type="text"
name="arrangement"
className="input"
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
required
placeholder="e.g., 2+2, 3+2"
placeholder="e.g., 3+2, 3+0, 2+0"
/>
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
</div>
<div>
<label className="label">Capacity</label>
<label className="label">Capacity *</label>
<input
type="number"
name="capacity"
@@ -540,12 +678,27 @@ export default function CoachesPage() {
/>
</div>
<div className="md:col-span-2">
<label className="label">Status</label>
<div>
<label className="label">Sequence Number *</label>
<input
type="number"
name="sequence"
className="input"
defaultValue={editingItem?.sequence || 0}
min="0"
required
placeholder="e.g., 1"
/>
<p className="text-xs text-muted-foreground mt-1">Used for ordering coaches in trains</p>
</div>
<div>
<label className="label">Status *</label>
<select
name="status"
className="input"
defaultValue={editingItem?.status || 'ACTIVE'}
required
>
<option value="ACTIVE">Active</option>
<option value="MAINTENANCE">Maintenance</option>

View File

@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
export default function CurrenciesLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,475 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
interface Currency {
id: string;
code: string;
name: string;
symbol: string;
baseCurrencyCode: string;
exchangeRate: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export default function CurrenciesPage() {
const [showModal, setShowModal] = useState(false);
const [editingCurrency, setEditingCurrency] = useState<Currency | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({
isOpen: false,
id: null,
});
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const [currencyForm, setCurrencyForm] = useState({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
const { data: currencies = [], isLoading } = useQuery({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
});
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/currencies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
resetForm();
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to create currency');
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setEditingCurrency(null);
resetForm();
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update currency');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setDeleteConfirm({ isOpen: false, id: null });
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to delete currency');
},
});
const syncRatesMutation = useMutation({
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to sync exchange rates');
},
});
const resetForm = () => {
setCurrencyForm({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
setEditingCurrency(null);
setShowModal(false);
setError(null);
};
const handleEditCurrency = (currency: Currency) => {
setEditingCurrency(currency);
setCurrencyForm({
code: currency.code,
name: currency.name,
symbol: currency.symbol,
baseCurrencyCode: currency.baseCurrencyCode,
exchangeRate: currency.exchangeRate.toString(),
});
setError(null);
setShowModal(true);
};
const handleSaveCurrency = async () => {
setError(null);
if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) {
setError('All fields are required');
return;
}
const rate = parseFloat(currencyForm.exchangeRate);
if (isNaN(rate) || rate <= 0) {
setError('Exchange rate must be a positive number');
return;
}
const payload = {
code: currencyForm.code.toUpperCase(),
name: currencyForm.name,
symbol: currencyForm.symbol,
baseCurrencyCode: currencyForm.baseCurrencyCode,
exchangeRate: rate,
};
if (editingCurrency) {
await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload });
} else {
await createMutation.mutateAsync(payload);
}
};
const confirmDelete = async () => {
if (deleteConfirm.id) {
await deleteMutation.mutateAsync(deleteConfirm.id);
}
};
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || [];
const columns = [
{
key: 'code',
label: 'Code',
render: (currency: Currency) => (
<span className="font-mono font-semibold text-primary">{currency.code}</span>
),
},
{
key: 'name',
label: 'Name',
render: (currency: Currency) => (
<span className="font-medium">{currency.name}</span>
),
},
{
key: 'symbol',
label: 'Symbol',
render: (currency: Currency) => (
<span className="text-lg">{currency.symbol}</span>
),
},
{
key: 'baseCurrencyCode',
label: 'Base Currency',
render: (currency: Currency) => (
<span className="font-mono text-sm">{currency.baseCurrencyCode}</span>
),
},
{
key: 'exchangeRate',
label: 'Exchange Rate',
render: (currency: Currency) => (
<div className="space-y-1">
<div className="font-mono font-semibold">
1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code}
</div>
<div className="text-xs text-muted-foreground">
1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode}
</div>
</div>
),
},
{
key: 'isActive',
label: 'Status',
render: (currency: Currency) => (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
currency.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-gray-100 dark:bg-gray-900/20 text-gray-800 dark:text-gray-300'
}`}>
{currency.isActive ? 'Active' : 'Inactive'}
</span>
),
},
{
key: 'updatedAt',
label: 'Last Updated',
render: (currency: Currency) => (
<span className="text-sm text-muted-foreground">
{new Date(currency.updatedAt).toLocaleDateString()}
</span>
),
},
];
const actions = [
{
label: 'Edit',
onClick: handleEditCurrency,
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Currencies</h1>
<p className="text-muted-foreground mt-1">Manage exchange rates and display currencies</p>
</div>
<div className="flex gap-3">
<ActionButton
icon={RefreshCw}
variant="secondary"
onClick={() => syncRatesMutation.mutate()}
loading={syncRatesMutation.isPending}
>
Sync Rates
</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setError(null);
setEditingCurrency(null);
setCurrencyForm({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
setShowModal(true);
}}
>
Add Currency
</ActionButton>
</div>
</div>
<div className="card">
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-900/10 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="text-sm text-blue-600 dark:text-blue-400 font-medium">Total Currencies</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-200 mt-2">
{currenciesArray.length}
</div>
</div>
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-900/10 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-sm text-green-600 dark:text-green-400 font-medium">Active</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-200 mt-2">
{currenciesArray.filter((c: Currency) => c.isActive).length}
</div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-900/10 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="text-sm text-purple-600 dark:text-purple-400 font-medium">Base Currency</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-200 mt-2">ETB</div>
</div>
<div className="p-4 bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-900/10 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">Last Sync</div>
<div className="text-lg font-bold text-orange-900 dark:text-orange-200 mt-2">
{currenciesArray.length > 0
? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString()
: 'N/A'}
</div>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : currenciesArray.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No currencies configured. Click "Add Currency" to create one.</p>
</div>
) : (
<DataTable
data={currenciesArray}
columns={columns}
actions={actions}
loading={false}
emptyMessage="No currencies found."
/>
)}
</div>
</div>
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Currency Management</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Base Currency:</strong> All exchange rates are calculated relative to this currency (typically ETB)
</li>
<li>
<strong>Exchange Rate:</strong> How many units of the currency equal 1 unit of the base currency
</li>
<li>
<strong>Display Currencies:</strong> Configure which currencies customers can view prices in
</li>
<li>
<strong>Sync Rates:</strong> Automatically update exchange rates from external sources
</li>
</ul>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmDelete}
title="Delete Currency"
message="Are you sure you want to delete this currency? This action cannot be undone."
confirmText="Delete"
isDanger={true}
warning="This will remove the currency from the system."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={resetForm}
title={`${editingCurrency ? 'Edit' : 'Add'} Currency`}
size="lg"
>
<div className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Currency Code *</label>
<input
type="text"
value={currencyForm.code}
onChange={(e) => setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })}
className="input w-full"
placeholder="e.g., USD"
maxLength={3}
disabled={!!editingCurrency}
required
/>
<p className="text-xs text-muted-foreground mt-1">3-letter ISO code (e.g., USD, DJF, GBP)</p>
</div>
<div>
<label className="label">Currency Name *</label>
<input
type="text"
value={currencyForm.name}
onChange={(e) => setCurrencyForm({ ...currencyForm, name: e.target.value })}
className="input w-full"
placeholder="e.g., United States Dollar"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Symbol *</label>
<input
type="text"
value={currencyForm.symbol}
onChange={(e) => setCurrencyForm({ ...currencyForm, symbol: e.target.value })}
className="input w-full"
placeholder="e.g., $"
maxLength={3}
required
/>
</div>
<div>
<label className="label">Base Currency *</label>
<select
value={currencyForm.baseCurrencyCode}
onChange={(e) => setCurrencyForm({ ...currencyForm, baseCurrencyCode: e.target.value })}
className="input w-full"
disabled
>
<option value="ETB">ETB (Ethiopian Birr)</option>
<option value="USD">USD (US Dollar)</option>
<option value="DJF">DJF (Djiboutian Franc)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">All rates relative to this currency</p>
</div>
</div>
<div>
<label className="label">Exchange Rate *</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
step="0.0001"
value={currencyForm.exchangeRate}
onChange={(e) => setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })}
className="input w-full"
placeholder="e.g., 0.018"
required
/>
<div className="text-sm text-muted-foreground whitespace-nowrap">
1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code}
</div>
</div>
{currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && (
<p className="text-xs text-muted-foreground mt-2">
1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode}
</p>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 rounded-lg text-xs text-blue-800 dark:text-blue-200">
<p className="font-semibold mb-1">Exchange Rate Example:</p>
<p>If 1 ETB = 0.018 USD, enter 0.018</p>
<p>If 1 ETB = 3.25 DJF, enter 3.25</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<ActionButton
variant="secondary"
onClick={resetForm}
type="button"
>
Cancel
</ActionButton>
<ActionButton
onClick={handleSaveCurrency}
loading={createMutation.isPending || updateMutation.isPending}
>
{editingCurrency ? 'Update Currency' : 'Add Currency'}
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,13 +1,15 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react';
import { Ticket, Users, DollarSign, Percent } 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, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
export default function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useQuery({
@@ -20,22 +22,55 @@ export default function DashboardPage() {
queryFn: () => dashboardApi.getRevenueChart(30),
});
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any>({
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any[]>({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
});
const recentBookings = Array.isArray(recentBookingsData)
? recentBookingsData
: recentBookingsData?.items || recentBookingsData?.data || [];
const { data: topAgents, isLoading: agentsLoading } = useQuery({
queryKey: ['top-agents'],
queryFn: () => dashboardApi.getTopAgents(5),
});
const columns = [
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
queryKey: ['occupancy-trend'],
queryFn: () => dashboardApi.getOccupancyTrend(7),
});
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
queryKey: ['upcoming-trips'],
queryFn: () => dashboardApi.getUpcomingTrips(5),
});
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryFn: dashboardApi.getPaymentMethods,
});
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : [];
const bookingColumns = [
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
{ key: 'passenger', label: 'Passenger', render: (item: any) => item.passenger?.fullName || item.contactEmail || 'N/A' },
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
{
key: 'status',
label: 'Status',
key: 'passenger',
label: 'Passenger',
render: (item: any) => {
if (item.passenger?.fullName) {
return item.passenger.fullName;
}
if (item.contactEmail) {
return item.contactEmail;
}
if (item.contactPhone) {
return item.contactPhone;
}
return 'N/A';
}
},
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
@@ -45,19 +80,43 @@ export default function DashboardPage() {
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
];
const agentColumns = [
{ key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName },
{ key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 },
{ key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') },
{ key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') },
];
const tripColumns = [
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name}${item.destinationStation?.name || item.destination?.name}` },
{ key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
{ key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Hello, welcome back! Here&apos;s what&apos;s happening today.</p>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
</div>
{/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Bookings"
value={statsLoading ? '...' : (stats?.totalBookings || 0).toLocaleString()}
icon={Ticket}
color="blue"
color="blue"
/>
<StatCard
title="Total Revenue"
@@ -74,35 +133,107 @@ export default function DashboardPage() {
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
icon={TrendingUp}
color="green"
icon={Percent}
color="orange"
/>
</div>
{!revenueLoading && revenueData && revenueData.length > 0 && (
{/* Charts Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend */}
{!revenueLoading && revenueData && revenueData.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
<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>
)}
{/* Occupancy Trend */}
{!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Occupancy Trend (Last 7 Days)</h2>
<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>
)}
</div>
{/* Payment Methods Distribution */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
<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>
<PieChart>
<Pie
data={paymentMethods}
dataKey="count"
nameKey="method"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{paymentMethods.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Recent Bookings */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
<DataTable
data={recentBookings}
columns={columns}
columns={bookingColumns}
loading={bookingsLoading}
emptyMessage="No recent bookings"
/>
</div>
{/* Upcoming Trips */}
{upcomingTrips && upcomingTrips.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Upcoming Trips</h2>
<DataTable
data={upcomingTrips}
columns={tripColumns}
loading={tripsLoading}
emptyMessage="No upcoming trips"
/>
</div>
)}
{/* Top Agents */}
{topAgents && topAgents.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Top Performing Agents</h2>
<DataTable
data={topAgents}
columns={agentColumns}
loading={agentsLoading}
emptyMessage="No agent data"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,949 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react';
const DocPage = () => {
const [expandedSections, setExpandedSections] = useState<{ [key: string]: boolean }>({
overview: true,
operations: true,
masterdata: false,
financial: false,
services: false,
security: false,
analytics: false,
system: false,
});
const toggleSection = (section: string) => {
setExpandedSections(prev => (({
...prev,
[section]: !prev[section]
})));
};
const scrollToSection = (id: string) => {
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
const headerOffset = 120;
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
const offsetPosition = elementPosition - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}, 0);
};
const sections = [
{
id: 'overview',
title: '📋 Overview & Getting Started',
items: [
{ id: 'about', label: 'Application Overview' },
{ id: 'features', label: 'Key Features' },
]
},
{
id: 'operations',
title: '📊 Operations',
items: [
{ id: 'bookings', label: 'Bookings' },
{ id: 'bookings-how', label: '→ How-To' },
{ id: 'passengers', label: 'Passengers' },
{ id: 'passengers-how', label: '→ How-To' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'tickets-how', label: '→ How-To' },
]
},
{
id: 'masterdata',
title: '🏢 Master Data',
items: [
{ id: 'stations', label: 'Stations' },
{ id: 'stations-how', label: '→ How-To' },
{ id: 'trains', label: 'Trains' },
{ id: 'trains-how', label: '→ How-To' },
{ id: 'coaches', label: 'Coaches' },
{ id: 'coaches-how', label: '→ How-To' },
{ id: 'seats', label: 'Seats' },
{ id: 'seats-how', label: '→ How-To' },
{ id: 'classes', label: 'Seat Classes' },
{ id: 'classes-how', label: '→ How-To' },
{ id: 'routes', label: 'Routes' },
{ id: 'routes-how', label: '→ How-To' },
{ id: 'schedules', label: 'Schedules' },
{ id: 'schedules-how', label: '→ How-To' },
]
},
{
id: 'financial',
title: '💰 Financial',
items: [
{ id: 'pricing', label: 'Pricing & Fares' },
{ id: 'pricing-how', label: '→ How-To' },
{ id: 'currencies', label: 'Currencies' },
{ id: 'currencies-how', label: '→ How-To' },
{ id: 'payments', label: 'Payments' },
{ id: 'payments-how', label: '→ How-To' },
{ id: 'promos', label: 'Promo Codes' },
{ id: 'promos-how', label: '→ How-To' },
]
},
{
id: 'services',
title: '🎁 Customer Services',
items: [
{ id: 'loyalty', label: 'Loyalty' },
{ id: 'loyalty-how', label: '→ How-To' },
{ id: 'support', label: 'Support' },
{ id: 'support-how', label: '→ How-To' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'notifications-how', label: '→ How-To' },
]
},
{
id: 'security',
title: '🔒 Security',
items: [
{ id: 'audit', label: 'Audit Logs' },
{ id: 'audit-how', label: '→ How-To' },
{ id: 'fraud', label: 'Fraud Detection' },
{ id: 'fraud-how', label: '→ How-To' },
{ id: 'verifayda', label: 'Verifayda' },
{ id: 'verifayda-how', label: '→ How-To' },
]
},
{
id: 'analytics',
title: '📈 Analytics',
items: [
{ id: 'reports', label: 'Reports' },
{ id: 'reports-how', label: '→ How-To' },
]
},
{
id: 'system',
title: '⚙️ System',
items: [
{ id: 'agents', label: 'Agents' },
{ id: 'agents-how', label: '→ How-To' },
{ id: 'users', label: 'Users' },
{ id: 'users-how', label: '→ How-To' },
{ id: 'settings', label: 'Settings' },
{ id: 'settings-how', label: '→ How-To' },
]
},
];
const HowToStep = ({ number, title, children }: { number: number; title: string; children: React.ReactNode }) => (
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">{number}</div>
<div className="flex-1">
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">{title}</h4>
{children}
</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-8 w-8 text-emerald-600" />
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Documentation</h1>
</div>
<div className="flex items-center gap-2">
<a href="http://localhost:4000/api-docs" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition" title="Opens API documentation in new tab">
<FileText className="h-4 w-4" />
View API Docs
</a>
<Link href="/dashboard" target="_blank" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition">
<Home className="h-4 w-4" />
Dashboard
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 sticky top-24 h-fit">
<nav>
{sections.map(section => (
<div key={section.id}>
<button onClick={() => toggleSection(section.id)} className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-700">
<span>{section.title}</span>
{expandedSections[section.id] ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{expandedSections[section.id] && (
<div className="bg-slate-50 dark:bg-slate-700/50">
{section.items.map(item => (
<button key={item.id} onClick={() => scrollToSection(item.id)} className="w-full text-left px-6 py-2 text-sm text-slate-600 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-white dark:hover:bg-slate-700 transition">
{item.label}
</button>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
<div className="lg:col-span-3">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-12">
<div id="about">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">Welcome to EDR Passenger Backoffice</h2>
<p className="text-slate-600 dark:text-slate-300">Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.</p>
</div>
<div id="features" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🌟 Key Features</h2>
<p className="text-slate-600 dark:text-slate-300">Complete booking, passenger, fleet, and financial management.</p>
</div>
{/* BOOKINGS */}
<div id="bookings" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Bookings</h2>
<p className="text-slate-600 dark:text-slate-300">Manage passenger bookings with search, view, modify, and refund capabilities.</p>
</div>
<div id="bookings-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: Manage Bookings</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Bookings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Bookings"`} in Operations section</li>
<li>View all bookings in table format</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search & Filter">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Use search box for reference, email, or phone</li>
<li>Use Status dropdown to filter</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Bookings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"View Details"`} for full information</li>
<li>Click {`"Cancel Booking"`} to process refunds</li>
</ol>
</HowToStep>
</div>
</div>
{/* PASSENGERS */}
<div id="passengers" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👥 Passengers</h2>
<p className="text-slate-600 dark:text-slate-300">Manage passenger profiles, loyalty, and verification status.</p>
</div>
<div id="passengers-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👥 How-To: Manage Passengers</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Passengers">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Passengers"`} in Operations</li>
<li>View all profiles with pagination</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search & Filter">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by name, email, phone, ID</li>
<li>Filter by nationality, verification, loyalty tier</li>
</ol>
</HowToStep>
<HowToStep number={3} title="View Profile">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click passenger row to open modal</li>
<li>View account, loyalty, wallet, booking history</li>
</ol>
</HowToStep>
</div>
</div>
{/* TICKETS */}
<div id="tickets" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎫 Tickets</h2>
<p className="text-slate-600 dark:text-slate-300">Manage ticket generation, tracking, and validation.</p>
</div>
<div id="tickets-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎫 How-To: Manage Tickets</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Tickets">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Tickets"`} in Operations</li>
<li>View all issued tickets with status</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search Tickets">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by booking reference or ticket number</li>
<li>Filter by validation status</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Download PDF">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click ticket to view details</li>
<li>Click {`"Download PDF"`} for printable version</li>
</ol>
</HowToStep>
</div>
</div>
{/* STATIONS */}
<div id="stations" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏢 Stations</h2>
<p className="text-slate-600 dark:text-slate-300">Configure railway stations with locations and timezones.</p>
</div>
<div id="stations-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏢 How-To: Manage Stations</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Stations">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Stations"`} in Master Data</li>
<li>View all configured stations</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Station">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Add Station"`}</li>
<li>Enter code, name, city, timezone, coordinates</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Edit Station">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click station to open details</li>
<li>Update information and save</li>
</ol>
</HowToStep>
</div>
</div>
{/* TRAINS */}
<div id="trains" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚂 Trains</h2>
<p className="text-slate-600 dark:text-slate-300">Manage train fleet with coach assignments.</p>
</div>
<div id="trains-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚂 How-To: Manage Trains</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Trains">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Trains"`} in Master Data</li>
<li>View all trains and coaches</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Train">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Add Train"`}</li>
<li>Enter code and select coaches</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Assign Coaches">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click train to edit</li>
<li>Add/remove coaches with position numbers</li>
</ol>
</HowToStep>
</div>
</div>
{/* COACHES */}
<div id="coaches" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚃 Coaches</h2>
<p className="text-slate-600 dark:text-slate-300">Manage coach inventory with seat configurations.</p>
</div>
<div id="coaches-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚃 How-To: Manage Coaches</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Coaches">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Coaches" in Master Data</li>
<li>View all coaches and assignments</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Coach">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Coach"</li>
<li>Enter code, select train, define seat layout</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Configure Seats">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click coach to edit</li>
<li>Add seats and assign classes</li>
</ol>
</HowToStep>
</div>
</div>
{/* SEATS */}
<div id="seats" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💺 Seats</h2>
<p className="text-slate-600 dark:text-slate-300">Manage seat inventory with visual maps.</p>
</div>
<div id="seats-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💺 How-To: Manage Seats</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Seat Map">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to "Seats" in Master Data</li>
<li>Select coach from dropdown</li>
<li>Visual map shows: Green=Available, Red=Blocked</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Block Seat">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click available seat</li>
<li>Click "Block" and select reason</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Unblock Seat">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click blocked seat</li>
<li>Click "Unblock" to restore</li>
</ol>
</HowToStep>
</div>
</div>
{/* SEAT CLASSES */}
<div id="classes" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎯 Seat Classes</h2>
<p className="text-slate-600 dark:text-slate-300">Define seat class types with pricing.</p>
</div>
<div id="classes-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎯 How-To: Manage Seat Classes</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Classes">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Seat Classes" in Master Data</li>
<li>View all class types</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Class">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Class"</li>
<li>Enter name, base fare, premium, insurance</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Update Pricing">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click class to edit</li>
<li>Update fares and save</li>
</ol>
</HowToStep>
</div>
</div>
{/* ROUTES */}
<div id="routes" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🛤 Routes</h2>
<p className="text-slate-600 dark:text-slate-300">Define railway routes with ordered stops.</p>
</div>
<div id="routes-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🛤 How-To: Manage Routes</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Routes">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Routes" in Master Data</li>
<li>View all routes and stops</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Route">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Route"</li>
<li>Enter code and description</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Add Stops">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click route to edit</li>
<li>Click "Add Stop" and select station</li>
</ol>
</HowToStep>
</div>
</div>
{/* SCHEDULES */}
<div id="schedules" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📅 Schedules</h2>
<p className="text-slate-600 dark:text-slate-300">Create and manage train schedules.</p>
</div>
<div id="schedules-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📅 How-To: Create Schedules</h3>
<div className="space-y-4">
<HowToStep number={1} title="Create Single">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to "Schedules" in Master Data</li>
<li>Click "Create Schedule"</li>
<li>Fill train, route, departure/arrival times</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Bulk Generate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Bulk Generate"</li>
<li>Set recurring parameters and generate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Status">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click schedule to edit</li>
<li>Update times and view fares</li>
</ol>
</HowToStep>
</div>
</div>
{/* PRICING */}
<div id="pricing" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💰 Pricing & Fares</h2>
<p className="text-slate-600 dark:text-slate-300">Configure dynamic pricing with segments.</p>
</div>
<div id="pricing-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💰 How-To: Configure Pricing</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Pricing">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Pricing & Fares" in Financial</li>
<li>Two tabs: Schedule Fares, Segment Fares</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Schedule Fares">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Fare Rule"</li>
<li>Fill schedule, seat class, fare, nationality</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Segment Fares">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Switch to "Segment Fares" tab</li>
<li>Select route and add origin/destination fare</li>
</ol>
</HowToStep>
</div>
</div>
{/* CURRENCIES */}
<div id="currencies" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💵 Currencies</h2>
<p className="text-slate-600 dark:text-slate-300">Manage exchange rates for multiple currencies.</p>
</div>
<div id="currencies-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💵 How-To: Manage Currencies</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Rates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Currencies" in Financial</li>
<li>View all configured rates</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Add Rate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Rate"</li>
<li>Select currency and enter exchange rate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Sync Rates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click rate to edit</li>
<li>Click "Sync" to update from provider</li>
</ol>
</HowToStep>
</div>
</div>
{/* PAYMENTS */}
<div id="payments" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💳 Payments</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor and process transactions.</p>
</div>
<div id="payments-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💳 How-To: Manage Payments</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Transactions">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Payments" in Financial</li>
<li>View all transactions</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search & Filter">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by booking or transaction ID</li>
<li>Filter by status and payment method</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Process Refunds">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click transaction</li>
<li>Click "Refund" if eligible</li>
</ol>
</HowToStep>
</div>
</div>
{/* PROMOS */}
<div id="promos" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎁 Promo Codes</h2>
<p className="text-slate-600 dark:text-slate-300">Create and manage promotional campaigns.</p>
</div>
<div id="promos-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎁 How-To: Manage Promo Codes</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Promos">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Promo Codes" in Financial</li>
<li>View all active codes</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Code">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Promo Code"</li>
<li>Enter code, discount type, validity dates</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Track Usage">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click code to view analytics</li>
<li>View usage count and savings</li>
</ol>
</HowToStep>
</div>
</div>
{/* LOYALTY */}
<div id="loyalty" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏆 Loyalty</h2>
<p className="text-slate-600 dark:text-slate-300">Manage loyalty program and rewards.</p>
</div>
<div id="loyalty-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏆 How-To: Manage Loyalty</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Accounts">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Loyalty Program" in Services</li>
<li>View all loyalty accounts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Adjust Points">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click account</li>
<li>Click "Adjust Points" and enter amount</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Award Rewards">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click account</li>
<li>Click "Grant Reward" and select reward</li>
</ol>
</HowToStep>
</div>
</div>
{/* SUPPORT */}
<div id="support" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💬 Support</h2>
<p className="text-slate-600 dark:text-slate-300">Manage support tickets and conversations.</p>
</div>
<div id="support-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💬 How-To: Manage Support</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Tickets">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Support Center" in Services</li>
<li>View all support tickets</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Manage Ticket">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click ticket to open conversation</li>
<li>Add replies and update status</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage FAQ">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to FAQ management</li>
<li>Add or edit FAQ articles</li>
</ol>
</HowToStep>
</div>
</div>
{/* NOTIFICATIONS */}
<div id="notifications" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🔔 Notifications</h2>
<p className="text-slate-600 dark:text-slate-300">Send notifications via multiple channels.</p>
</div>
<div id="notifications-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🔔 How-To: Manage Notifications</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Notifications">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Notifications" in Services</li>
<li>View notification history</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Send Notification">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Send Notification"</li>
<li>Select channel and message</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Templates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to Templates section</li>
<li>Create or edit templates with variables</li>
</ol>
</HowToStep>
</div>
</div>
{/* AUDIT */}
<div id="audit" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Audit Logs</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor system activities and user actions.</p>
</div>
<div id="audit-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: View Audit Logs</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Audit Logs" in Security</li>
<li>View all recorded activities</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Filter Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Filter by user, action, or date</li>
<li>Search by entity ID</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Export Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click log entry for details</li>
<li>Click "Export" to download CSV</li>
</ol>
</HowToStep>
</div>
</div>
{/* FRAUD */}
<div id="fraud" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🛡 Fraud Detection</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor and manage fraud alerts.</p>
</div>
<div id="fraud-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🛡 How-To: Manage Fraud Detection</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Alerts">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Fraud Detection" in Security</li>
<li>View all fraud alerts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Investigate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click alert to view details</li>
<li>Review triggered rules and patterns</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Take Action">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Allow" or "Block" with notes</li>
<li>Update user status</li>
</ol>
</HowToStep>
</div>
</div>
{/* VERIFAYDA */}
<div id="verifayda" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4"> Verifayda</h2>
<p className="text-slate-600 dark:text-slate-300">Verify passenger identities against government database.</p>
</div>
<div id="verifayda-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2"> How-To: Manage Verifayda</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Verification">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Verifayda Integration" in Security</li>
<li>View verification history</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Verify Passenger">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Enter national ID or passport number</li>
<li>Click "Verify" to check database</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Review Results">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>View verified passenger data</li>
<li>Match with booking details</li>
</ol>
</HowToStep>
</div>
</div>
{/* REPORTS */}
<div id="reports" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📊 Reports</h2>
<p className="text-slate-600 dark:text-slate-300">Generate business analytics and reports.</p>
</div>
<div id="reports-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📊 How-To: Generate Reports</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Reports">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Reports" in Analytics</li>
<li>View available report types</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Generate Report">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click report type</li>
<li>Select date range and parameters</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Export Report">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>View report with charts</li>
<li>Click "Export" for PDF or CSV</li>
</ol>
</HowToStep>
</div>
</div>
{/* AGENTS */}
<div id="agents" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👤 Agents</h2>
<p className="text-slate-600 dark:text-slate-300">Manage booking agents and commissions.</p>
</div>
<div id="agents-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👤 How-To: Manage Agents</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Agents">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Agents" in System</li>
<li>View all agents</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Agent">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Agent"</li>
<li>Enter name, email, commission rate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Create Shift">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click agent to edit</li>
<li>Click "Create Shift" to assign schedule</li>
</ol>
</HowToStep>
</div>
</div>
{/* USERS */}
<div id="users" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👥 Users</h2>
<p className="text-slate-600 dark:text-slate-300">Manage backoffice user accounts and permissions.</p>
</div>
<div id="users-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👥 How-To: Manage Users</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Users">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Users" in System</li>
<li>View all user accounts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create User">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add User"</li>
<li>Enter email, name, select role</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Permissions">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click user to edit</li>
<li>Adjust roles and permissions</li>
</ol>
</HowToStep>
</div>
</div>
{/* SETTINGS */}
<div id="settings" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4"> Settings</h2>
<p className="text-slate-600 dark:text-slate-300">Configure system-wide settings and integrations.</p>
</div>
<div id="settings-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2"> How-To: Configure Settings</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Settings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Settings" in System</li>
<li>View configuration options</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Configure Email">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to Email tab</li>
<li>Enter SendGrid API key and email</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Configure API Keys">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to API tab</li>
<li>Add payment and Verifayda keys</li>
</ol>
</HowToStep>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-12 bg-slate-900 text-white py-8">
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400">
<p>© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0</p>
</div>
</div>
</div>
);
};
export default DocPage;

Some files were not shown because too many files have changed in this diff Show More