diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e46409ba6..a819f7939 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index b1481d0d4..5b38c4d65 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { + return this.companiesService.getDashboardSummary(user.id); + } + @Patch('profile') @ApiOperation({ summary: 'Update profile (flattened settings page)' }) async updateProfile( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index d18573460..406c4f509 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index f383b9e55..086e8d767 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { @@ -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 { + // 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 { await this.findCompanyById(id); const updated = await this.companiesRepo.update(id, dto); diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts new file mode 100644 index 000000000..365cf1daa --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -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, + ) {} + + /** Count of delivered/completed bookings for a company within [from, to). */ + async countDelivered(companyId: string, from: Date, to: Date): Promise { + 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 { + 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 { + 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 { + 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 { + 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), + })); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts new file mode 100644 index 000000000..c0d99643c --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts @@ -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; +} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index dd3627fef..080df0ed2 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -173,7 +173,7 @@ const App = () => { } /> - }> + }> {item.href && !isLast ? ( - + {item.label} ) : ( diff --git a/apps/edr-freight-web/portal/src/components/bookings/BookingForm.tsx b/apps/edr-freight-web/portal/src/components/bookings/BookingForm.tsx deleted file mode 100644 index 44736a486..000000000 --- a/apps/edr-freight-web/portal/src/components/bookings/BookingForm.tsx +++ /dev/null @@ -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) => { - event.preventDefault(); - onSubmit({ - reference, - customerId, - scheduledDate, - totalAmount: Number(totalAmount), - }); - }; - - return ( -
- setReference(e.target.value)} - required - /> - setCustomerId(e.target.value)} - required - /> - setScheduledDate(e.target.value)} - required - /> - setTotalAmount(e.target.value)} - min="0" - /> - - - ); -}; - -export default BookingForm; diff --git a/apps/edr-freight-web/portal/src/components/bookings/BookingTable.tsx b/apps/edr-freight-web/portal/src/components/bookings/BookingTable.tsx deleted file mode 100644 index ccac88c57..000000000 --- a/apps/edr-freight-web/portal/src/components/bookings/BookingTable.tsx +++ /dev/null @@ -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[] = [ - { 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) => ( - row.id} - emptyMessage="No bookings yet" - /> -); - -export default BookingTable; diff --git a/apps/edr-freight-web/portal/src/components/consignments/ConsignmentForm.tsx b/apps/edr-freight-web/portal/src/components/consignments/ConsignmentForm.tsx deleted file mode 100644 index 1738cb1d0..000000000 --- a/apps/edr-freight-web/portal/src/components/consignments/ConsignmentForm.tsx +++ /dev/null @@ -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) => { - event.preventDefault(); - onSubmit({ - bookingId, - trackingNumber, - cargoType, - weightKg: Number(weightKg), - originStation, - destinationStation, - }); - }; - - return ( -
- setBookingId(e.target.value)} - required - /> - setTrackingNumber(e.target.value)} - required - /> - setCargoType(e.target.value)} - /> - setWeightKg(e.target.value)} - min="0" - /> - setOriginStation(e.target.value)} - required - /> - setDestinationStation(e.target.value)} - required - /> - - - ); -}; - -export default ConsignmentForm; diff --git a/apps/edr-freight-web/portal/src/components/consignments/ConsignmentTable.tsx b/apps/edr-freight-web/portal/src/components/consignments/ConsignmentTable.tsx deleted file mode 100644 index cc04524bf..000000000 --- a/apps/edr-freight-web/portal/src/components/consignments/ConsignmentTable.tsx +++ /dev/null @@ -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[] = [ - { 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) => ( -
row.id} - emptyMessage="No consignments yet" - /> -); - -export default ConsignmentTable; diff --git a/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx b/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx index 2003457f0..fa10a795e 100644 --- a/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx +++ b/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx @@ -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 ( - diff --git a/apps/edr-freight-web/portal/src/components/tracking/TrackingTimeline.tsx b/apps/edr-freight-web/portal/src/components/tracking/TrackingTimeline.tsx index c167e1371..ac02239ad 100644 --- a/apps/edr-freight-web/portal/src/components/tracking/TrackingTimeline.tsx +++ b/apps/edr-freight-web/portal/src/components/tracking/TrackingTimeline.tsx @@ -17,7 +17,7 @@ const TrackingTimeline = ({ events }: TrackingTimelineProps) => {
{event.location} - {event.status} + {event.status}
- - - - @@ -279,7 +269,10 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) { -
e.stopPropagation()}> +
e.stopPropagation()} + > + {expandedSections[section.id] && ( +
+ {section.items.map(item => ( + + ))} +
+ )} +
+ ))} + +
+ + +
+
+ +
+

Welcome to EDR Passenger Backoffice

+

Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.

+
+ +
+

🌟 Key Features

+

Complete booking, passenger, fleet, and financial management.

+
+ + {/* BOOKINGS */} +
+

📋 Bookings

+

Manage passenger bookings with search, view, modify, and refund capabilities.

+
+ +
+

📋 How-To: Manage Bookings

+
+ +
    +
  1. Click {`"Bookings"`} in Operations section
  2. +
  3. View all bookings in table format
  4. +
+
+ +
    +
  1. Use search box for reference, email, or phone
  2. +
  3. Use Status dropdown to filter
  4. +
+
+ +
    +
  1. Click {`"View Details"`} for full information
  2. +
  3. Click {`"Cancel Booking"`} to process refunds
  4. +
+
+
+
+ + {/* PASSENGERS */} +
+

👥 Passengers

+

Manage passenger profiles, loyalty, and verification status.

+
+ +
+

👥 How-To: Manage Passengers

+
+ +
    +
  1. Click {`"Passengers"`} in Operations
  2. +
  3. View all profiles with pagination
  4. +
+
+ +
    +
  1. Search by name, email, phone, ID
  2. +
  3. Filter by nationality, verification, loyalty tier
  4. +
+
+ +
    +
  1. Click passenger row to open modal
  2. +
  3. View account, loyalty, wallet, booking history
  4. +
+
+
+
+ + {/* TICKETS */} +
+

🎫 Tickets

+

Manage ticket generation, tracking, and validation.

+
+ +
+

🎫 How-To: Manage Tickets

+
+ +
    +
  1. Click {`"Tickets"`} in Operations
  2. +
  3. View all issued tickets with status
  4. +
+
+ +
    +
  1. Search by booking reference or ticket number
  2. +
  3. Filter by validation status
  4. +
+
+ +
    +
  1. Click ticket to view details
  2. +
  3. Click {`"Download PDF"`} for printable version
  4. +
+
+
+
+ + {/* STATIONS */} +
+

🏢 Stations

+

Configure railway stations with locations and timezones.

+
+ +
+

🏢 How-To: Manage Stations

+
+ +
    +
  1. Click {`"Stations"`} in Master Data
  2. +
  3. View all configured stations
  4. +
+
+ +
    +
  1. Click {`"Add Station"`}
  2. +
  3. Enter code, name, city, timezone, coordinates
  4. +
+
+ +
    +
  1. Click station to open details
  2. +
  3. Update information and save
  4. +
+
+
+
+ + {/* TRAINS */} +
+

🚂 Trains

+

Manage train fleet with coach assignments.

+
+ +
+

🚂 How-To: Manage Trains

+
+ +
    +
  1. Click {`"Trains"`} in Master Data
  2. +
  3. View all trains and coaches
  4. +
+
+ +
    +
  1. Click {`"Add Train"`}
  2. +
  3. Enter code and select coaches
  4. +
+
+ +
    +
  1. Click train to edit
  2. +
  3. Add/remove coaches with position numbers
  4. +
+
+
+
+ + {/* COACHES */} +
+

🚃 Coaches

+

Manage coach inventory with seat configurations.

+
+ +
+

🚃 How-To: Manage Coaches

+
+ +
    +
  1. Click "Coaches" in Master Data
  2. +
  3. View all coaches and assignments
  4. +
+
+ +
    +
  1. Click "Add Coach"
  2. +
  3. Enter code, select train, define seat layout
  4. +
+
+ +
    +
  1. Click coach to edit
  2. +
  3. Add seats and assign classes
  4. +
+
+
+
+ + {/* SEATS */} +
+

💺 Seats

+

Manage seat inventory with visual maps.

+
+ +
+

💺 How-To: Manage Seats

+
+ +
    +
  1. Go to "Seats" in Master Data
  2. +
  3. Select coach from dropdown
  4. +
  5. Visual map shows: Green=Available, Red=Blocked
  6. +
+
+ +
    +
  1. Click available seat
  2. +
  3. Click "Block" and select reason
  4. +
+
+ +
    +
  1. Click blocked seat
  2. +
  3. Click "Unblock" to restore
  4. +
+
+
+
+ + {/* SEAT CLASSES */} +
+

🎯 Seat Classes

+

Define seat class types with pricing.

+
+ +
+

🎯 How-To: Manage Seat Classes

+
+ +
    +
  1. Click "Seat Classes" in Master Data
  2. +
  3. View all class types
  4. +
+
+ +
    +
  1. Click "Add Class"
  2. +
  3. Enter name, base fare, premium, insurance
  4. +
+
+ +
    +
  1. Click class to edit
  2. +
  3. Update fares and save
  4. +
+
+
+
+ + {/* ROUTES */} +
+

🛤️ Routes

+

Define railway routes with ordered stops.

+
+ +
+

🛤️ How-To: Manage Routes

+
+ +
    +
  1. Click "Routes" in Master Data
  2. +
  3. View all routes and stops
  4. +
+
+ +
    +
  1. Click "Add Route"
  2. +
  3. Enter code and description
  4. +
+
+ +
    +
  1. Click route to edit
  2. +
  3. Click "Add Stop" and select station
  4. +
+
+
+
+ + {/* SCHEDULES */} +
+

📅 Schedules

+

Create and manage train schedules.

+
+ +
+

📅 How-To: Create Schedules

+
+ +
    +
  1. Go to "Schedules" in Master Data
  2. +
  3. Click "Create Schedule"
  4. +
  5. Fill train, route, departure/arrival times
  6. +
+
+ +
    +
  1. Click "Bulk Generate"
  2. +
  3. Set recurring parameters and generate
  4. +
+
+ +
    +
  1. Click schedule to edit
  2. +
  3. Update times and view fares
  4. +
+
+
+
+ + {/* PRICING */} +
+

💰 Pricing & Fares

+

Configure dynamic pricing with segments.

+
+ +
+

💰 How-To: Configure Pricing

+
+ +
    +
  1. Click "Pricing & Fares" in Financial
  2. +
  3. Two tabs: Schedule Fares, Segment Fares
  4. +
+
+ +
    +
  1. Click "Add Fare Rule"
  2. +
  3. Fill schedule, seat class, fare, nationality
  4. +
+
+ +
    +
  1. Switch to "Segment Fares" tab
  2. +
  3. Select route and add origin/destination fare
  4. +
+
+
+
+ + {/* CURRENCIES */} +
+

💵 Currencies

+

Manage exchange rates for multiple currencies.

+
+ +
+

💵 How-To: Manage Currencies

+
+ +
    +
  1. Click "Currencies" in Financial
  2. +
  3. View all configured rates
  4. +
+
+ +
    +
  1. Click "Add Rate"
  2. +
  3. Select currency and enter exchange rate
  4. +
+
+ +
    +
  1. Click rate to edit
  2. +
  3. Click "Sync" to update from provider
  4. +
+
+
+
+ + {/* PAYMENTS */} +
+

💳 Payments

+

Monitor and process transactions.

+
+ +
+

💳 How-To: Manage Payments

+
+ +
    +
  1. Click "Payments" in Financial
  2. +
  3. View all transactions
  4. +
+
+ +
    +
  1. Search by booking or transaction ID
  2. +
  3. Filter by status and payment method
  4. +
+
+ +
    +
  1. Click transaction
  2. +
  3. Click "Refund" if eligible
  4. +
+
+
+
+ + {/* PROMOS */} +
+

🎁 Promo Codes

+

Create and manage promotional campaigns.

+
+ +
+

🎁 How-To: Manage Promo Codes

+
+ +
    +
  1. Click "Promo Codes" in Financial
  2. +
  3. View all active codes
  4. +
+
+ +
    +
  1. Click "Add Promo Code"
  2. +
  3. Enter code, discount type, validity dates
  4. +
+
+ +
    +
  1. Click code to view analytics
  2. +
  3. View usage count and savings
  4. +
+
+
+
+ + {/* LOYALTY */} +
+

🏆 Loyalty

+

Manage loyalty program and rewards.

+
+ +
+

🏆 How-To: Manage Loyalty

+
+ +
    +
  1. Click "Loyalty Program" in Services
  2. +
  3. View all loyalty accounts
  4. +
+
+ +
    +
  1. Click account
  2. +
  3. Click "Adjust Points" and enter amount
  4. +
+
+ +
    +
  1. Click account
  2. +
  3. Click "Grant Reward" and select reward
  4. +
+
+
+
+ + {/* SUPPORT */} +
+

💬 Support

+

Manage support tickets and conversations.

+
+ +
+

💬 How-To: Manage Support

+
+ +
    +
  1. Click "Support Center" in Services
  2. +
  3. View all support tickets
  4. +
+
+ +
    +
  1. Click ticket to open conversation
  2. +
  3. Add replies and update status
  4. +
+
+ +
    +
  1. Go to FAQ management
  2. +
  3. Add or edit FAQ articles
  4. +
+
+
+
+ + {/* NOTIFICATIONS */} +
+

🔔 Notifications

+

Send notifications via multiple channels.

+
+ +
+

🔔 How-To: Manage Notifications

+
+ +
    +
  1. Click "Notifications" in Services
  2. +
  3. View notification history
  4. +
+
+ +
    +
  1. Click "Send Notification"
  2. +
  3. Select channel and message
  4. +
+
+ +
    +
  1. Go to Templates section
  2. +
  3. Create or edit templates with variables
  4. +
+
+
+
+ + {/* AUDIT */} +
+

📋 Audit Logs

+

Monitor system activities and user actions.

+
+ +
+

📋 How-To: View Audit Logs

+
+ +
    +
  1. Click "Audit Logs" in Security
  2. +
  3. View all recorded activities
  4. +
+
+ +
    +
  1. Filter by user, action, or date
  2. +
  3. Search by entity ID
  4. +
+
+ +
    +
  1. Click log entry for details
  2. +
  3. Click "Export" to download CSV
  4. +
+
+
+
+ + {/* FRAUD */} +
+

🛡️ Fraud Detection

+

Monitor and manage fraud alerts.

+
+ +
+

🛡️ How-To: Manage Fraud Detection

+
+ +
    +
  1. Click "Fraud Detection" in Security
  2. +
  3. View all fraud alerts
  4. +
+
+ +
    +
  1. Click alert to view details
  2. +
  3. Review triggered rules and patterns
  4. +
+
+ +
    +
  1. Click "Allow" or "Block" with notes
  2. +
  3. Update user status
  4. +
+
+
+
+ + {/* VERIFAYDA */} +
+

✅ Verifayda

+

Verify passenger identities against government database.

+
+ +
+

✅ How-To: Manage Verifayda

+
+ +
    +
  1. Click "Verifayda Integration" in Security
  2. +
  3. View verification history
  4. +
+
+ +
    +
  1. Enter national ID or passport number
  2. +
  3. Click "Verify" to check database
  4. +
+
+ +
    +
  1. View verified passenger data
  2. +
  3. Match with booking details
  4. +
+
+
+
+ + {/* REPORTS */} +
+

📊 Reports

+

Generate business analytics and reports.

+
+ +
+

📊 How-To: Generate Reports

+
+ +
    +
  1. Click "Reports" in Analytics
  2. +
  3. View available report types
  4. +
+
+ +
    +
  1. Click report type
  2. +
  3. Select date range and parameters
  4. +
+
+ +
    +
  1. View report with charts
  2. +
  3. Click "Export" for PDF or CSV
  4. +
+
+
+
+ + {/* AGENTS */} +
+

👤 Agents

+

Manage booking agents and commissions.

+
+ +
+

👤 How-To: Manage Agents

+
+ +
    +
  1. Click "Agents" in System
  2. +
  3. View all agents
  4. +
+
+ +
    +
  1. Click "Add Agent"
  2. +
  3. Enter name, email, commission rate
  4. +
+
+ +
    +
  1. Click agent to edit
  2. +
  3. Click "Create Shift" to assign schedule
  4. +
+
+
+
+ + {/* USERS */} +
+

👥 Users

+

Manage backoffice user accounts and permissions.

+
+ +
+

👥 How-To: Manage Users

+
+ +
    +
  1. Click "Users" in System
  2. +
  3. View all user accounts
  4. +
+
+ +
    +
  1. Click "Add User"
  2. +
  3. Enter email, name, select role
  4. +
+
+ +
    +
  1. Click user to edit
  2. +
  3. Adjust roles and permissions
  4. +
+
+
+
+ + {/* SETTINGS */} +
+

⚙️ Settings

+

Configure system-wide settings and integrations.

+
+ +
+

⚙️ How-To: Configure Settings

+
+ +
    +
  1. Click "Settings" in System
  2. +
  3. View configuration options
  4. +
+
+ +
    +
  1. Go to Email tab
  2. +
  3. Enter SendGrid API key and email
  4. +
+
+ +
    +
  1. Go to API tab
  2. +
  3. Add payment and Verifayda keys
  4. +
+
+
+
+
+
+ + + +
+
+

© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0

+
+
+ + ); +}; + +export default DocPage; diff --git a/apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx b/apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx new file mode 100644 index 000000000..20cdd419a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx @@ -0,0 +1,537 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react'; + +const HowToPage = () => { + 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 guides = [ + { id: 'bookings', title: 'How to Manage Bookings', icon: '📋' }, + { id: 'passengers', title: 'How to Manage Passengers', icon: '👥' }, + { id: 'pricing', title: 'How to Configure Pricing', icon: '💰' }, + { id: 'schedules', title: 'How to Create Schedules', icon: '📅' }, + { id: 'seats', title: 'How to Manage Seats', icon: '💺' }, + { id: 'loyalty', title: 'How to Manage Loyalty', icon: '🏆' }, + ]; + + return ( +
+
+
+
+ +
+

How-To Guides

+

Step-by-step instructions for common tasks

+
+
+ + + Back to Docs + +
+
+ +
+
+
+
+ +
+
+ +
+
+ + {/* Bookings How-To */} +
+

📋 How to Manage Bookings

+

Learn how to search, view, modify, and cancel passenger bookings in the system.

+ +
+
+
+
1
+
+

Access the Bookings Page

+
    +
  1. Click on "Bookings" in the Operations section of the sidebar
  2. +
  3. The page loads showing a table with all bookings
  4. +
  5. You'll see columns: Reference, Passenger, Status, Amount, Payment, Created date
  6. +
+
+

📍 Path: Sidebar → Operations → Bookings

+
+
+
+
+ +
+
+
2
+
+

Search for a Booking

+
    +
  1. Find the search box at the top of the booking table
  2. +
  3. Type in: booking reference (e.g., "BK123"), email, or phone number
  4. +
  5. Results update in real-time as you type
  6. +
  7. Optional: Use the Status dropdown to filter (All, Pending Payment, Confirmed, Cancelled, Completed)
  8. +
+
+

💡 Tip: Search is case-insensitive and supports partial matches

+
+
+
+
+ +
+
+
3
+
+

View Booking Details

+
    +
  1. Find the booking in the table
  2. +
  3. Click the "View Details" button on the right side
  4. +
  5. Modal window opens showing complete information: +
      +
    • Booking reference and status
    • +
    • Passenger name and contact details
    • +
    • Journey information (schedule, adults, children)
    • +
    • Payment details and amount
    • +
    • All metadata and timestamps
    • +
    +
  6. +
+
+
+
+ +
+
+
4
+
+

Cancel a Booking with Refund

+
    +
  1. Find the booking in the table
  2. +
  3. Click the "Cancel Booking" button (red)
  4. +
  5. Confirmation dialog appears
  6. +
  7. Click "Confirm" to proceed
  8. +
  9. System calculates and processes refund: +
      +
    • Confirmed bookings: 80% refund
    • +
    • Pending bookings: 0% refund
    • +
    +
  10. +
  11. Status changes to "CANCELLED"
  12. +
  13. Success message appears
  14. +
+
+

⚠️ Important: Cannot be undone. Seats are automatically released.

+
+
+
+
+ +
+
+
5
+
+

Export Bookings

+
    +
  1. Click the "Export" button (top-right)
  2. +
  3. CSV file downloads automatically
  4. +
  5. Includes all current filters applied
  6. +
  7. Use for external analysis or backup
  8. +
+
+
+
+
+
+ + {/* Passengers How-To */} +
+

👥 How to Manage Passengers

+

Learn how to search, filter, and view passenger profiles with loyalty and verification data.

+ +
+
+
+
1
+
+

Access Passengers Page

+
    +
  1. Click "Passengers" in the Operations section
  2. +
  3. Page displays all passenger profiles
  4. +
  5. Default view shows 20 passengers per page
  6. +
+
+
+
+ +
+
+
2
+
+

Search & Filter

+
+
+

Search by:

+
    +
  • Full name
  • +
  • Email address
  • +
  • Phone number
  • +
  • National ID
  • +
+
+
+

Filter by:

+
    +
  • Nationality: Ethiopian, Djiboutian, Other
  • +
  • Verifayda Status: Verified, Unverified, Pending
  • +
  • Loyalty Tier: Bronze, Silver, Gold, Platinum
  • +
+
+
+
+
+
+ +
+
+
3
+
+

View Complete Profile

+
    +
  1. Click on any passenger row
  2. +
  3. Detailed profile modal opens showing: +
      +
    • Account info (email, phone, nationality)
    • +
    • Verifayda verification status
    • +
    • Loyalty tier and points
    • +
    • Wallet balance
    • +
    • Booking history with links
    • +
    +
  4. +
+
+

ℹ️ Note: Read-only view. Updates via passenger portal.

+
+
+
+
+
+
+ + {/* Pricing How-To */} +
+

💰 How to Configure Pricing

+

Learn how to set up dynamic fares with segment pricing and nationality overrides.

+ +
+
+
+
1
+
+

Access Pricing Page

+
    +
  1. Click "Pricing & Fares" in Financial section
  2. +
  3. Two tabs: Schedule Fares and Segment Fares
  4. +
  5. Default tab shows Schedule Fares
  6. +
+
+
+
+ +
+
+
2
+
+

Create Schedule Fare Rule

+
    +
  1. Click "Add Fare Rule"
  2. +
  3. Fill in form: +
      +
    • Schedule (optional): Leave empty for global
    • +
    • Route Code (optional): e.g., "ADD-DJI"
    • +
    • Seat Class (required): Economy Regular, VIP Bed, etc.
    • +
    • Fare in ETB (required): e.g., 350.00
    • +
    • Passenger Type (optional): ADULT or CHILD
    • +
    • Nationality (optional): Ethiopian, Djiboutian, Other
    • +
    • Valid From & Until: Set date range
    • +
    +
  4. +
  5. Click "Save Fare Rule"
  6. +
+
+
+
+ +
+
+
3
+
+

Create Segment Fare Rule

+
    +
  1. Click "Add Fare Rule"
  2. +
  3. Switch to "Segment Fares" tab
  4. +
  5. Select route from dropdown
  6. +
  7. Fill in form: +
      +
    • Origin Station (required): Starting point
    • +
    • Destination Station (required): Must be after origin
    • +
    • Seat Class (required): Class type
    • +
    • Fare in ETB (required): Segment price
    • +
    +
  8. +
  9. Click "Save Segment Fare Rule"
  10. +
+
+

Example: ADD (Stop 1) to DDA (Stop 4) at 250 ETB

+
+
+
+
+
+
+ + {/* Schedules How-To */} +
+

📅 How to Create Schedules

+

Learn how to create schedules manually or in bulk with recurring patterns.

+ +
+
+
+
1
+
+

Create Single Schedule

+
    +
  1. Go to Schedules page (Master Data)
  2. +
  3. Click "Create Schedule"
  4. +
  5. Fill in required fields: +
      +
    • Train: Select from dropdown
    • +
    • Route: Select from dropdown
    • +
    • Departure Date & Time: Pick from date/time picker
    • +
    • Arrival Date & Time: Must be after departure
    • +
    +
  6. +
  7. Select coaches to assign
  8. +
  9. Click "Create Schedule"
  10. +
+
+
+
+ +
+
+
2
+
+

Bulk Generate Recurring Schedules

+
    +
  1. Click "Bulk Generate" button
  2. +
  3. Fill in generation form: +
      +
    • Train (required): Select train
    • +
    • Route (required): Select route
    • +
    • Start Date & Time (required): First departure
    • +
    • Duration (Hours): Trip length
    • +
    • Repeat Every (Days): Daily or custom
    • +
    • For Next (Days): How many days
    • +
    +
  4. +
  5. Review preview showing number of schedules
  6. +
  7. Click "Generate Schedules"
  8. +
+
+

Example: 30 days ÷ 1 day = ~30 daily schedules

+
+
+
+
+
+
+ + {/* Seats How-To */} +
+

💺 How to Manage Seats

+

Learn how to view, block, and manage seat inventory using visual seat maps.

+ +
+
+
+
1
+
+

View Seat Map

+
    +
  1. Go to Seats page (Master Data)
  2. +
  3. Select a coach from dropdown
  4. +
  5. Visual seat map displays
  6. +
  7. Color-coded by status: +
      +
    • 🟢 Green: Available
    • +
    • 🟡 Yellow: Held
    • +
    • 🔵 Blue: Booked
    • +
    • 🔴 Red: Blocked
    • +
    +
  8. +
+
+
+
+ +
+
+
2
+
+

Block a Seat

+
    +
  1. Click on an available (green) seat
  2. +
  3. Click "Block" button
  4. +
  5. Select reason: +
      +
    • Maintenance
    • +
    • Reserved
    • +
    • Damaged
    • +
    +
  6. +
  7. Set until date (optional)
  8. +
  9. Add notes
  10. +
  11. Click "Block Seat"
  12. +
  13. Seat turns red
  14. +
+
+
+
+ +
+
+
3
+
+

Unblock a Seat

+
    +
  1. Click on a blocked (red) seat
  2. +
  3. Click "Unblock" button
  4. +
  5. Confirm action
  6. +
  7. Seat becomes available (green)
  8. +
+
+
+
+
+
+ + {/* Loyalty How-To */} +
+

🏆 How to Manage Loyalty Program

+

Learn how to view loyalty accounts, manage points, and administer rewards.

+ +
+
+
+
1
+
+

View Loyalty Accounts

+
    +
  1. Go to Loyalty Program (Customer Services)
  2. +
  3. Table displays all loyalty accounts
  4. +
  5. Columns: Name, Tier, Points Balance, Lifetime Points
  6. +
  7. Search by name or filter by tier
  8. +
+
+
+
+ +
+
+
2
+
+

Adjust Points

+
    +
  1. Click on a loyalty account
  2. +
  3. Click "Adjust Points" button
  4. +
  5. Enter points to add/subtract
  6. +
  7. Select reason: Bonus, Correction, Promotion, etc.
  8. +
  9. Add optional notes
  10. +
  11. Click "Apply"
  12. +
  13. Balance updates immediately
  14. +
+
+
+
+ +
+
+
3
+
+

Award Rewards

+
    +
  1. Click on a loyalty account
  2. +
  3. Click "Grant Reward" button
  4. +
  5. Select reward from list
  6. +
  7. Specify quantity if applicable
  8. +
  9. Click "Award"
  10. +
  11. Confirmation email sent to passenger
  12. +
+
+
+
+
+
+ + {/* Common Tips */} +
+

💡 Common Tips & Tricks

+
    +
  • Keyboard Shortcuts: Tab to navigate, Enter to submit
  • +
  • Pagination: Change page size or jump to specific page
  • +
  • Sidebar Collapse: Use chevron to minimize sidebar
  • +
  • Dark Mode: Toggle with sun/moon icon in header
  • +
  • Error Messages: Red text above forms if validation fails
  • +
  • Success Notifications: Green banner appears for 3 seconds
  • +
  • Undo Not Available: Most actions cannot be undone
  • +
  • Real-time Updates: Refresh page to see changes by other users
  • +
+
+
+
+
+
+ +
+
+

© 2026 Ethio-Djibouti Railway | How-To Guides v1.0

+

Last Updated: January 15, 2026

+
+
+
+ ); +}; + +export default HowToPage; diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 4a6138f5a..fe0917aa3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -1,17 +1,25 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; -import { Train } from 'lucide-react'; +import { useTheme } from '@/lib/theme-store'; +import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isMounted, setIsMounted] = useState(false); const router = useRouter(); const { login } = useAuthStore(); + const { isDark, toggleTheme } = useTheme(); + + useEffect(() => { + setIsMounted(true); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -29,77 +37,109 @@ export default function LoginPage() { } }; + if (!isMounted) { + return null; + } + return ( -
- {/* Banner Image Side */} -
-
-
-
-
- +
+ {/* Full Screen Banner Background */} +
+ + {/* Content Overlay */} +
+
+ {/* Login Card with Shadow */} +
+ {/* Card Header with Logo, App Name and Theme Toggle */} +
+
+
+ +
+
+

Ethio-Djibouti Railway

+

Passenger Back-office

+
+
+ + +
+ + {/* Card Body */} +
+
+

Welcome back!

+

Sign in to continue.

+
+ + {error && ( +
+ {error} +
+ )} + + +
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" + placeholder="name@email.com" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" + placeholder="••••••••" + required + /> + +
+
+ + +
-

EDR

-

Passenger Back-office

- - {/* Login Form Side */} -
-
-
-
-
-
- -
-
EDR
-
-

Sign in to get started.

-
- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - className="input" - required - /> -
- -
- - setPassword(e.target.value)} - className="input" - required - /> -
- - - - -
-
-
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx index 339cd591f..29f36dd0a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -2,64 +2,467 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye, Plus } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; import { reportsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; -export default function OperationalreportsPage() { +export default function OperationalReportsPage() { const [filters, setFilters] = useState({ search: '', reportType: '' }); - - const { data, isLoading } = useQuery({ - queryKey: ['operational-reports', filters], - queryFn: () => reportsApi.getOperationalReports(filters), + const [selectedReport, setSelectedReport] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [showGenerateModal, setShowGenerateModal] = useState(false); + const [generateForm, setGenerateForm] = useState({ + reportType: 'REVENUE', + dateFrom: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + dateTo: new Date().toISOString().split('T')[0], }); + const { data, isLoading, refetch } = useQuery({ + queryKey: ['operational-reports', filters], + queryFn: () => reportsApi.listReports(filters.reportType || undefined), + }); + + const handleGenerateReport = async () => { + try { + await reportsApi.generateReport(generateForm); + refetch(); + setShowGenerateModal(false); + } catch (error) { + console.error('Error generating report:', error); + } + }; + + const getReportTypeBadgeColor = (type: string) => { + switch (type) { + case 'REVENUE': + return 'success'; + case 'OCCUPANCY': + return 'primary'; + case 'PERFORMANCE': + return 'info'; + case 'AGENT_SALES': + return 'secondary'; + default: + return 'secondary'; + } + }; + + const formatReportType = (type: string) => { + const typeMap: { [key: string]: string } = { + REVENUE: 'Revenue Report', + OCCUPANCY: 'Occupancy Report', + PERFORMANCE: 'Performance Report', + AGENT_SALES: 'Agent Sales Report', + CANCELLATIONS: 'Cancellations Report', + PAYMENT_METHODS: 'Payment Methods Report', + }; + return typeMap[type] || type; + }; + const columns = [ - { key: 'reportType', label: 'Type', render: (report: any) => {report.reportType} }, - { key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' }, - { key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' }, - { key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) }, - ]; + { + key: 'reportType', + label: 'Report Type', + sortable: true, + render: (report: any) => ( + + {formatReportType(report.reportType)} + + ), + }, + { + key: 'dateFrom', + label: 'Period From', + sortable: true, + render: (report: any) => ( + {new Date(report.dateFrom).toLocaleDateString()} + ), + }, + { + key: 'dateTo', + label: 'Period To', + sortable: true, + render: (report: any) => ( + {new Date(report.dateTo).toLocaleDateString()} + ), + }, + { + key: 'data', + label: 'Summary', + render: (report: any) => { + const data = report.data || {}; + if (report.reportType === 'REVENUE') { + return ( +
+

{formatCurrency(data.totalRevenueMinor || 0, 'ETB')}

+

{data.totalBookings || 0} bookings

+
+ ); + } + if (report.reportType === 'OCCUPANCY') { + return ( +
+

{(data.averageOccupancyRate || 0).toFixed(1)}% occupancy

+

{data.totalSchedules || 0} schedules

+
+ ); + } + if (report.reportType === 'AGENT_SALES') { + return ( +
+

{data.totalAgentBookings || 0} bookings

+

{Object.keys(data.byAgent || {}).length} agents

+
+ ); + } + if (report.reportType === 'CANCELLATIONS') { + return ( +
+

{data.totalCancellations || 0} cancellations

+

Refunded: {formatCurrency(data.totalRefundedMinor || 0, 'ETB')}

+
+ ); + } + if (report.reportType === 'PAYMENT_METHODS') { + return ( +
+

{data.totalPayments || 0} payments

+

{Object.keys(data.byMethod || {}).length} methods

+
+ ); + } + return View details; + }, + }, + { + key: 'createdAt', + label: 'Generated', + sortable: true, + render: (report: any) => ( + {formatDateTime(report.createdAt)} + ), + }, + ]; + + const actions = [ + { + label: 'View Details', + onClick: (report: any) => { + setSelectedReport(report); + setShowDetailsModal(true); + }, + variant: 'secondary' as const, + icon: Eye, + }, + ]; + + const reports = data?.items || data || []; return (
-

Operational Reports

-

View operational reports and analytics

+

Operational Reports

+

View and analyze operational performance

+
+
+ setShowGenerateModal(true)}> + Generate Report + + + Export All +
- Export
+ {/* Filters */}
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + +
+
+ setFilters({ search: '', reportType: '' })} + className="w-full" + > + Clear Filters + +
+ {/* Reports Table */} + + {/* Generate Report Modal */} + setShowGenerateModal(false)} + title="Generate Report" + size="sm" + > +
+
+ + +
+
+ + setGenerateForm({ ...generateForm, dateFrom: e.target.value })} + /> +
+
+ + setGenerateForm({ ...generateForm, dateTo: e.target.value })} + /> +
+
+ + Generate + + setShowGenerateModal(false)} + className="flex-1" + > + Cancel + +
+
+
+ + {/* Details Modal */} + { + setShowDetailsModal(false); + setSelectedReport(null); + }} + title={formatReportType(selectedReport?.reportType)} + size="lg" + > +
+ {/* Report Header */} +
+
+ +

{formatReportType(selectedReport?.reportType)}

+
+
+ +

{formatDateTime(selectedReport?.createdAt)}

+
+
+ +

{new Date(selectedReport?.dateFrom).toLocaleDateString()}

+
+
+ +

{new Date(selectedReport?.dateTo).toLocaleDateString()}

+
+
+ + {/* Revenue Report Data */} + {selectedReport?.reportType === 'REVENUE' && ( +
+

Revenue Metrics

+
+
+

Total Revenue

+

+ {formatCurrency(selectedReport?.data?.totalRevenueMinor || 0, 'ETB')} +

+
+
+

Total Bookings

+

+ {(selectedReport?.data?.totalBookings || 0).toLocaleString()} +

+
+
+ {selectedReport?.data?.byPaymentMethod && ( +
+

By Payment Method

+
+ {Object.entries(selectedReport.data.byPaymentMethod).map(([method, amount]: [string, any]) => ( +
+ {method.toLowerCase().replace('_', ' ')} + {formatCurrency(amount, 'ETB')} +
+ ))} +
+
+ )} +
+ )} + + {/* Occupancy Report Data */} + {selectedReport?.reportType === 'OCCUPANCY' && ( +
+

Occupancy Metrics

+
+
+

Avg Occupancy Rate

+

+ {(selectedReport?.data?.averageOccupancyRate || 0).toFixed(1)}% +

+
+
+

Total Schedules

+

+ {(selectedReport?.data?.totalSchedules || 0).toLocaleString()} +

+
+
+
+ )} + + {/* Agent Sales Report Data */} + {selectedReport?.reportType === 'AGENT_SALES' && ( +
+

Agent Sales Metrics

+
+
+

Total Bookings

+

+ {(selectedReport?.data?.totalAgentBookings || 0).toLocaleString()} +

+
+
+

Active Agents

+

+ {Object.keys(selectedReport?.data?.byAgent || {}).length} +

+
+
+ {selectedReport?.data?.byAgent && ( +
+

By Agent

+
+ {Object.entries(selectedReport.data.byAgent).map(([agent, stats]: [string, any]) => ( +
+

{agent}

+
+

Bookings: {stats.bookings} | Revenue: {formatCurrency(stats.revenueMinor, 'ETB')}

+
+
+ ))} +
+
+ )} +
+ )} + + {/* Cancellations Report Data */} + {selectedReport?.reportType === 'CANCELLATIONS' && ( +
+

Cancellation Metrics

+
+
+

Total Cancellations

+

+ {(selectedReport?.data?.totalCancellations || 0).toLocaleString()} +

+
+
+

Total Refunded

+

+ {formatCurrency(selectedReport?.data?.totalRefundedMinor || 0, 'ETB')} +

+
+
+
+ )} + + {/* Payment Methods Report Data */} + {selectedReport?.reportType === 'PAYMENT_METHODS' && ( +
+

Payment Method Breakdown

+
+

Total Payments

+

+ {(selectedReport?.data?.totalPayments || 0).toLocaleString()} +

+
+ {selectedReport?.data?.byMethod && ( +
+ {Object.entries(selectedReport.data.byMethod).map(([method, stats]: [string, any]) => ( +
+
+

{method.toLowerCase().replace('_', ' ')}

+

{stats.count} transactions

+
+

{formatCurrency(stats.totalMinor, 'ETB')}

+
+ ))} +
+ )} +
+ )} + + {/* Report ID */} +
+ +

{selectedReport?.id}

+
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index 36db98a34..2c8e50a0e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -52,6 +52,44 @@ export default function PassengersPage() { console.error('Passengers API Error:', error); } + const handleExportPassengers = async () => { + const selectedColumns = prompt( + 'Select columns to export (comma-separated):\n\n' + + 'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' + + 'Default: fullName, email, phone, gender, nationality, verified', + 'fullName, email, phone, gender, nationality, verified' + ); + + if (!selectedColumns) return; + + const cols = selectedColumns.split(',').map(c => c.trim()); + const csv = [ + cols.join(','), + ...data?.items?.map((passenger: any) => { + const values = cols.map(col => { + switch(col) { + case 'fullName': return passenger.fullName; + case 'email': return passenger.email || ''; + case 'phone': return passenger.phone || ''; + case 'dateOfBirth': return passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : ''; + case 'gender': return passenger.gender || ''; + case 'nationality': return passenger.nationality || ''; + case 'verified': return passenger.nationalId ? 'Yes' : 'No'; + 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 = `passengers-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + }; + const columns = [ { key: 'fullName', @@ -67,16 +105,25 @@ export default function PassengersPage() { { key: 'phone', label: 'Phone', + sortable: true, render: (passenger: any) => passenger.phone, }, { - key: 'nationalId', - label: 'National ID', - render: (passenger: any) => passenger.nationalId || 'N/A', + key: 'gender', + label: 'Gender', + sortable: true, + render: (passenger: any) => passenger.gender || 'N/A', + }, + { + key: 'nationality', + label: 'Nationality', + sortable: true, + render: (passenger: any) => passenger.nationality || 'N/A', }, { key: 'dateOfBirth', label: 'Date of Birth', + sortable: true, render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A', }, { @@ -113,7 +160,7 @@ export default function PassengersPage() {

Manage passenger profiles and verification

- Export + Export
@@ -230,10 +277,6 @@ export default function PassengersPage() {

Identification

-
- -

{selectedPassenger.nationalId || 'N/A'}

-

{selectedPassenger.passportNumber || 'N/A'}

diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index b27f559f8..a2dcae6aa 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -50,6 +50,7 @@ export default function PricingPage() { seatClassId: '', baseFare: '', nationality: '', + passengerCategory: '', route: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', @@ -61,6 +62,7 @@ export default function PricingPage() { destinationStationId: '', baseFare: '', nationality: '', + passengerCategory: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); @@ -87,7 +89,17 @@ export default function PricingPage() { const { data: fares = [], isLoading: faresLoading, refetch: refetchFares } = useQuery({ queryKey: ['schedule-fares', selectedSchedule], - queryFn: () => (selectedSchedule ? apiClient.get(`/schedules/${selectedSchedule}/fares/all`) : Promise.resolve([])), + queryFn: async () => { + if (!selectedSchedule) return []; + try { + const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/all`); + return Array.isArray(response) ? response : (response as any)?.data || []; + } catch (err: any) { + const errMsg = err.response?.data?.message || err.message || 'Failed to load fares'; + setError(`Error loading fares: ${errMsg}`); + return []; + } + }, enabled: !!selectedSchedule && tab === 'schedule', }); @@ -174,6 +186,7 @@ export default function PricingPage() { seatClassId: '', baseFare: '', nationality: '', + passengerCategory: '', route: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', @@ -189,6 +202,7 @@ export default function PricingPage() { destinationStationId: '', baseFare: '', nationality: '', + passengerCategory: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); @@ -198,13 +212,11 @@ export default function PricingPage() { const handleEditFare = (fare: any) => { setEditingFare(fare); - const fareValue = fare.baseFare || fare.baseFareMinor || 0; - const etbValue = fareValue > 100 ? (fareValue / 100).toString() : fareValue.toString(); - setFareForm({ seatClassId: fare.seatClassId || '', - baseFare: etbValue, + baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(), nationality: fare.nationality || '', + passengerCategory: fare.passengerCategory || '', route: fare.route || '', validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0], validUntil: fare.validUntil ? new Date(fare.validUntil).toISOString().split('T')[0] : '', @@ -215,9 +227,6 @@ export default function PricingPage() { const handleEditSegmentFare = (fare: any) => { setEditingFare(fare); - const fareValue = fare.baseFare || fare.baseFareMinor || 0; - const etbValue = fareValue > 100 ? (fareValue / 100).toString() : fareValue.toString(); - const routeStops = currentRoute?.stops || []; const originStop = routeStops.find((s: any) => s.sequence === fare.originStopSequence); const destStop = routeStops.find((s: any) => s.sequence === fare.destinationStopSequence); @@ -226,8 +235,9 @@ export default function PricingPage() { seatClassId: fare.seatClassId || '', originStationId: originStop?.stationId || '', destinationStationId: destStop?.stationId || '', - baseFare: etbValue, + baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(), nationality: fare.nationality || '', + passengerCategory: fare.passengerCategory || '', validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0], validUntil: fare.validUntil ? new Date(fare.validUntil).toISOString().split('T')[0] : '', }); @@ -242,7 +252,7 @@ export default function PricingPage() { return; } - const baseFareMinor = Math.round(parseFloat(fareForm.baseFare) * 100); + const baseFareMinor = parseInt(fareForm.baseFare, 10); if (editingFare) { await updateFareMutation.mutateAsync({ @@ -250,6 +260,7 @@ export default function PricingPage() { seatClassId: fareForm.seatClassId, baseFareMinor, nationality: fareForm.nationality || undefined, + passengerCategory: fareForm.passengerCategory || undefined, route: fareForm.route || undefined, validFrom: fareForm.validFrom, validUntil: fareForm.validUntil || undefined, @@ -260,6 +271,7 @@ export default function PricingPage() { seatClassId: fareForm.seatClassId, baseFareMinor, nationality: fareForm.nationality || undefined, + passengerCategory: fareForm.passengerCategory || undefined, route: fareForm.route || undefined, validFrom: fareForm.validFrom, validUntil: fareForm.validUntil || undefined, @@ -288,7 +300,7 @@ export default function PricingPage() { return; } - const baseFareMinor = Math.round(parseFloat(segmentForm.baseFare) * 100); + const baseFareMinor = parseInt(segmentForm.baseFare, 10); if (editingFare) { await updateSegmentFareMutation.mutateAsync({ @@ -299,6 +311,7 @@ export default function PricingPage() { destinationStopSequence: destStop.sequence, baseFareMinor, nationality: segmentForm.nationality || undefined, + passengerCategory: segmentForm.passengerCategory || undefined, validFrom: segmentForm.validFrom, validUntil: segmentForm.validUntil || undefined, }); @@ -310,6 +323,7 @@ export default function PricingPage() { destinationStopSequence: destStop.sequence, baseFareMinor, nationality: segmentForm.nationality || undefined, + passengerCategory: segmentForm.passengerCategory || undefined, validFrom: segmentForm.validFrom, validUntil: segmentForm.validUntil || undefined, }); @@ -343,14 +357,20 @@ export default function PricingPage() { return {className}; }, }, + { + key: 'passengerCategory', + label: 'Passenger Type', + render: (fare: any) => ( + {fare.passengerCategory || 'All'} + ), + }, { key: 'baseFare', label: 'Fare (ETB)', render: (fare: any) => { const fareValue = fare.baseFare || fare.baseFareMinor; if (!fareValue && fareValue !== 0) return N/A; - const etbValue = fareValue > 100 ? (fareValue / 100).toFixed(2) : parseFloat(fareValue).toFixed(2); - return {etbValue} ETB; + return {fareValue} ETB; }, }, { @@ -408,14 +428,20 @@ export default function PricingPage() { return {className}; }, }, + { + key: 'passengerCategory', + label: 'Passenger Type', + render: (fare: any) => ( + {fare.passengerCategory || 'All'} + ), + }, { key: 'baseFare', label: 'Fare (ETB)', render: (fare: any) => { const fareValue = fare.baseFare || fare.baseFareMinor; if (!fareValue && fareValue !== 0) return N/A; - const etbValue = fareValue > 100 ? (fareValue / 100).toFixed(2) : parseFloat(fareValue).toFixed(2); - return {etbValue} ETB; + return {fareValue} ETB; }, }, { @@ -449,12 +475,14 @@ export default function PricingPage() { onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare, variant: 'secondary' as const, icon: Edit, + disabled: tab === 'schedule', // Schedule fares are computed, not stored }, { label: 'Delete', onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }), variant: 'danger' as const, icon: Trash2, + disabled: tab === 'schedule', // Schedule fares are computed, not stored }, ]; @@ -463,7 +491,7 @@ export default function PricingPage() {

Pricing & Fares

-

Manage fares by schedule and route segments

+

Manage fares by schedule and route segments with passenger type pricing

-

Fare Rules

+

Calculated Fares

+
+ These are dynamically calculated fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above. +
{faresLoading ? (
) : faresArray.length === 0 ? (
- {`No fares defined. Click "Add Fare Rule" to create one.`} + No fares available for this schedule.
) : ( <>
- {faresArray.length} fare rule(s) found + {faresArray.length} seat class(es) available
)} @@ -632,16 +665,16 @@ export default function PricingPage() {

Pricing Structure

  • - • Schedule Fares: Set custom pricing for each schedule by seat class + • Schedule Fares: Set custom pricing for each schedule by seat class and passenger type
  • Segment Fares: Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa)
  • - • Nationality-based: Override fares for specific nationalities + • Passenger Type: ADULT (5+ years) or CHILD (<5) — first child travels free, subsequent children pay full fare
  • - • Age-Based Pricing: ADULT (5+ years) pays 100%, CHILD (<5) first child FREE, subsequent children 100% + • Nationality-based: Override fares for specific nationalities (Ethiopian, Djiboutian, Other)
@@ -732,28 +765,44 @@ export default function PricingPage() { setFareForm({ ...fareForm, baseFare: e.target.value })} className="input w-full" - placeholder="e.g., 350.00" + placeholder="e.g., 350" required />
-
- - +
+
+ + +

Scope pricing to specific passenger type

+
+ +
+ + +
@@ -846,28 +895,44 @@ export default function PricingPage() { setSegmentForm({ ...segmentForm, baseFare: e.target.value })} className="input w-full" - placeholder="e.g., 150.00" + placeholder="e.g., 150" required />
-
- - +
+
+ + +

Scope pricing to specific passenger type

+
+ +
+ + +
@@ -900,7 +965,8 @@ export default function PricingPage() {
  • Schedule: Apply to specific schedule only
  • Route Code: Apply to all schedules on that route
  • -
  • Nationality: Override for specific passenger nationalities
  • +
  • Passenger Type: ADULT or CHILD pricing
  • +
  • Nationality: Override for specific nationalities
  • All empty: Apply globally to all schedules
)} @@ -908,6 +974,7 @@ export default function PricingPage() {
  • Segments: Define pricing for specific stop-to-stop segments
  • Stops: Use sequence numbers from the route
  • +
  • Passenger Type: ADULT or CHILD pricing
  • Nationality: Optional scope to specific nationalities
)} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx index 7f1f02abe..353b1fd3a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx @@ -1,124 +1,316 @@ 'use client'; import { useState } from 'react'; -import { Download, Calendar } from 'lucide-react'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; -import { formatCurrency } from '@/lib/utils'; +import { useQuery } from '@tanstack/react-query'; +import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react'; +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { bookingsApi } from '@/lib/api'; +import ActionButton from '@/components/ui/ActionButton'; -const revenueByRoute = [ - { route: 'Addis - Djibouti', revenue: 125000000 }, - { route: 'Addis - Dire Dawa', revenue: 85000000 }, - { route: 'Dire Dawa - Djibouti', revenue: 45000000 }, -]; - -const bookingsByClass = [ - { name: 'Economy Regular', value: 65, color: '#3b82f6' }, - { name: 'Economy Bed', value: 25, color: '#10b981' }, - { name: 'VIP Bed', value: 10, color: '#f59e0b' }, -]; - -const occupancyData = [ - { month: 'Jan', rate: 72 }, - { month: 'Feb', rate: 78 }, - { month: 'Mar', rate: 85 }, - { month: 'Apr', rate: 82 }, - { month: 'May', rate: 88 }, - { month: 'Jun', rate: 91 }, -]; +const COLORS = ['#3b82f6', '#10b981', '#f59e0b']; export default function ReportsPage() { - const [dateRange, setDateRange] = useState('last-30-days'); + const [dateRange, setDateRange] = useState('30'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const getDateRange = () => { + const end = new Date(); + end.setHours(23, 59, 59, 999); + const start = new Date(); + + switch (dateRange) { + case '7': + start.setDate(end.getDate() - 7); + break; + case '30': + start.setDate(end.getDate() - 30); + break; + case '90': + start.setDate(end.getDate() - 90); + break; + default: + if (startDate && endDate) { + return { startDate, endDate }; + } + } + + return { + startDate: start.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + }; + }; + + const dates = getDateRange(); + + // Fetch all bookings + const { data: bookingsData, isLoading } = useQuery({ + queryKey: ['all-bookings'], + queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), + }); + + // Filter bookings by date range + const bookings = Array.isArray(bookingsData?.items) + ? bookingsData.items.filter((b: any) => { + const bookingDate = new Date(b.createdAt).toISOString().split('T')[0]; + return bookingDate >= dates.startDate && bookingDate <= dates.endDate; + }) + : []; + + // Calculate metrics + const totalRevenue = bookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0); + const totalBookings = bookings.length; + const avgTicketPrice = totalBookings > 0 ? Math.round(totalRevenue / totalBookings) : 0; + + // Group by date for revenue chart + const byDate = bookings.reduce((acc: Record, b: any) => { + const date = new Date(b.createdAt).toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { totalMinor: 0, count: 0 }; + } + acc[date].totalMinor += b.totalMinor || 0; + acc[date].count += 1; + return acc; + }, {} as Record); + + const chartData = Object.entries(byDate) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, d]: [string, any]) => ({ + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + revenue: (d.totalMinor || 0) / 100, + bookings: d.count || 0, + })); return (
-
-
-

Reports & Analytics

-

View detailed reports and analytics

-
-
- - -
-
- -
-
-

Revenue by Route

- - - - - - formatCurrency(value, 'ETB')} /> - - - -
- -
-

Bookings by Class

- - - `${name}: ${value}%`} - outerRadius={100} - fill="#8884d8" - dataKey="value" - > - {bookingsByClass.map((entry, index) => ( - - ))} - - - - -
- -
-

Occupancy Rate Trend

- - - - - - `${value}%`} /> - - - -
+
+

Reports & Analytics

+

View detailed reports and performance metrics

+ {/* Date Range Selector */}
-

Quick Stats

-
-
-

Total Revenue

-

{formatCurrency(255000000, 'ETB')}

+
+
+ +
-
-

Total Bookings

-

1,247

+ + {dateRange === 'custom' && ( + <> +
+ + setStartDate(e.target.value)} + disabled={isLoading} + /> +
+
+ + setEndDate(e.target.value)} + disabled={isLoading} + /> +
+ + )} + + + Export + +
+ {isLoading && ( +

Loading...

+ )} +
+ + {/* Key Metrics */} +
+
+
+
+

Total Revenue

+

ETB {Math.round(totalRevenue / 100).toLocaleString()}

+

Last {dateRange} days

+
+
-
-

Avg. Ticket Price

-

{formatCurrency(42500, 'ETB')}

+
+ +
+
+
+

Total Bookings

+

{totalBookings.toLocaleString()}

+

All bookings

+
+
-
-

Cancellation Rate

-

3.2%

+
+ +
+
+
+

Avg. Ticket Price

+

ETB {(avgTicketPrice / 100).toLocaleString()}

+

Per booking

+
+ +
+
+ +
+
+
+

Avg. Daily Revenue

+

ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}

+

Daily average

+
+ +
+
+
+ + {/* Charts */} +
+ {/* Revenue Trend */} +
+

Revenue Trend

+ {chartData.length > 0 ? ( + + + + + + `ETB ${Math.round(value).toLocaleString()}`} /> + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Daily Bookings */} +
+

Daily Bookings

+ {chartData.length > 0 ? ( + + + + + + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Booking Status Distribution */} +
+

Booking Status

+ {bookings.length > 0 ? ( + + + b.status === 'CONFIRMED').length }, + { name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length }, + { name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length }, + { name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length }, + ].filter(d => d.value > 0)} + cx="50%" + cy="50%" + labelLine={false} + label={({ name, value }) => `${name}: ${value}`} + outerRadius={100} + dataKey="value" + > + {COLORS.map((color, idx) => )} + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Top Payment Methods */} +
+

Payment Methods

+ {bookings.length > 0 ? ( +
+ {(Object.entries( + bookings.reduce((acc: Record, b: any) => { + const method = b.paymentIntent?.method || 'Unknown'; + acc[method] = (acc[method] || 0) + 1; + return acc; + }, {} as Record) + ) as [string, number][] + ) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([method, count]) => ( +
+ {method.toLowerCase().replace(/_/g, ' ')} + {count} +
+ ))} +
+ ) : ( +
+ No data available +
+ )} +
+
+ + {/* Summary Stats */} +
+

Summary

+
+
+

Total Days with Bookings

+

{chartData.length}

+
+
+

Confirmed Bookings

+

{bookings.filter((b: any) => b.status === 'CONFIRMED').length}

+
+
+

Completed Bookings

+

{bookings.filter((b: any) => b.status === 'COMPLETED').length}

+
+
+

Cancelled Bookings

+

{bookings.filter((b: any) => b.status === 'CANCELLED').length}

diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 943b4fb5a..0f56f0e15 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -220,28 +220,15 @@ export default function RoutesPage() { setOriginStationId(routeStops[0].stationId); setDestinationStationId(routeStops[routeStops.length - 1].stationId); - // Calculate cumulative distance for destination - let cumulativeDistance = 0; - routeStops.forEach((stop: any, idx: number) => { - if (idx > 0) { - cumulativeDistance += stop.distanceKm || 0; - } - }); - setDestinationDistance(cumulativeDistance); - - // Calculate distance from origin for middle stops - const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => { - let distFromOrigin = 0; - for (let i = 1; i <= idx + 1; i++) { - distFromOrigin += routeStops[i].distanceKm || 0; - } - return { - stationId: stop.stationId, - sequence: stop.sequence, - distanceKm: stop.distanceKm, - distanceFromOrigin: distFromOrigin, - }; - }); + // Last stop's distanceKm is already cumulative from origin + setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0); + + const middleStops = routeStops.slice(1, -1).map((stop: any) => ({ + stationId: stop.stationId, + sequence: stop.sequence, + distanceKm: stop.distanceKm, + distanceFromOrigin: stop.distanceKm || 0, + })); setStops(middleStops); } setShowModal(true); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index a40fbbc02..e60de15c2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -40,6 +40,7 @@ interface Coach { number: string; coachNumber?: string; capacity: number; + sequence?: number; coachType?: { name: string }; } @@ -630,7 +631,22 @@ export default function SchedulesPage() {
- +
+ + +
{coaches.length === 0 ? (

No coaches available

@@ -656,7 +672,7 @@ export default function SchedulesPage() { className="rounded" /> - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) + Seq {coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) )) @@ -765,7 +781,22 @@ export default function SchedulesPage() {
- +
+ + +
{coaches.length === 0 ? (

No coaches available

@@ -791,7 +822,7 @@ export default function SchedulesPage() { className="rounded" /> - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) + Seq {coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) )) diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 62056e21a..ba561b24e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -1,18 +1,24 @@ 'use client'; import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { seatsApi, schedulesApi } from '@/lib/api'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { seatsApi, schedulesApi, fleetApi } from '@/lib/api'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' -import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react'; +import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react'; export default function SeatsPage() { const [selectedSchedule, setSelectedSchedule] = useState(''); + const [expandedCoaches, setExpandedCoaches] = useState>(new Set()); const [showBlockModal, setShowBlockModal] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false); const [selectedSeat, setSelectedSeat] = useState(null); const [blockReason, setBlockReason] = useState(''); + const [showBlockCoachModal, setShowBlockCoachModal] = useState(false); + const [selectedCoach, setSelectedCoach] = useState(null); + const [blockCoachReason, setBlockCoachReason] = useState(''); + const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false); + const [coachToUnblock, setCoachToUnblock] = useState(null); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -26,6 +32,11 @@ export default function SeatsPage() { enabled: !!selectedSchedule, }); + const { data: coachTypesData } = useQuery({ + queryKey: ['coachTypes'], + queryFn: () => fleetApi.getCoaches(), + }); + const blockMutation = useMutation({ mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), onSuccess: () => { @@ -62,6 +73,43 @@ export default function SeatsPage() { const schedules = schedulesData?.items || schedulesData?.data || []; const coaches = seatMapData?.coaches || []; + const blockCoachMutation = useMutation({ + mutationFn: async ({ coachId, reason }: any) => { + const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; + const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); + return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason }))); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + setShowBlockCoachModal(false); + setSelectedCoach(null); + setBlockCoachReason(''); + }, + }); + + const unblockCoachMutation = useMutation({ + mutationFn: async ({ coachId }: any) => { + const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; + const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); + return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId))); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + setShowUnblockCoachModal(false); + setCoachToUnblock(null); + }, + }); + + const toggleCoach = (coachId: string) => { + const newExpanded = new Set(expandedCoaches); + if (newExpanded.has(coachId)) { + newExpanded.delete(coachId); + } else { + newExpanded.add(coachId); + } + setExpandedCoaches(newExpanded); + }; + const handleBlock = (seat: any) => { setSelectedSeat(seat); setShowBlockModal(true); @@ -84,6 +132,43 @@ export default function SeatsPage() { } }; + const handleBlockCoach = (coach: any) => { + setSelectedCoach(coach); + setShowBlockCoachModal(true); + }; + + const handleUnblockCoach = (coach: any) => { + const isBlocked = coach.seats?.some((s: any) => s.status === 'BLOCKED' || s.isBlocked); + if (isBlocked) { + setCoachToUnblock(coach); + setShowUnblockCoachModal(true); + } + }; + + const confirmUnblockCoach = async () => { + if (coachToUnblock) { + await unblockCoachMutation.mutateAsync({ coachId: coachToUnblock.id }); + } + }; + + const isCoachBlocked = (coach: any) => { + const seats = (coach.seats || []).filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + return seats.length > 0 && seats.every((s: any) => s.status === 'BLOCKED' || s.isBlocked); + }; + + const isCoachUnblocked = (coach: any) => { + const seats = (coach.seats || []).filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + return seats.length > 0 && seats.every((s: any) => s.status !== 'BLOCKED' && !s.isBlocked); + }; + + const submitBlockCoach = async () => { + if (!blockCoachReason.trim()) { + alert('Please provide a reason for blocking'); + return; + } + await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason }); + }; + const submitBlock = async () => { if (!blockReason.trim()) { alert('Please provide a reason for blocking'); @@ -126,6 +211,12 @@ export default function SeatsPage() { return ''; }; + const formatBedSeatNumber = (seat: any): string => { + if (!seat.seatNumber || !seat.bedPosition) return seat.seatNumber || ''; + const label = getBedLabel(seat.bedPosition); + return `${seat.seatNumber}${label}`; + }; + const renderCoachSeats = (coach: any, isBedCoach: boolean) => { const allSeats = coach.seats || []; const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); @@ -138,14 +229,13 @@ export default function SeatsPage() { const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); if (isBedCoach && hasBedPositionData) { - // Render bed coach with flipping effect and bed position labels const arrangement = parseSeatArrangement(coach.seatArrangement); const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const allSeatsForLayout = [...validSeats, ...removedSeats]; - const rows = []; + const rows: any[][] = []; const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || ''); const isVipBed = seatClassStr.toLowerCase().includes('vip'); - const bedWidth = isVipBed ? 'w-24' : 'w-16'; + const bedWidth = isVipBed ? 'w-20' : 'w-16'; for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) { rows.push(allSeatsForLayout.slice(i, i + seatsPerRow)); @@ -154,23 +244,14 @@ export default function SeatsPage() { return (
{rows.map((rowSeats: any[], idx: number) => { - const rowNumber = rowSeats[0]?.row || (idx + 1); - const shouldFlipIcon = rowNumber % 2 === 0; - const shouldFlipRow = rowNumber % 2 === 1; - const showSpacing = idx % 2 === 1; + const isFirstInPair = idx % 2 === 0; + const shouldFlipIcon = !isFirstInPair; + const isLastRow = idx === rows.length - 1; + const nextRowSeats = !isLastRow ? rows[idx + 1] : null; return (
- {shouldFlipIcon && ( -
- {rowSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- ))} -
- )} -
+
{rowSeats.map((seat: any) => ( ))}
- {!shouldFlipIcon && ( -
- {rowSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- ))} + {isFirstInPair && nextRowSeats && ( +
+ {rowSeats.map((seat: any, seatIdx: number) => { + const currentSeat = rowSeats[seatIdx]; + const nextSeat = nextRowSeats[seatIdx]; + const currentFormatted = currentSeat ? formatBedSeatNumber(currentSeat) : ''; + const nextFormatted = nextSeat ? formatBedSeatNumber(nextSeat) : ''; + return ( +
+
{currentFormatted}
+
{nextFormatted}
+
+ ); + })}
)} - {showSpacing &&
} + {!isFirstInPair &&
}
); })} @@ -205,7 +293,6 @@ export default function SeatsPage() { ); } - // Regular armchair layout const arrangement = parseSeatArrangement(coach.seatArrangement); const leftCount = arrangement[0]; const rightCount = arrangement[1] || 0; @@ -231,25 +318,24 @@ export default function SeatsPage() { const rightSeats = rowSeats.slice(leftCount); const rowNumber = rowSeats[0]?.row || 1; const shouldFlipArmchair = rowNumber % 2 === 0; - const shouldFlipRow = rowNumber % 2 === 0; const showSpacing = rowIdx % 2 === 1; return (
{shouldFlipArmchair && ( -
+
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -257,7 +343,7 @@ export default function SeatsPage() { )}
)} -
+
{leftSeats.map((seat: any) => ( ))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( @@ -300,19 +386,19 @@ export default function SeatsPage() {
{!shouldFlipArmchair && ( -
+
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -329,22 +415,22 @@ export default function SeatsPage() { ); }; - const coachesWithSeats = coaches.filter((coach: any) => { - const seats = (coach.seats || []).filter((s: any) => s.seatNumber); - return seats.length > 0; - }); + const coachesWithSeats = coaches + .filter((coach: any) => { + const seats = (coach.seats || []).filter((s: any) => s.seatNumber); + return seats.length > 0; + }) + .sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0)); return (
-
-
-

Seat Management

-

View and manage seat availability by schedule

-
+
+

Seat Management

+

View and manage seats by coach

-
-
+ {!selectedSchedule ? ( +
setSelectedSchedule(e.target.value)} + className="input" + > + + {schedules.map((schedule: any) => { + const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A'; + const routeName = schedule.route?.name || 'N/A'; + const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A'; + return ( + + ); + })} +
-
- {coachesWithSeats.map((coach: any) => { - const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) || - (coach.mode && coach.mode.toLowerCase().includes('bed')); - const seats = (coach.seats || []).filter((s: any) => s.seatNumber); +
+

Seat Status

+
+
+
+ Available +
+
+
+ Booked +
+
+
+ Held +
+
+
+ Blocked +
+
+
+ Removed +
+
+
+
- return ( -
-
-

Coach {coach.coachNumber}

-
+
+
+ +
-
- {renderCoachSeats(coach, isBedCoach)} -
+ {coachesWithSeats.map((coach: any, index: number) => { + const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach; + const coachTypeName = coachData?.coachType?.type || 'Coach'; + const isBedCoach = coachTypeName.toLowerCase().includes('bed'); + const seats = (coach.seats || []).filter((s: any) => s.seatNumber); + const isExpanded = expandedCoaches.has(coach.id); + const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; + const sequence = coachData?.sequence ?? coach?.sequence ?? index + 1; + + return ( +
+
+ + isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)} + className="ml-2" + disabled={!isCoachBlocked(coach) && !isCoachUnblocked(coach)} + > + {isCoachBlocked(coach) ? ( + <> + Unblock + + ) : isCoachUnblocked(coach) ? ( + <> + Block + + ) : ( + 'Mixed Status' + )} +
- ); - })} -
+ + {isExpanded && ( +
+
+ {renderCoachSeats(coach, isBedCoach)} +
+
+ )} +
+ ); + })}
- )} -
+
+ )}

- Block seat {selectedSeat?.seatNumber} in Coach{' '} - {selectedSeat?.coach?.coachNumber} + Block seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber}

@@ -485,8 +635,7 @@ export default function SeatsPage() { >

- Remove seat {selectedSeat?.seatNumber} from Coach{' '} - {selectedSeat?.coach?.coachNumber} + Remove seat {selectedSeat?.seatNumber} from Coach {selectedSeat?.coach?.coachNumber}

@@ -514,6 +663,96 @@ export default function SeatsPage() {

+ + { + setShowBlockCoachModal(false); + setSelectedCoach(null); + setBlockCoachReason(''); + }} + title="Block Coach" + size="md" + > +
+

+ Block all seats in Coach {selectedCoach?.coachNumber} +

+
+

+ This will block all {selectedCoach?.seats?.length || 0} seats in this coach. +

+
+
+ +