mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
Merge branch 'freight_feature/priority' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
128
.github/workflows/deploy.yml
vendored
128
.github/workflows/deploy.yml
vendored
@@ -1,5 +1,4 @@
|
|||||||
name: Deploy Stacks
|
name: Deploy Stacks
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -8,50 +7,129 @@ on:
|
|||||||
- staging
|
- staging
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-${{ github.ref_name }}
|
group: deploy-${{ github.ref_name }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
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:
|
deploy:
|
||||||
name: Deploy ${{ matrix.service }}
|
name: Deploy ${{ matrix.service }}
|
||||||
|
needs: detect-changes
|
||||||
|
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
service: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
|
||||||
- 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
|
|
||||||
env:
|
env:
|
||||||
PROJECT: ${{ matrix.project }}
|
|
||||||
BRANCH: ${{ github.ref_name }}
|
BRANCH: ${{ github.ref_name }}
|
||||||
DEPLOY_USER: tria
|
DEPLOY_USER: tria
|
||||||
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
|
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
COMPOSE_DOCKER_CLI_BUILD: "1"
|
COMPOSE_DOCKER_CLI_BUILD: "1"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Sync environment from server
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/deploy/*.sh
|
chmod +x scripts/deploy/*.sh
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
|||||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||||
|
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||||
|
|
||||||
interface CurrentIamUser {
|
interface CurrentIamUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -45,6 +46,12 @@ export class CompaniesController {
|
|||||||
return new ProfileResponseDto(profile, company);
|
return new ProfileResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
||||||
|
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
||||||
|
return this.companiesService.getDashboardSummary(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('profile')
|
@Patch('profile')
|
||||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import { CompaniesService } from './companies.service';
|
|||||||
import { CompaniesRepository } from './companies.repository';
|
import { CompaniesRepository } from './companies.repository';
|
||||||
import { ExternalProfileRepository } from './external-profile.repository';
|
import { ExternalProfileRepository } from './external-profile.repository';
|
||||||
import { FFClientRepository } from './ff-client.repository';
|
import { FFClientRepository } from './ff-client.repository';
|
||||||
|
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||||
import { Company } from './entities/company.entity';
|
import { Company } from './entities/company.entity';
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { ExternalProfile } from './entities/external-profile.entity';
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
})
|
})
|
||||||
export class CompaniesModule {}
|
export class CompaniesModule {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
|||||||
import { CompaniesRepository } from './companies.repository';
|
import { CompaniesRepository } from './companies.repository';
|
||||||
import { ExternalProfileRepository } from './external-profile.repository';
|
import { ExternalProfileRepository } from './external-profile.repository';
|
||||||
import { FFClientRepository } from './ff-client.repository';
|
import { FFClientRepository } from './ff-client.repository';
|
||||||
|
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||||
import { CreateExternalProfileDto } from './dto/create-external-profile.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 { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||||
|
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||||
import { Company } from './entities/company.entity';
|
import { Company } from './entities/company.entity';
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { ExternalProfile } from './entities/external-profile.entity';
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
@@ -27,6 +29,7 @@ export class CompaniesService {
|
|||||||
private readonly companiesRepo: CompaniesRepository,
|
private readonly companiesRepo: CompaniesRepository,
|
||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly ffClientsRepo: FFClientRepository,
|
private readonly ffClientsRepo: FFClientRepository,
|
||||||
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
@@ -98,6 +101,122 @@ export class CompaniesService {
|
|||||||
return { profile, company };
|
return { profile, company };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
||||||
|
* current user's company bookings. All figures are scoped to that company.
|
||||||
|
*
|
||||||
|
* Note: delivered/spend/volume all derive from the bookings table — there is
|
||||||
|
* no separate data source for them. On-time delivery rate is replaced by
|
||||||
|
* completion rate (delivered ÷ committed): the schema has no ETA /
|
||||||
|
* promised-delivery date, so on-time cannot be computed.
|
||||||
|
*
|
||||||
|
* Period attribution uses booking.created_at: there is no delivery-date
|
||||||
|
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||||
|
* delivered/completed status.
|
||||||
|
*/
|
||||||
|
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
|
||||||
|
// A user without a company profile has no bookings — return an empty summary
|
||||||
|
// rather than 404, so the portal home still renders.
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||||
|
if (!companyId) return this.emptyDashboardSummary();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||||
|
// Same point in the previous year, so YoY compares like-for-like windows.
|
||||||
|
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
|
||||||
|
|
||||||
|
const [
|
||||||
|
deliveredThis,
|
||||||
|
committedThis,
|
||||||
|
spendThisByCcy,
|
||||||
|
spendPrevByCcy,
|
||||||
|
tonnageThis,
|
||||||
|
tonnagePrev,
|
||||||
|
monthlyRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||||
|
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||||
|
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||||
|
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
|
||||||
|
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||||
|
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
||||||
|
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||||
|
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
||||||
|
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deliveredCount: deliveredThis,
|
||||||
|
// Share of committed bookings that reached delivered/completed.
|
||||||
|
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
|
||||||
|
spendYtd: spend.total,
|
||||||
|
spendCurrency: spend.currency,
|
||||||
|
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||||
|
freightVolume: {
|
||||||
|
totalTonnes: Math.round(tonnageThis),
|
||||||
|
totalValue: spend.total,
|
||||||
|
currency: spend.currency,
|
||||||
|
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
|
||||||
|
monthly: this.buildMonthlySeries(now, monthlyRows),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyDashboardSummary(): DashboardSummaryResponseDto {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
deliveredCount: 0,
|
||||||
|
completionRate: 0,
|
||||||
|
spendYtd: 0,
|
||||||
|
spendCurrency: 'ETB',
|
||||||
|
spendYtdChangePct: 0,
|
||||||
|
freightVolume: {
|
||||||
|
totalTonnes: 0,
|
||||||
|
totalValue: 0,
|
||||||
|
currency: 'ETB',
|
||||||
|
ytdChangePct: 0,
|
||||||
|
monthly: this.buildMonthlySeries(now, []),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First day of the month `n` months before `from`. */
|
||||||
|
private monthsAgo(from: Date, n: number): Date {
|
||||||
|
return new Date(from.getFullYear(), from.getMonth() - n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||||
|
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
||||||
|
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
||||||
|
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
|
||||||
|
private changePct(current: number, previous: number): number {
|
||||||
|
if (previous <= 0) return 0;
|
||||||
|
return Math.round(((current - previous) / previous) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
|
||||||
|
private buildMonthlySeries(
|
||||||
|
now: Date,
|
||||||
|
rows: { year: number; month: number; tonnes: number }[],
|
||||||
|
): { month: string; tonnes: number }[] {
|
||||||
|
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||||
|
const series: { month: string; tonnes: number }[] = [];
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||||
|
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
|
||||||
|
}
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||||
await this.findCompanyById(id);
|
await this.findCompanyById(id);
|
||||||
const updated = await this.companiesRepo.update(id, dto);
|
const updated = await this.companiesRepo.update(id, dto);
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
|
/** Booking statuses that represent a delivered/finished shipment. */
|
||||||
|
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses that represent real, committed freight (excludes drafts and dead
|
||||||
|
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
|
||||||
|
*/
|
||||||
|
const COMMITTED_STATUSES = [
|
||||||
|
'APPROVED',
|
||||||
|
'CONTRACT_READY',
|
||||||
|
'SIGNED_CUSTOMER',
|
||||||
|
'FULLY_EXECUTED',
|
||||||
|
'SELECTED_FOR_BATCH',
|
||||||
|
'PNR_GENERATED',
|
||||||
|
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
|
'PAID',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'COMPLETED',
|
||||||
|
'DELIVERED',
|
||||||
|
'CONSOLIDATED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface CurrencyTotal {
|
||||||
|
currency: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyTonnage {
|
||||||
|
year: number;
|
||||||
|
month: number; // 1-12
|
||||||
|
tonnes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only aggregation queries against the bookings table, scoped to a
|
||||||
|
* company, that back the portal dashboard. Lives in the companies module so it
|
||||||
|
* can be exposed via `companies.controller` without a circular dependency on
|
||||||
|
* BookingsModule (which already imports CompaniesModule).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CompanyDashboardRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Booking)
|
||||||
|
private readonly bookings: Repository<Booking>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Count of delivered/completed bookings for a company within [from, to). */
|
||||||
|
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
||||||
|
return this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
||||||
|
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
||||||
|
return this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
||||||
|
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||||
|
const rows = await this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('b.payment_currency', 'currency')
|
||||||
|
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere("b.payment_status = 'PAID'")
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.groupBy('b.payment_currency')
|
||||||
|
.getRawMany<{ currency: string; total: string }>();
|
||||||
|
|
||||||
|
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
||||||
|
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
||||||
|
const row = await this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.getRawOne<{ total: string }>();
|
||||||
|
|
||||||
|
return Number(row?.total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Committed tonnage grouped by calendar month within [from, to). */
|
||||||
|
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||||
|
const rows = await this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||||
|
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||||
|
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.groupBy('year')
|
||||||
|
.addGroupBy('month')
|
||||||
|
.getRawMany<{ year: string; month: string; total: string }>();
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
year: Number(r.year),
|
||||||
|
month: Number(r.month),
|
||||||
|
tonnes: Number(r.total),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -173,7 +173,7 @@ const App = () => {
|
|||||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route element={<RequireCompany path={location.pathname} />}>
|
<Route element={<RequireCompany />}>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<AppLayout
|
<AppLayout
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ export function AppLayout({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
const toggleStyle: CSSProperties = { ...islandStyle, borderRadius: 10 };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ChevronRight, Home } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
export interface BreadcrumbItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -34,10 +34,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
|||||||
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
|
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
|
||||||
|
|
||||||
{item.href && !isLast ? (
|
{item.href && !isLast ? (
|
||||||
<Link
|
<Link to={item.href} className="transition hover:text-[#10B981]">
|
||||||
to={item.href}
|
|
||||||
className="transition hover:text-[#10B981]"
|
|
||||||
>
|
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { FormEvent, useState } from "react";
|
|
||||||
import { Button, FormField } from "@edr/ui-common";
|
|
||||||
|
|
||||||
import type { CreateBookingPayload } from "../../services/bookings.service";
|
|
||||||
|
|
||||||
export interface BookingFormProps {
|
|
||||||
onSubmit: (payload: CreateBookingPayload) => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BookingForm = ({ onSubmit, isSubmitting }: BookingFormProps) => {
|
|
||||||
const [reference, setReference] = useState("");
|
|
||||||
const [customerId, setCustomerId] = useState("");
|
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
|
||||||
const [totalAmount, setTotalAmount] = useState("0");
|
|
||||||
|
|
||||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSubmit({
|
|
||||||
reference,
|
|
||||||
customerId,
|
|
||||||
scheduledDate,
|
|
||||||
totalAmount: Number(totalAmount),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
||||||
<FormField
|
|
||||||
label="Reference"
|
|
||||||
value={reference}
|
|
||||||
onChange={(e) => setReference(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Customer ID"
|
|
||||||
value={customerId}
|
|
||||||
onChange={(e) => setCustomerId(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Scheduled date"
|
|
||||||
type="date"
|
|
||||||
value={scheduledDate}
|
|
||||||
onChange={(e) => setScheduledDate(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Total amount"
|
|
||||||
type="number"
|
|
||||||
value={totalAmount}
|
|
||||||
onChange={(e) => setTotalAmount(e.target.value)}
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<Button type="submit" isLoading={isSubmitting}>
|
|
||||||
Create booking
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BookingForm;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { Freight } from "@edr/types";
|
|
||||||
import { Table, type TableColumn } from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface BookingTableProps {
|
|
||||||
bookings: Freight.IBooking[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: TableColumn<Freight.IBooking>[] = [
|
|
||||||
{ key: "reference", header: "Reference" },
|
|
||||||
{ key: "customerId", header: "Customer" },
|
|
||||||
{ key: "status", header: "Status" },
|
|
||||||
{
|
|
||||||
key: "scheduledDate",
|
|
||||||
header: "Scheduled",
|
|
||||||
render: (row) => new Date(row.scheduledDate).toLocaleDateString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "totalAmount",
|
|
||||||
header: "Total",
|
|
||||||
render: (row) => row.totalAmount.toFixed(2),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const BookingTable = ({ bookings }: BookingTableProps) => (
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={bookings}
|
|
||||||
rowKey={(row) => row.id}
|
|
||||||
emptyMessage="No bookings yet"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default BookingTable;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { FormEvent, useState } from "react";
|
|
||||||
import { Button, FormField } from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface ConsignmentFormProps {
|
|
||||||
onSubmit: (payload: {
|
|
||||||
bookingId: string;
|
|
||||||
trackingNumber: string;
|
|
||||||
cargoType: string;
|
|
||||||
weightKg: number;
|
|
||||||
originStation: string;
|
|
||||||
destinationStation: string;
|
|
||||||
}) => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConsignmentForm = ({ onSubmit, isSubmitting }: ConsignmentFormProps) => {
|
|
||||||
const [bookingId, setBookingId] = useState("");
|
|
||||||
const [trackingNumber, setTrackingNumber] = useState("");
|
|
||||||
const [cargoType, setCargoType] = useState("GENERAL");
|
|
||||||
const [weightKg, setWeightKg] = useState("0");
|
|
||||||
const [originStation, setOriginStation] = useState("");
|
|
||||||
const [destinationStation, setDestinationStation] = useState("");
|
|
||||||
|
|
||||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSubmit({
|
|
||||||
bookingId,
|
|
||||||
trackingNumber,
|
|
||||||
cargoType,
|
|
||||||
weightKg: Number(weightKg),
|
|
||||||
originStation,
|
|
||||||
destinationStation,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
||||||
<FormField
|
|
||||||
label="Booking ID"
|
|
||||||
value={bookingId}
|
|
||||||
onChange={(e) => setBookingId(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Tracking #"
|
|
||||||
value={trackingNumber}
|
|
||||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Cargo type"
|
|
||||||
value={cargoType}
|
|
||||||
onChange={(e) => setCargoType(e.target.value)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Weight (kg)"
|
|
||||||
type="number"
|
|
||||||
value={weightKg}
|
|
||||||
onChange={(e) => setWeightKg(e.target.value)}
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Origin station"
|
|
||||||
value={originStation}
|
|
||||||
onChange={(e) => setOriginStation(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Destination station"
|
|
||||||
value={destinationStation}
|
|
||||||
onChange={(e) => setDestinationStation(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button type="submit" isLoading={isSubmitting}>
|
|
||||||
Create consignment
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConsignmentForm;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { Freight } from "@edr/types";
|
|
||||||
import { Table, type TableColumn } from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface ConsignmentTableProps {
|
|
||||||
consignments: Freight.IConsignment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: TableColumn<Freight.IConsignment>[] = [
|
|
||||||
{ key: "trackingNumber", header: "Tracking #" },
|
|
||||||
{ key: "cargoType", header: "Cargo" },
|
|
||||||
{ key: "status", header: "Status" },
|
|
||||||
{ key: "originStation", header: "Origin" },
|
|
||||||
{ key: "destinationStation", header: "Destination" },
|
|
||||||
{
|
|
||||||
key: "weightKg",
|
|
||||||
header: "Weight (kg)",
|
|
||||||
render: (row) => row.weightKg.toFixed(2),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ConsignmentTable = ({ consignments }: ConsignmentTableProps) => (
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={consignments}
|
|
||||||
rowKey={(row) => row.id}
|
|
||||||
emptyMessage="No consignments yet"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ConsignmentTable;
|
|
||||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { Loader2, AlertCircle } from "lucide-react";
|
import { Loader2, AlertCircle } from "lucide-react";
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -64,11 +63,7 @@ export function DynamicSelect({
|
|||||||
const options = [...data.children].sort((a, b) => a.order - b.order);
|
const options = [...data.children].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||||
value={value}
|
|
||||||
onValueChange={onValueChange}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={cn("w-full", className)}>
|
<SelectTrigger className={cn("w-full", className)}>
|
||||||
<SelectValue placeholder={placeholder ?? `Select ${data.label}`} />
|
<SelectValue placeholder={placeholder ?? `Select ${data.label}`} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const TrackingTimeline = ({ events }: TrackingTimelineProps) => {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||||
<span>{event.location}</span>
|
<span>{event.location}</span>
|
||||||
<Badge tone="info">{event.status}</Badge>
|
<Badge>{event.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<time className="text-xs text-gray-500">
|
<time className="text-xs text-gray-500">
|
||||||
{new Date(event.occurredAt).toLocaleString()}
|
{new Date(event.occurredAt).toLocaleString()}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export * from './table';
|
export * from "./table";
|
||||||
export * from './badge';
|
export * from "./badge";
|
||||||
export * from './button';
|
export * from "./button";
|
||||||
export * from './dialog';
|
export * from "./dialog";
|
||||||
export * from './input';
|
export * from "./input";
|
||||||
export * from './label';
|
export * from "./label";
|
||||||
export * from './textarea';
|
export * from "./textarea";
|
||||||
export * from './Breadcrumbs';
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
|
|||||||
GET_INFO: "/api/companies/getInfo",
|
GET_INFO: "/api/companies/getInfo",
|
||||||
CREATE: "/api/companies/create",
|
CREATE: "/api/companies/create",
|
||||||
PROFILE: "/api/companies/profile",
|
PROFILE: "/api/companies/profile",
|
||||||
|
DASHBOARD: "/api/companies/dashboard",
|
||||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -100,4 +101,10 @@ export const URL_CONSTANTS = {
|
|||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
PAYMENTS: {
|
||||||
|
INITIATE: "/api/payments/initiate",
|
||||||
|
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
|
||||||
|
CHECKOUT: "/api/payments/checkout",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { bookingsService } from "../services/bookings.service";
|
|
||||||
|
|
||||||
export const useBookings = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["bookings"],
|
|
||||||
queryFn: bookingsService.list,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useBooking = (id: string) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["bookings", id],
|
|
||||||
queryFn: () => bookingsService.get(id),
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { consignmentsService } from "../services/consignments.service";
|
|
||||||
|
|
||||||
export const useConsignments = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["consignments"],
|
|
||||||
queryFn: consignmentsService.list,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useConsignment = (id: string) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["consignments", id],
|
|
||||||
queryFn: () => consignmentsService.get(id),
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { customersService } from "@/services/customers.service";
|
|
||||||
import type {
|
|
||||||
CreateCustomerDto,
|
|
||||||
UpdateCustomerDto,
|
|
||||||
} from "@/types/customers";
|
|
||||||
|
|
||||||
const KEY = ["customers"] as const;
|
|
||||||
|
|
||||||
export const useCustomers = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: KEY,
|
|
||||||
queryFn: customersService.list,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useCustomer = (id: string | undefined) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: [...KEY, "id", id],
|
|
||||||
queryFn: () => customersService.getById(id!),
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useCreateCustomer = () => {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (dto: CreateCustomerDto) => customersService.create(dto),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateCustomer = () => {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) =>
|
|
||||||
customersService.update(id, dto),
|
|
||||||
onSuccess: (_data, { id }) => {
|
|
||||||
qc.invalidateQueries({ queryKey: KEY });
|
|
||||||
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeleteCustomer = () => {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => customersService.remove(id),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
|
||||||
import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
|
|
||||||
import { shipments, type Shipment } from "@/pages/tracking/shipments.mock";
|
|
||||||
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
||||||
|
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock "logged-in customer". When auth integrates, replace this with the value
|
* Mock "logged-in customer". When auth integrates, replace this with the value
|
||||||
@@ -16,16 +14,6 @@ export function getCurrentCustomer(): Customer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMyBookings(): Booking[] {
|
|
||||||
const me = getCurrentCustomer();
|
|
||||||
return bookings.filter((b) => b.customerId === me.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMyShipments(): Shipment[] {
|
|
||||||
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
|
|
||||||
return shipments.filter((s) => myBookingIds.has(s.bookingId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMyInvoices(): Invoice[] {
|
export function getMyInvoices(): Invoice[] {
|
||||||
const me = getCurrentCustomer();
|
const me = getCurrentCustomer();
|
||||||
return invoices.filter((inv) => inv.customerId === me.id);
|
return invoices.filter((inv) => inv.customerId === me.id);
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import { useMemo } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { getMyInvoices, getMyShipments } from "@/lib/currentCustomer";
|
import { getMyInvoices } from "@/lib/currentCustomer";
|
||||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
@@ -38,6 +38,9 @@ const cv = (token: string) => {
|
|||||||
return `var(--mantine-color-${name}-${shade ?? "6"})`;
|
return `var(--mantine-color-${name}-${shade ?? "6"})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Format a signed percentage for KPI deltas, e.g. 16 → "+16%", -4 → "-4%". */
|
||||||
|
const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`;
|
||||||
|
|
||||||
const ACTIVE_STATUSES = [
|
const ACTIVE_STATUSES = [
|
||||||
"DRAFT",
|
"DRAFT",
|
||||||
"SUBMITTED",
|
"SUBMITTED",
|
||||||
@@ -356,12 +359,8 @@ const INVOICE_BADGE: Record<
|
|||||||
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
|
|
||||||
const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
|
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
export default function MyPortalPage() {
|
||||||
const { user, customer } = useAuth();
|
const { user, customer } = useAuth();
|
||||||
const myShipments = useMemo(() => getMyShipments(), []);
|
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -371,10 +370,17 @@ export default function MyPortalPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions());
|
||||||
|
const dashboard = dashboardQuery.data;
|
||||||
|
|
||||||
const allBookings = bookingsQuery.data?.items ?? [];
|
const allBookings = bookingsQuery.data?.items ?? [];
|
||||||
const activeBookings = allBookings.filter((b) =>
|
const activeBookings = allBookings.filter((b) =>
|
||||||
ACTIVE_STATUSES.includes(b.status),
|
ACTIVE_STATUSES.includes(b.status),
|
||||||
);
|
);
|
||||||
|
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const newActiveThisWeek = activeBookings.filter(
|
||||||
|
(b) => new Date(b.createdAt).getTime() >= weekAgo,
|
||||||
|
).length;
|
||||||
|
|
||||||
const visibleBookings = allBookings;
|
const visibleBookings = allBookings;
|
||||||
const outstandingInvoices = myInvoices.filter(
|
const outstandingInvoices = myInvoices.filter(
|
||||||
@@ -384,9 +390,6 @@ export default function MyPortalPage() {
|
|||||||
(sum, inv) => sum + inv.amount,
|
(sum, inv) => sum + inv.amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const deliveredCount =
|
|
||||||
myShipments.filter((s) => s.status === "Delivered").length || 12;
|
|
||||||
|
|
||||||
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
||||||
const companyName = (customer as any)?.companyName ?? displayName;
|
const companyName = (customer as any)?.companyName ?? displayName;
|
||||||
|
|
||||||
@@ -398,7 +401,9 @@ export default function MyPortalPage() {
|
|||||||
? "Good afternoon,"
|
? "Good afternoon,"
|
||||||
: "Good evening,";
|
: "Good evening,";
|
||||||
const recentInvoices = myInvoices.slice(0, 3);
|
const recentInvoices = myInvoices.slice(0, 3);
|
||||||
const maxVolume = Math.max(...VOLUME_DATA);
|
|
||||||
|
const volumePoints = dashboard?.freightVolume.monthly ?? [];
|
||||||
|
const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||||
@@ -471,8 +476,12 @@ export default function MyPortalPage() {
|
|||||||
<StatKpi
|
<StatKpi
|
||||||
icon={Truck}
|
icon={Truck}
|
||||||
label="Active Shipments"
|
label="Active Shipments"
|
||||||
value={activeBookings.length.toString()}
|
value={
|
||||||
delta="+2 this week"
|
bookingsQuery.isPending ? "—" : activeBookings.length.toString()
|
||||||
|
}
|
||||||
|
delta={
|
||||||
|
bookingsQuery.isPending ? "" : `+${newActiveThisWeek} this week`
|
||||||
|
}
|
||||||
deltaColor="edr-green.7"
|
deltaColor="edr-green.7"
|
||||||
/>
|
/>
|
||||||
<StatKpi
|
<StatKpi
|
||||||
@@ -485,17 +494,26 @@ export default function MyPortalPage() {
|
|||||||
/>
|
/>
|
||||||
<StatKpi
|
<StatKpi
|
||||||
icon={CheckCircle2}
|
icon={CheckCircle2}
|
||||||
label="Delivered (May)"
|
label="Delivered (YTD)"
|
||||||
value={deliveredCount.toString()}
|
value={dashboard ? dashboard.deliveredCount.toString() : "—"}
|
||||||
delta="96% on-time"
|
delta={dashboard ? `${dashboard.completionRate}% completed` : ""}
|
||||||
deltaColor="edr-muted"
|
deltaColor="edr-muted"
|
||||||
divider
|
divider
|
||||||
/>
|
/>
|
||||||
<StatKpi
|
<StatKpi
|
||||||
icon={Wallet}
|
icon={Wallet}
|
||||||
label="Spend YTD"
|
label="Spend YTD"
|
||||||
value="ETB 1.24M"
|
value={
|
||||||
delta="+16% YoY"
|
dashboard
|
||||||
|
? formatCurrency(
|
||||||
|
dashboard.spendYtd,
|
||||||
|
dashboard.spendCurrency as Currency,
|
||||||
|
)
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
delta={
|
||||||
|
dashboard ? `${formatPct(dashboard.spendYtdChangePct)} YoY` : ""
|
||||||
|
}
|
||||||
deltaColor="edr-green.7"
|
deltaColor="edr-green.7"
|
||||||
divider
|
divider
|
||||||
/>
|
/>
|
||||||
@@ -670,37 +688,57 @@ export default function MyPortalPage() {
|
|||||||
Freight Volume
|
Freight Volume
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={10} align="baseline" mt={4} mb={22}>
|
<Group gap={10} align="baseline" mt={4} mb={22}>
|
||||||
<Text fz={26} fw={800} c="edr-text">
|
{dashboardQuery.isPending ? (
|
||||||
4,180 t
|
<Skeleton height={32} width={180} radius="sm" />
|
||||||
</Text>
|
) : (
|
||||||
<Text fz={13} c="edr-muted">
|
<>
|
||||||
ETB 1.24M
|
<Text fz={26} fw={800} c="edr-text">
|
||||||
</Text>
|
{(dashboard?.freightVolume.totalTonnes ?? 0).toLocaleString()}{" "}
|
||||||
<Text fz={12} fw={700} c="edr-green.7">
|
t
|
||||||
+16% YTD
|
</Text>
|
||||||
</Text>
|
<Text fz={13} c="edr-muted">
|
||||||
|
{formatCurrency(
|
||||||
|
dashboard?.freightVolume.totalValue ?? 0,
|
||||||
|
(dashboard?.freightVolume.currency ?? "ETB") as Currency,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} fw={700} c="edr-green.7">
|
||||||
|
{formatPct(dashboard?.freightVolume.ytdChangePct ?? 0)} YTD
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group align="flex-end" gap={10} className="h-[110px]">
|
{dashboardQuery.isPending ? (
|
||||||
{VOLUME_DATA.map((val, i) => {
|
<Skeleton height={110} radius="md" />
|
||||||
const isLast = i === VOLUME_DATA.length - 1;
|
) : volumePoints.length === 0 ? (
|
||||||
return (
|
<Box className="flex h-[110px] items-center">
|
||||||
<Box
|
<Text fz={13} c="edr-muted">
|
||||||
key={i}
|
No freight volume yet.
|
||||||
className="flex flex-1 flex-col items-center gap-2"
|
</Text>
|
||||||
>
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Group align="flex-end" gap={10} className="h-[110px]">
|
||||||
|
{volumePoints.map((point, i) => {
|
||||||
|
const isLast = i === volumePoints.length - 1;
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
bg={isLast ? "edr-green" : "edr-soft"}
|
key={point.month}
|
||||||
bd={isLast ? undefined : "1px solid edr-border"}
|
className="flex flex-1 flex-col items-center gap-2"
|
||||||
h={Math.round((val / maxVolume) * 86)}
|
>
|
||||||
className="w-full rounded-t-md"
|
<Box
|
||||||
/>
|
bg={isLast ? "edr-green" : "edr-soft"}
|
||||||
<Text fz={11} c="edr-muted">
|
bd={isLast ? undefined : "1px solid edr-border"}
|
||||||
{MONTHS[i]}
|
h={Math.round((point.tonnes / maxVolume) * 86)}
|
||||||
</Text>
|
className="w-full rounded-t-md"
|
||||||
</Box>
|
/>
|
||||||
);
|
<Text fz={11} c="edr-muted">
|
||||||
})}
|
{point.month}
|
||||||
</Group>
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -8,7 +19,6 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -32,7 +42,10 @@ const onboardingSchema = z.object({
|
|||||||
companyLocation: z.string().min(1, "Location is required"),
|
companyLocation: z.string().min(1, "Location is required"),
|
||||||
companyAddress: z.string().min(1, "Address is required"),
|
companyAddress: z.string().min(1, "Address is required"),
|
||||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||||
vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"),
|
vatNumber: z
|
||||||
|
.string()
|
||||||
|
.min(1, "VAT number is required")
|
||||||
|
.length(10, "VAT number must be exactly 10 digits"),
|
||||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||||
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
||||||
@@ -52,8 +65,26 @@ const onboardingSchema = z.object({
|
|||||||
type FormData = z.infer<typeof onboardingSchema>;
|
type FormData = z.infer<typeof onboardingSchema>;
|
||||||
|
|
||||||
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||||
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
|
company: [
|
||||||
personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"],
|
"companyName",
|
||||||
|
"companyEmail",
|
||||||
|
"companyPhone",
|
||||||
|
"companyPhoneCountryCode",
|
||||||
|
"companyLocation",
|
||||||
|
"companyAddress",
|
||||||
|
"tinNumber",
|
||||||
|
"vatNumber",
|
||||||
|
"fanNumber",
|
||||||
|
],
|
||||||
|
personnel: [
|
||||||
|
"contactPersonName",
|
||||||
|
"contactPersonPhone",
|
||||||
|
"contactPersonPhoneCountryCode",
|
||||||
|
"generalManagerName",
|
||||||
|
"generalManagerEmail",
|
||||||
|
"generalManagerPhone",
|
||||||
|
"generalManagerPhoneCountryCode",
|
||||||
|
],
|
||||||
poa: [],
|
poa: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
confirm: [],
|
confirm: [],
|
||||||
@@ -76,7 +107,10 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
|||||||
generalManagerEmail: data.generalManagerEmail,
|
generalManagerEmail: data.generalManagerEmail,
|
||||||
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
||||||
poaName: data.poaName || undefined,
|
poaName: data.poaName || undefined,
|
||||||
poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined,
|
poaPhone:
|
||||||
|
data.poaPhone && data.poaPhoneCountryCode
|
||||||
|
? `${data.poaPhoneCountryCode}${data.poaPhone}`
|
||||||
|
: undefined,
|
||||||
poaAddress: data.poaAddress || undefined,
|
poaAddress: data.poaAddress || undefined,
|
||||||
poaEmail: data.poaEmail || undefined,
|
poaEmail: data.poaEmail || undefined,
|
||||||
poaLocation: data.poaLocation || undefined,
|
poaLocation: data.poaLocation || undefined,
|
||||||
@@ -102,22 +136,50 @@ export default function CompanyProfileForm({
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<CompanyStep>("company");
|
const [step, setStep] = useState<CompanyStep>("company");
|
||||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
const [internalFiles, setInternalFiles] = useState<
|
||||||
|
Record<string, File | File[] | null>
|
||||||
|
>({});
|
||||||
const documentFiles = controlledFiles ?? internalFiles;
|
const documentFiles = controlledFiles ?? internalFiles;
|
||||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||||
|
|
||||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
|
input: { code: documentSettingCode },
|
||||||
|
refetchOnMount: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
trigger,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(onboardingSchema),
|
resolver: zodResolver(onboardingSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251",
|
companyName: "",
|
||||||
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
|
companyEmail: "",
|
||||||
contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251",
|
companyPhone: "",
|
||||||
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251",
|
companyPhoneCountryCode: "+251",
|
||||||
poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "",
|
companyLocation: "",
|
||||||
|
companyAddress: "",
|
||||||
|
tinNumber: "",
|
||||||
|
vatNumber: "",
|
||||||
|
fanNumber: "",
|
||||||
|
contactPersonName: "",
|
||||||
|
contactPersonPhone: "",
|
||||||
|
contactPersonPhoneCountryCode: "+251",
|
||||||
|
generalManagerName: "",
|
||||||
|
generalManagerEmail: "",
|
||||||
|
generalManagerPhone: "",
|
||||||
|
generalManagerPhoneCountryCode: "+251",
|
||||||
|
poaName: "",
|
||||||
|
poaPhone: "",
|
||||||
|
poaPhoneCountryCode: "+251",
|
||||||
|
poaAddress: "",
|
||||||
|
poaEmail: "",
|
||||||
|
poaLocation: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +188,18 @@ export default function CompanyProfileForm({
|
|||||||
const totalSteps = 5;
|
const totalSteps = 5;
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (step === "poa") { setStep("documents"); return; }
|
if (step === "poa") {
|
||||||
if (step === "documents") { setStep("confirm"); return; }
|
setStep("documents");
|
||||||
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
|
return;
|
||||||
|
}
|
||||||
|
if (step === "documents") {
|
||||||
|
setStep("confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "confirm") {
|
||||||
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isValid = await trigger(stepFields[step]);
|
const isValid = await trigger(stepFields[step]);
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
setStep(step === "company" ? "personnel" : "poa");
|
setStep(step === "company" ? "personnel" : "poa");
|
||||||
@@ -158,7 +229,13 @@ export default function CompanyProfileForm({
|
|||||||
confirm: `Step 5 of ${totalSteps} — Review & Confirm`,
|
confirm: `Step 5 of ${totalSteps} — Review & Confirm`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepOrder: CompanyStep[] = ["company", "personnel", "poa", "documents", "confirm"];
|
const stepOrder: CompanyStep[] = [
|
||||||
|
"company",
|
||||||
|
"personnel",
|
||||||
|
"poa",
|
||||||
|
"documents",
|
||||||
|
"confirm",
|
||||||
|
];
|
||||||
const currentIdx = stepOrder.indexOf(step);
|
const currentIdx = stepOrder.indexOf(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,13 +252,24 @@ export default function CompanyProfileForm({
|
|||||||
Change account type
|
Change account type
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
className="relative max-w-lg mx-auto px-2"
|
||||||
|
>
|
||||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
||||||
{STEPS.map(({ key, icon }, i) => {
|
{STEPS.map(({ key, icon }, i) => {
|
||||||
const done = i < currentIdx;
|
const done = i < currentIdx;
|
||||||
const active = i === currentIdx;
|
const active = i === currentIdx;
|
||||||
return done || active ? (
|
return done || active ? (
|
||||||
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
|
<ThemeIcon
|
||||||
|
key={key}
|
||||||
|
size={40}
|
||||||
|
radius="xl"
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
className="relative z-10"
|
||||||
|
>
|
||||||
{done ? <CheckCircle2 size={18} /> : icon}
|
{done ? <CheckCircle2 size={18} /> : icon}
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
) : (
|
) : (
|
||||||
@@ -223,7 +311,10 @@ export default function CompanyProfileForm({
|
|||||||
/>
|
/>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||||
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
|
phone={{
|
||||||
|
...register("companyPhone"),
|
||||||
|
placeholder: "912345678",
|
||||||
|
}}
|
||||||
countryCodeError={errors.companyPhoneCountryCode}
|
countryCodeError={errors.companyPhoneCountryCode}
|
||||||
phoneError={errors.companyPhone}
|
phoneError={errors.companyPhone}
|
||||||
label="Company Phone"
|
label="Company Phone"
|
||||||
@@ -271,7 +362,9 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "personnel" && (
|
{step === "personnel" && (
|
||||||
<>
|
<>
|
||||||
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
Contact Person
|
||||||
|
</Text>
|
||||||
<SimpleGrid cols={2} spacing="md">
|
<SimpleGrid cols={2} spacing="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
@@ -281,7 +374,10 @@ export default function CompanyProfileForm({
|
|||||||
/>
|
/>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
|
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
|
||||||
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
|
phone={{
|
||||||
|
...register("contactPersonPhone"),
|
||||||
|
placeholder: "912345678",
|
||||||
|
}}
|
||||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||||
phoneError={errors.contactPersonPhone}
|
phoneError={errors.contactPersonPhone}
|
||||||
label="Phone"
|
label="Phone"
|
||||||
@@ -290,7 +386,9 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
<Divider color="edr-border" />
|
<Divider color="edr-border" />
|
||||||
|
|
||||||
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
General Manager
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
placeholder="Abebe Bikila"
|
placeholder="Abebe Bikila"
|
||||||
@@ -306,8 +404,13 @@ export default function CompanyProfileForm({
|
|||||||
{...register("generalManagerEmail")}
|
{...register("generalManagerEmail")}
|
||||||
/>
|
/>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
|
countryCode={{
|
||||||
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
|
...register("generalManagerPhoneCountryCode"),
|
||||||
|
}}
|
||||||
|
phone={{
|
||||||
|
...register("generalManagerPhone"),
|
||||||
|
placeholder: "912345678",
|
||||||
|
}}
|
||||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||||
phoneError={errors.generalManagerPhone}
|
phoneError={errors.generalManagerPhone}
|
||||||
label="Phone"
|
label="Phone"
|
||||||
@@ -319,7 +422,8 @@ export default function CompanyProfileForm({
|
|||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
|
Power of Attorney details are optional. Fill them in if you have
|
||||||
|
them, or skip to continue.
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Name"
|
label="PoA Name"
|
||||||
@@ -371,51 +475,126 @@ export default function CompanyProfileForm({
|
|||||||
No document requirements found for your account type.
|
No document requirements found for your account type.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
|
<SmartFileInput
|
||||||
|
file={uploadSetting}
|
||||||
|
value={documentFiles}
|
||||||
|
onChange={setDocumentFiles}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "confirm" && (
|
{step === "confirm" && (
|
||||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
<Box
|
||||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
p={16}
|
||||||
|
className="rounded-2xl border border-edr-border bg-edr-card"
|
||||||
|
>
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Review your registration
|
||||||
|
</Text>
|
||||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||||
Confirm the company details below before saving.
|
Confirm the company details below before saving.
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
<ReviewRow label="Company name" value={formValues.companyName} />
|
<ReviewRow
|
||||||
<ReviewRow label="Company email" value={formValues.companyEmail} />
|
label="Company name"
|
||||||
<ReviewRow label="Company phone" value={formValues.companyPhone} />
|
value={formValues.companyName}
|
||||||
<ReviewRow label="Location" value={formValues.companyLocation} />
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="Company email"
|
||||||
|
value={formValues.companyEmail}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="Company phone"
|
||||||
|
value={formValues.companyPhone}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="Location"
|
||||||
|
value={formValues.companyLocation}
|
||||||
|
/>
|
||||||
<ReviewRow label="Address" value={formValues.companyAddress} />
|
<ReviewRow label="Address" value={formValues.companyAddress} />
|
||||||
<ReviewRow label="TIN" value={formValues.tinNumber} />
|
<ReviewRow label="TIN" value={formValues.tinNumber} />
|
||||||
<ReviewRow label="VAT" value={formValues.vatNumber} />
|
<ReviewRow label="VAT" value={formValues.vatNumber} />
|
||||||
<ReviewRow label="FAN" value={formValues.fanNumber} />
|
<ReviewRow label="FAN" value={formValues.fanNumber} />
|
||||||
<ReviewRow label="Contact person" value={formValues.contactPersonName} />
|
<ReviewRow
|
||||||
<ReviewRow label="Contact phone" value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`} />
|
label="Contact person"
|
||||||
<ReviewRow label="General manager" value={formValues.generalManagerName} />
|
value={formValues.contactPersonName}
|
||||||
<ReviewRow label="GM email" value={formValues.generalManagerEmail} />
|
/>
|
||||||
<ReviewRow label="GM phone" value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`} />
|
<ReviewRow
|
||||||
<ReviewRow label="PoA name" value={formValues.poaName || undefined} />
|
label="Contact phone"
|
||||||
<ReviewRow label="PoA phone" value={formValues.poaPhone && formValues.poaPhoneCountryCode ? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}` : undefined} />
|
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
|
||||||
<ReviewRow label="PoA email" value={formValues.poaEmail || undefined} />
|
/>
|
||||||
<ReviewRow label="PoA location" value={formValues.poaLocation || undefined} />
|
<ReviewRow
|
||||||
|
label="General manager"
|
||||||
|
value={formValues.generalManagerName}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="GM email"
|
||||||
|
value={formValues.generalManagerEmail}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="GM phone"
|
||||||
|
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="PoA name"
|
||||||
|
value={formValues.poaName || undefined}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="PoA phone"
|
||||||
|
value={
|
||||||
|
formValues.poaPhone && formValues.poaPhoneCountryCode
|
||||||
|
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="PoA email"
|
||||||
|
value={formValues.poaEmail || undefined}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="PoA location"
|
||||||
|
value={formValues.poaLocation || undefined}
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="space-between" pt="xs">
|
<Group justify="space-between" pt="xs">
|
||||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
<Button
|
||||||
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
variant="default"
|
||||||
|
onClick={prevStep}
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
>
|
||||||
|
{step === "company"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
onClick={
|
||||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
step === "confirm"
|
||||||
|
? handleSubmit((data) => onSubmit(buildPayload(data, user)))
|
||||||
|
: nextStep
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
(step === "documents" && !hasDocuments && loadingDocuments)
|
||||||
|
}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
rightSection={
|
||||||
|
!isPending && step !== "confirm" && step !== "documents" ? (
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
{step === "documents"
|
||||||
|
? "Continue"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Submit Registration"
|
||||||
|
: "Next Step"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -427,10 +606,20 @@ export default function CompanyProfileForm({
|
|||||||
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
<Box p={12} className="rounded-xl bg-edr-bg">
|
||||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c="edr-muted"
|
||||||
|
className="uppercase tracking-wide"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
c={value?.trim() ? "edr-text" : "edr-muted"}
|
||||||
|
mt={4}
|
||||||
|
>
|
||||||
{value?.trim() ? value : "Not provided"}
|
{value?.trim() ? value : "Not provided"}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export function DraftBookingView({
|
|||||||
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const pricing = booking.pricingBreakdown ?? generatedPricing ?? null;
|
const pricing = (booking.pricingBreakdown ??
|
||||||
|
generatedPricing ??
|
||||||
|
null) as Freight.PricingBreakdown | null;
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Box, Group, Text } from "@mantine/core";
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { CreditCard, Download } from "lucide-react";
|
import { CreditCard, Download } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
@@ -13,21 +15,30 @@ import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout
|
|||||||
import { CancelledBanner } from "./components/Notices";
|
import { CancelledBanner } from "./components/Notices";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||||
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
import { PaymentCard } from "./components/pricing";
|
import { PaymentCard } from "./components/pricing";
|
||||||
import { ScheduleCard } from "./components/ScheduleCard";
|
import { ScheduleCard } from "./components/ScheduleCard";
|
||||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||||
import { StatusHero } from "./components/StatusHero";
|
import { StatusHero } from "./components/StatusHero";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { fmtDate, isNegative } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
|
|
||||||
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
|
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Two-step flow: POST /payments/initiate to create the intent, then send the
|
||||||
|
// browser to the public /payments/checkout page which redirects to the
|
||||||
|
// selected provider to complete payment.
|
||||||
const payMutation = useMutation({
|
const payMutation = useMutation({
|
||||||
mutationFn: () => api.bookings.pay.call({ id: booking.id }),
|
mutationFn: (method: PaymentMethod) =>
|
||||||
onSuccess: (data) => {
|
api.payments.initiate.call({ bookingId: booking.id, method }),
|
||||||
if (data.redirectUrl) window.location.href = data.redirectUrl;
|
onSuccess: (_data, method) => {
|
||||||
|
window.location.href = paymentsService.checkoutUrl({
|
||||||
|
bookingId: booking.id,
|
||||||
|
method,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,9 +58,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
<HeaderButton
|
<HeaderButton
|
||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
label={payMutation.isPending ? "Processing…" : "Pay now"}
|
label="Pay now"
|
||||||
onClick={() => payMutation.mutate()}
|
onClick={() => setPayModalOpen(true)}
|
||||||
disabled={payMutation.isPending}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -128,7 +138,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
{showCountdown && (
|
{showCountdown && (
|
||||||
<PaymentDeadlineCard
|
<PaymentDeadlineCard
|
||||||
paymentDeadline={booking.paymentDeadline!}
|
paymentDeadline={booking.paymentDeadline!}
|
||||||
onPay={() => payMutation.mutate()}
|
onPay={() => setPayModalOpen(true)}
|
||||||
paying={payMutation.isPending}
|
paying={payMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -142,6 +152,26 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PaymentMethodModal
|
||||||
|
opened={payModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!payMutation.isPending) {
|
||||||
|
setPayModalOpen(false);
|
||||||
|
payMutation.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
|
processing={payMutation.isPending}
|
||||||
|
error={
|
||||||
|
payMutation.isError
|
||||||
|
? payMutation.error instanceof Error
|
||||||
|
? payMutation.error.message
|
||||||
|
: "Could not start payment. Please try again."
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onConfirm={(method) => payMutation.mutate(method)}
|
||||||
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Banknote,
|
||||||
|
Building2,
|
||||||
|
CreditCard,
|
||||||
|
Smartphone,
|
||||||
|
Wallet,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { PaymentMethod } from "@/services/payments.service";
|
||||||
|
|
||||||
|
interface ProviderOption {
|
||||||
|
method: PaymentMethod;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS: ProviderOption[] = [
|
||||||
|
{
|
||||||
|
method: "TELEBIRR",
|
||||||
|
label: "telebirr",
|
||||||
|
description: "Ethiopian mobile money",
|
||||||
|
icon: Smartphone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "CBE_BIRR",
|
||||||
|
label: "CBE Birr",
|
||||||
|
description: "Commercial Bank of Ethiopia",
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "EBIRR",
|
||||||
|
label: "E-Birr",
|
||||||
|
description: "Electronic payment gateway",
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "WAAFI",
|
||||||
|
label: "WAAFI",
|
||||||
|
description: "Djibouti mobile money",
|
||||||
|
icon: Smartphone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "CARD",
|
||||||
|
label: "Card",
|
||||||
|
description: "Visa / Mastercard",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "DMONEY",
|
||||||
|
label: "D-Money",
|
||||||
|
description: "Djibouti D-money",
|
||||||
|
icon: Banknote,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "CAC_BANK",
|
||||||
|
label: "CAC Bank",
|
||||||
|
description: "CAC Int Bank (OTP)",
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ProviderRow({
|
||||||
|
option,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
option: ProviderOption;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = option.icon;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
onClick={onSelect}
|
||||||
|
gap={12}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "13px 14px",
|
||||||
|
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
|
||||||
|
backgroundColor: selected ? "#ECF6F1" : "#fff",
|
||||||
|
transition: "border-color .12s, background-color .12s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
|
||||||
|
color: selected ? "#fff" : "#475569",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={19} />
|
||||||
|
</Box>
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Text fz="14px" fw={700} c="#10202F">
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12.5px" c="#9AA8B5">
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
|
||||||
|
backgroundColor: selected ? "#0A6F4D" : "transparent",
|
||||||
|
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentMethodModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
amountLabel,
|
||||||
|
onConfirm,
|
||||||
|
processing,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Human-readable total, e.g. "ETB 12,500". */
|
||||||
|
amountLabel?: string;
|
||||||
|
onConfirm: (method: PaymentMethod) => void;
|
||||||
|
processing?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) {
|
||||||
|
const [method, setMethod] = useState<PaymentMethod | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size={460}
|
||||||
|
title={
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={800} fz="17px" c="#10202F">
|
||||||
|
Choose a payment method
|
||||||
|
</Text>
|
||||||
|
{amountLabel && (
|
||||||
|
<Text fz="12.5px" c="#9AA8B5">
|
||||||
|
Amount due: {amountLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap={10}>
|
||||||
|
{PROVIDERS.map((option) => (
|
||||||
|
<ProviderRow
|
||||||
|
key={option.method}
|
||||||
|
option={option}
|
||||||
|
selected={method === option.method}
|
||||||
|
onSelect={() => setMethod(option.method)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text fz="12.5px" c="#C0392B" fw={600}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt={6}
|
||||||
|
radius={10}
|
||||||
|
color="edr-green"
|
||||||
|
disabled={!method || processing}
|
||||||
|
loading={processing}
|
||||||
|
onClick={() => method && onConfirm(method)}
|
||||||
|
styles={{
|
||||||
|
root: { height: 46 },
|
||||||
|
label: { fontSize: 14, fontWeight: 800 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processing ? "Redirecting…" : "Continue to payment"}
|
||||||
|
</Button>
|
||||||
|
<Text fz="11.5px" c="#9AA8B5" ta="center">
|
||||||
|
You'll be redirected to your provider to complete payment securely.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import BookingForm from "../../components/bookings/BookingForm";
|
|
||||||
import { bookingsService } from "../../services/bookings.service";
|
|
||||||
|
|
||||||
const CreateBookingPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: bookingsService.create,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["bookings"] });
|
|
||||||
navigate("/bookings");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-lg">
|
|
||||||
<h1 className="mb-4 text-2xl font-semibold text-gray-900">New booking</h1>
|
|
||||||
<BookingForm
|
|
||||||
onSubmit={mutation.mutate}
|
|
||||||
isSubmitting={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateBookingPage;
|
|
||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type RouteDirection,
|
|
||||||
} from "./new-booking-form/schema";
|
} from "./new-booking-form/schema";
|
||||||
import { SelectField } from "./new-booking-form/shared";
|
import { SelectField } from "./new-booking-form/shared";
|
||||||
import { Step5CargoDetails } from "./new-booking-form/steps";
|
import { Step5CargoDetails } from "./new-booking-form/steps";
|
||||||
@@ -90,13 +89,13 @@ function mapBookingToFormValues(
|
|||||||
booking: Freight.IBooking,
|
booking: Freight.IBooking,
|
||||||
referenceData: Freight.BookingReferenceData,
|
referenceData: Freight.BookingReferenceData,
|
||||||
): BookingFormInputValues {
|
): BookingFormInputValues {
|
||||||
const vals: BookingFormInputValues = {
|
const vals = {
|
||||||
...initialBookingFormValues,
|
...initialBookingFormValues,
|
||||||
contractType:
|
contractType:
|
||||||
(booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new",
|
(booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new",
|
||||||
previousContractRef: booking.previousContractId ?? "",
|
previousContractRef: booking.previousContractId ?? "",
|
||||||
serviceType:
|
serviceTypeId:
|
||||||
booking.serviceType === "RAIL_AND_FORWARDING" ? "rail_forwarding" : "rail",
|
referenceData.service.find((s) => s.code === booking.serviceType)?.id ?? "",
|
||||||
firstMile: {
|
firstMile: {
|
||||||
enabled: booking.firstMileEnabled ?? false,
|
enabled: booking.firstMileEnabled ?? false,
|
||||||
pickUpAddress: booking.firstMilePickupAddress ?? "",
|
pickUpAddress: booking.firstMilePickupAddress ?? "",
|
||||||
@@ -120,18 +119,15 @@ function mapBookingToFormValues(
|
|||||||
notes: "",
|
notes: "",
|
||||||
// Terms were accepted at creation; editing shouldn't re-gate on them.
|
// Terms were accepted at creation; editing shouldn't re-gate on them.
|
||||||
termsAccepted: true,
|
termsAccepted: true,
|
||||||
freightType: "",
|
|
||||||
bulkCommoditytype: "",
|
|
||||||
containers: [],
|
containers: [],
|
||||||
};
|
} as BookingFormInputValues;
|
||||||
|
|
||||||
const bookingCargoTypeId = (booking as any).cargoTypeId as string | undefined;
|
const bookingCargoTypeId = (booking as any).cargoTypeId as string | undefined;
|
||||||
if (booking.freightType === "BULK" && bookingCargoTypeId) {
|
if (booking.freightType === "BULK" && bookingCargoTypeId) {
|
||||||
for (const group of referenceData.cargo_type) {
|
for (const group of referenceData.cargo_type) {
|
||||||
const child = group.children?.find((c) => c.id === bookingCargoTypeId);
|
const child = group.children?.find((c) => c.id === bookingCargoTypeId);
|
||||||
if (child) {
|
if (child) {
|
||||||
vals.freightType = group.code.toLowerCase();
|
vals.cargoTypePath = [group.id, child.id];
|
||||||
vals.bulkCommoditytype = child.name;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,16 +295,24 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const serviceType = form.watch("serviceType");
|
const serviceTypeId = form.watch("serviceTypeId");
|
||||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||||
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
|
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
|
||||||
|
|
||||||
const direction: RouteDirection = useMemo(
|
const selectedService = useMemo(
|
||||||
() => getRouteDirection(originYard, destinationYard),
|
() => referenceData?.service.find((s) => s.id === serviceTypeId),
|
||||||
[originYard, destinationYard],
|
[serviceTypeId, referenceData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const direction = useMemo(() => {
|
||||||
|
const origin = referenceData?.yard.find((y) => y.name === originYard);
|
||||||
|
const destination = referenceData?.yard.find(
|
||||||
|
(y) => y.name === destinationYard,
|
||||||
|
);
|
||||||
|
return getRouteDirection(origin, destination);
|
||||||
|
}, [originYard, destinationYard, referenceData]);
|
||||||
|
|
||||||
const yardOptions = useMemo(() => {
|
const yardOptions = useMemo(() => {
|
||||||
if (!referenceData?.yard) return [];
|
if (!referenceData?.yard) return [];
|
||||||
return referenceData.yard.map((y) => ({
|
return referenceData.yard.map((y) => ({
|
||||||
@@ -339,29 +343,17 @@ export default function EditBookingPage() {
|
|||||||
const yards = referenceData?.yard ?? [];
|
const yards = referenceData?.yard ?? [];
|
||||||
const services = referenceData?.service ?? [];
|
const services = referenceData?.service ?? [];
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
const shippingLines = referenceData?.shipping_line ?? [];
|
||||||
const cargoTree = referenceData?.cargo_type ?? [];
|
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
|
|
||||||
const findYardId = (name: string): string =>
|
const findYardId = (name: string): string =>
|
||||||
yards.find((y) => y.name === name)?.id ?? "";
|
yards.find((y) => y.name === name)?.id ?? "";
|
||||||
|
|
||||||
const findServiceTypeId = (): string => {
|
|
||||||
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
|
|
||||||
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const findShippingLineId = (name: string): string | undefined =>
|
const findShippingLineId = (name: string): string | undefined =>
|
||||||
shippingLines.find((l) => l.name === name)?.id;
|
shippingLines.find((l) => l.name === name)?.id;
|
||||||
|
|
||||||
const selectedChild =
|
const cargoTypePath = data.cargoTypePath ?? [];
|
||||||
data.cargoType !== "container" && data.bulkCommoditytype
|
|
||||||
? cargoTree
|
|
||||||
.find((g) => g.code.toLowerCase() === data.freightType)
|
|
||||||
?.children?.find((c) => c.name === data.bulkCommoditytype)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
data.cargoType === "container" ? undefined : (selectedChild?.id ?? "");
|
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
|
||||||
|
|
||||||
const findContainerTypeId = (name: string): string => {
|
const findContainerTypeId = (name: string): string => {
|
||||||
for (const group of containerGroups) {
|
for (const group of containerGroups) {
|
||||||
@@ -379,11 +371,13 @@ export default function EditBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
|
const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
|
||||||
|
|
||||||
const apiPayload: Partial<CreateBookingPayload> = {
|
const apiPayload: Partial<CreateBookingPayload> = {
|
||||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
serviceTypeId: findServiceTypeId(),
|
serviceTypeId: data.serviceTypeId,
|
||||||
equipmentReturn:
|
equipmentReturn:
|
||||||
data.equipmentReturn === "with_return"
|
data.equipmentReturn === "with_return"
|
||||||
? "WITH_RETURN"
|
? "WITH_RETURN"
|
||||||
@@ -391,9 +385,9 @@ export default function EditBookingPage() {
|
|||||||
originYardId: findYardId(data.originYard),
|
originYardId: findYardId(data.originYard),
|
||||||
destinationYardId: findYardId(data.destinationYard),
|
destinationYardId: findYardId(data.destinationYard),
|
||||||
tradeDirection:
|
tradeDirection:
|
||||||
direction === "export"
|
direction === "EXPORT"
|
||||||
? "EXPORT"
|
? "EXPORT"
|
||||||
: direction === "domestic"
|
: direction === "DOMESTIC"
|
||||||
? "DOMESTIC"
|
? "DOMESTIC"
|
||||||
: "IMPORT",
|
: "IMPORT",
|
||||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||||
@@ -420,10 +414,10 @@ export default function EditBookingPage() {
|
|||||||
...(data.contractType === "renewal" && data.previousContractRef
|
...(data.contractType === "renewal" && data.previousContractRef
|
||||||
? { pnrCode: data.previousContractRef }
|
? { pnrCode: data.previousContractRef }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
|
...(selectedSvc?.includesFirstMile && data.firstMile.enabled
|
||||||
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
|
...(selectedSvc?.includesLastMile && data.lastMile.enabled
|
||||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.shippingLine
|
...(data.shippingLine
|
||||||
@@ -523,7 +517,7 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<Controller
|
<Controller
|
||||||
name="serviceType"
|
name="serviceTypeId"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<SelectField
|
<SelectField
|
||||||
@@ -531,13 +525,9 @@ export default function EditBookingPage() {
|
|||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Service Type *"
|
label="Service Type *"
|
||||||
placeholder="Select service type..."
|
placeholder="Select service type..."
|
||||||
data={[
|
data={(referenceData?.service ?? [])
|
||||||
{ value: "rail", label: "Rail Transport Only" },
|
.filter((s) => s.canBeBookedAlone)
|
||||||
{
|
.map((s) => ({ value: s.id, label: s.serviceName }))}
|
||||||
value: "rail_forwarding",
|
|
||||||
label: "Logistics (Rail + Forwarding)",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -560,7 +550,9 @@ export default function EditBookingPage() {
|
|||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
{serviceType === "rail_forwarding" && (
|
{(selectedService?.includesFirstMile ||
|
||||||
|
selectedService?.includesLastMile ||
|
||||||
|
selectedService?.includesCustoms) && (
|
||||||
<Paper withBorder radius="md">
|
<Paper withBorder radius="md">
|
||||||
<Controller
|
<Controller
|
||||||
name="firstMile.enabled"
|
name="firstMile.enabled"
|
||||||
@@ -570,7 +562,7 @@ export default function EditBookingPage() {
|
|||||||
icon={<Truck size={16} color="#6B7C8E" />}
|
icon={<Truck size={16} color="#6B7C8E" />}
|
||||||
title="First Mile - Pick-up"
|
title="First Mile - Pick-up"
|
||||||
description="Truck pick-up from your premises to the origin rail yard."
|
description="Truck pick-up from your premises to the origin rail yard."
|
||||||
checked={field.value}
|
checked={field.value ?? false}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -610,7 +602,7 @@ export default function EditBookingPage() {
|
|||||||
icon={<Truck size={16} color="#6B7C8E" />}
|
icon={<Truck size={16} color="#6B7C8E" />}
|
||||||
title="Last Mile - Delivery"
|
title="Last Mile - Delivery"
|
||||||
description="Truck delivery from the destination rail yard to the final address."
|
description="Truck delivery from the destination rail yard to the final address."
|
||||||
checked={field.value}
|
checked={field.value ?? false}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -650,7 +642,7 @@ export default function EditBookingPage() {
|
|||||||
icon={<FileText size={16} color="#6B7C8E" />}
|
icon={<FileText size={16} color="#6B7C8E" />}
|
||||||
title="Customs Clearing Service"
|
title="Customs Clearing Service"
|
||||||
description="EDR handles customs documentation and clearance on your behalf."
|
description="EDR handles customs documentation and clearance on your behalf."
|
||||||
checked={field.value}
|
checked={field.value ?? false}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -710,7 +702,7 @@ export default function EditBookingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{direction && direction !== "domestic" && (
|
{direction && direction !== "DOMESTIC" && (
|
||||||
<Controller
|
<Controller
|
||||||
name="shippingLine"
|
name="shippingLine"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -763,7 +755,7 @@ export default function EditBookingPage() {
|
|||||||
<Box>
|
<Box>
|
||||||
<Step5CargoDetails
|
<Step5CargoDetails
|
||||||
form={form}
|
form={form}
|
||||||
direction={direction}
|
direction={direction!}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
isLoading={!referenceData}
|
isLoading={!referenceData}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -179,11 +179,7 @@ export default function MyBookings() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original;
|
const b = row.original;
|
||||||
const cargoLabel =
|
const cargoLabel =
|
||||||
b.freightType === "BULK"
|
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
|
||||||
? "Bulk Cargo"
|
|
||||||
: b.freightType === "BREAK_BULK"
|
|
||||||
? "Break Bulk"
|
|
||||||
: "Cargo";
|
|
||||||
return (
|
return (
|
||||||
<Group gap={12} wrap="nowrap" align="center">
|
<Group gap={12} wrap="nowrap" align="center">
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -193,7 +193,6 @@ export default function NewBookingPage() {
|
|||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
// @ts-ignore
|
|
||||||
freightType:
|
freightType:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? ("CONTAINER" as const)
|
? ("CONTAINER" as const)
|
||||||
@@ -322,7 +321,12 @@ export default function NewBookingPage() {
|
|||||||
)}
|
)}
|
||||||
{step === 6 && <StepDocuments form={form} />}
|
{step === 6 && <StepDocuments form={form} />}
|
||||||
{step === 7 && (
|
{step === 7 && (
|
||||||
<Step8Review form={form} setStep={setStep} direction={direction!} />
|
<Step8Review
|
||||||
|
form={form}
|
||||||
|
setStep={setStep}
|
||||||
|
direction={direction!}
|
||||||
|
referenceData={referenceData}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ import type { Freight } from "@edr/types";
|
|||||||
import { DeepPartial, Path } from "react-hook-form";
|
import { DeepPartial, Path } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
export const MOCK_VALID_CONTRACTS = [
|
|
||||||
"EDR-2024-10001",
|
|
||||||
"EDR-2024-10002",
|
|
||||||
"EDR-2023-88123",
|
|
||||||
"EDR-2022-55442",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
{ id: 1, label: "Contract Type", short: "Contract" },
|
{ id: 1, label: "Contract Type", short: "Contract" },
|
||||||
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
||||||
@@ -75,6 +68,7 @@ export const bookingFormSchema = z
|
|||||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||||
previousContractRef: z.string(),
|
previousContractRef: z.string(),
|
||||||
serviceTypeId: z.string("Select a service type."),
|
serviceTypeId: z.string("Select a service type."),
|
||||||
|
|
||||||
firstMile: z
|
firstMile: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
@@ -218,6 +212,7 @@ export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
|
|||||||
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||||
previousContractRef: "",
|
previousContractRef: "",
|
||||||
|
|
||||||
|
serviceTypeId: "",
|
||||||
firstMile: {
|
firstMile: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
pickUpAddress: "",
|
pickUpAddress: "",
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
|
||||||
import { Field, FieldError, Input, Switch } from "@edr/ui-common";
|
|
||||||
import { type BookingFormValues } from "./schema";
|
|
||||||
import { StepHeader } from "./shared";
|
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
|
||||||
|
|
||||||
export function Step3FirstLastMile({ form }: { form: BookingForm }) {
|
|
||||||
const firstMileEnabled = form.watch("firstMileEnabled");
|
|
||||||
const lastMileEnabled = form.watch("lastMileEnabled");
|
|
||||||
const equipmentReturn = form.watch("equipmentReturn");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<StepHeader
|
|
||||||
title="First & Last Mile"
|
|
||||||
description="Configure trucking and container return options."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="divide-y divide-border rounded-xl border border-border">
|
|
||||||
<div className="p-4">
|
|
||||||
<Controller
|
|
||||||
name="firstMileEnabled"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">First Mile - Pick-up</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
Truck pick-up from your premises (Door to Port) to the
|
|
||||||
origin rail yard.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
if (!value) {
|
|
||||||
form.setValue("pickUpAddress", "", {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{firstMileEnabled && (
|
|
||||||
<Controller
|
|
||||||
name="pickUpAddress"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field className="mt-3" data-invalid={fieldState.invalid}>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Pick-up address *"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<Controller
|
|
||||||
name="lastMileEnabled"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Last Mile - Delivery</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
Truck delivery from the destination rail yard to the final
|
|
||||||
address (Port to Door).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
if (!value) {
|
|
||||||
form.setValue("deliveryAddress", "", {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{lastMileEnabled && (
|
|
||||||
<Controller
|
|
||||||
name="deliveryAddress"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field className="mt-3" data-invalid={fieldState.invalid}>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Delivery address *"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lastMileEnabled && (
|
|
||||||
<div className="mt-4 border-t border-border pt-4">
|
|
||||||
<Controller
|
|
||||||
name="equipmentReturn"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Equipment Return</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
{field.value === "with_return"
|
|
||||||
? "Container returned to EDR after unloading."
|
|
||||||
: "Container retained by the customer after delivery."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={field.value === "with_return"}
|
|
||||||
onCheckedChange={(value) => {
|
|
||||||
field.onChange(
|
|
||||||
value ? "with_return" : "without_return",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { SelectField, StepHeader, StepLabel } from "./shared";
|
import { SelectField, StepHeader, StepLabel } from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step4Route({
|
export function Step4Route({
|
||||||
form,
|
form,
|
||||||
@@ -31,44 +35,36 @@ export function Step4Route({
|
|||||||
|
|
||||||
const shippingLineOptions = useMemo(() => {
|
const shippingLineOptions = useMemo(() => {
|
||||||
if (!referenceData?.shipping_line) return [];
|
if (!referenceData?.shipping_line) return [];
|
||||||
return referenceData.shipping_line.map((sl) => ({ value: sl.name, label: sl.name }));
|
return referenceData.shipping_line.map((sl) => ({
|
||||||
|
value: sl.name,
|
||||||
|
label: sl.name,
|
||||||
|
}));
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const originData = useMemo(
|
const originData = useMemo(() => {
|
||||||
() => {
|
return yardOptions
|
||||||
return yardOptions.filter((o) => o.value !== destinationYard).filter((o) => {
|
.filter((o) => o.value !== destinationYard)
|
||||||
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
.filter((o) => {
|
||||||
if(!dest) return true;
|
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||||
|
if (!dest) return true;
|
||||||
const origin = referenceData?.yard.find((y) => y.id === o.value);
|
const origin = referenceData?.yard.find((y) => y.id === o.value);
|
||||||
|
|
||||||
// can't go from Djibouti to Djibouti
|
|
||||||
if(dest?.country === 'Djibouti' && origin?.country == 'Djibouti') return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[yardOptions, destinationYard],
|
|
||||||
);
|
|
||||||
console.log({yardOptions,originYard, destinationYard})
|
|
||||||
const destData = useMemo(
|
|
||||||
() => {
|
|
||||||
return yardOptions.filter((o) => o.value !== originYard).filter((d) => {
|
|
||||||
|
|
||||||
|
|
||||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
|
||||||
if(!origin) return true;
|
|
||||||
|
|
||||||
const dest = referenceData?.yard.find((y) => y.id === d.value);
|
|
||||||
// can't go from Djibouti to Djibouti
|
// can't go from Djibouti to Djibouti
|
||||||
// if(origin.country === 'Djibouti' && dest?.country == 'Djibouti') return false;
|
if (dest?.country === "Djibouti" && origin?.country == "Djibouti")
|
||||||
|
return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
},
|
}, [yardOptions, destinationYard]);
|
||||||
[yardOptions, originYard],
|
console.log({ yardOptions, originYard, destinationYard });
|
||||||
);
|
const destData = useMemo(() => {
|
||||||
|
return yardOptions.filter((o) => o.value !== originYard);
|
||||||
|
}, [yardOptions, originYard]);
|
||||||
|
|
||||||
const direction = getRouteDirection(referenceData?.yard.find((y) => y.id === originYard), referenceData?.yard.find((y) => y.name === destinationYard));
|
const direction = getRouteDirection(
|
||||||
|
referenceData?.yard.find((y) => y.id === originYard),
|
||||||
|
referenceData?.yard.find((y) => y.name === destinationYard),
|
||||||
|
);
|
||||||
|
|
||||||
const directionStyle: Record<string, string> = {
|
const directionStyle: Record<string, string> = {
|
||||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
|
||||||
import { Field, FieldError, SmartFileInput } from "@edr/ui-common";
|
|
||||||
import {
|
|
||||||
BOOKING_DOCS_SETTING,
|
|
||||||
REQUIRED_DOC_KEYS,
|
|
||||||
type BookingFormValues,
|
|
||||||
} from "./schema";
|
|
||||||
import { getUploadedRequiredCount, StepHeader } from "./shared";
|
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
|
||||||
|
|
||||||
export function Step7Documents({ form }: { form: BookingForm }) {
|
|
||||||
const documents = form.watch("documents");
|
|
||||||
const uploadedRequired = getUploadedRequiredCount(documents);
|
|
||||||
const documentErrors = form.formState.errors.documents as
|
|
||||||
| Record<string, { message?: string }>
|
|
||||||
| undefined;
|
|
||||||
const smartFileErrors = Object.fromEntries(
|
|
||||||
Object.entries(documentErrors ?? {}).map(([key, value]) => [
|
|
||||||
key,
|
|
||||||
value?.message ?? "",
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<StepHeader
|
|
||||||
title="Compliance Documents"
|
|
||||||
description="Upload your company's legal credentials for EDR contract eligibility verification (US-04)."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
|
||||||
<div
|
|
||||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold ${uploadedRequired === REQUIRED_DOC_KEYS.length
|
|
||||||
? "bg-emerald-100 text-emerald-700"
|
|
||||||
: "bg-primary/10 text-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{uploadedRequired}/{REQUIRED_DOC_KEYS.length}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{uploadedRequired < REQUIRED_DOC_KEYS.length
|
|
||||||
? `${REQUIRED_DOC_KEYS.length - uploadedRequired} mandatory document(s) still needed.`
|
|
||||||
: "All mandatory documents uploaded. Power of Attorney is optional."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="documents"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<SmartFileInput
|
|
||||||
file={BOOKING_DOCS_SETTING}
|
|
||||||
value={field.value}
|
|
||||||
onChange={(value) => field.onChange(value)}
|
|
||||||
errors={smartFileErrors}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea, Title } from "@mantine/core";
|
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
@@ -9,19 +9,28 @@ import {
|
|||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
import type { Freight } from "@/types";
|
import type { Freight } from "@/types";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step8Review({
|
export function Step8Review({
|
||||||
form,
|
form,
|
||||||
setStep,
|
setStep,
|
||||||
direction,
|
direction,
|
||||||
|
referenceData,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
direction: Freight.ScheduleTradeDirection;
|
direction: Freight.ScheduleTradeDirection;
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
}) {
|
}) {
|
||||||
const values = form.watch();
|
const values = form.watch();
|
||||||
const errors = form.formState.errors;
|
const errors = form.formState.errors;
|
||||||
|
const serviceType = referenceData?.service.find(
|
||||||
|
(s) => s.id === values.serviceTypeId,
|
||||||
|
);
|
||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
label,
|
label,
|
||||||
@@ -56,17 +65,17 @@ export function Step8Review({
|
|||||||
const containerSummary =
|
const containerSummary =
|
||||||
values.cargoType === "container" && values.containers.length > 0
|
values.cargoType === "container" && values.containers.length > 0
|
||||||
? values.containers
|
? values.containers
|
||||||
.filter((c) => +c.qty > 0)
|
.filter((c) => +c.qty > 0)
|
||||||
.map((c) => `${c.qty} × ${c.type}`)
|
.map((c) => `${c.qty} × ${c.type}`)
|
||||||
.join(", ")
|
.join(", ")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const totalVgm =
|
const totalVgm =
|
||||||
values.cargoType === "container"
|
values.cargoType === "container"
|
||||||
? values.containers.reduce(
|
? values.containers.reduce(
|
||||||
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
|
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const documents = (values.documents ?? {}) as BookingDocuments;
|
const documents = (values.documents ?? {}) as BookingDocuments;
|
||||||
@@ -76,14 +85,15 @@ export function Step8Review({
|
|||||||
}).length;
|
}).length;
|
||||||
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
|
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
|
||||||
|
|
||||||
const cargoValue =
|
const cargoValue = (() => {
|
||||||
values.cargoType === "container"
|
if (values.cargoType === "container") return containerSummary;
|
||||||
? containerSummary
|
if (!referenceData) return "";
|
||||||
: values.freightType === "bulk"
|
const path = values.cargoTypePath ?? [];
|
||||||
? `Bulk — ${values.bulkCommoditytype}`
|
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
|
||||||
: values.freightType === "break_bulk"
|
if (!group) return "";
|
||||||
? `Break-Bulk`
|
const child = group.children?.find((c) => c.id === path[1]);
|
||||||
: "";
|
return child ? `${group.name} — ${child.name}` : group.name;
|
||||||
|
})();
|
||||||
|
|
||||||
function ReviewCard({
|
function ReviewCard({
|
||||||
title,
|
title,
|
||||||
@@ -99,7 +109,13 @@ export function Step8Review({
|
|||||||
py="sm"
|
py="sm"
|
||||||
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
|
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wider">
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
tt="uppercase"
|
||||||
|
c="dimmed"
|
||||||
|
className="tracking-wider"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -124,17 +140,7 @@ export function Step8Review({
|
|||||||
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
||||||
target={1}
|
target={1}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
|
||||||
label="Service"
|
|
||||||
value={
|
|
||||||
values.serviceType === "rail"
|
|
||||||
? "Rail Only"
|
|
||||||
: values.serviceType === "rail_forwarding"
|
|
||||||
? "Rail + Forwarding"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
target={2}
|
|
||||||
/>
|
|
||||||
</ReviewCard>
|
</ReviewCard>
|
||||||
|
|
||||||
<ReviewCard title="First & Last Mile">
|
<ReviewCard title="First & Last Mile">
|
||||||
@@ -260,9 +266,7 @@ export function Step8Review({
|
|||||||
}
|
}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||||
error={
|
error={fieldState.error?.message ?? errors.termsAccepted?.message}
|
||||||
fieldState.error?.message ?? errors.termsAccepted?.message
|
|
||||||
}
|
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { CheckCircle2, Loader2, Save, UserCheck, XCircle } from "lucide-react";
|
import { CheckCircle2, Save, UserCheck, XCircle } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
@@ -100,8 +100,8 @@ export default function TabPowerOfAttorney({
|
|||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text c="edr-muted" size="sm">
|
<Text c="edr-muted" size="sm">
|
||||||
Power of Attorney details are optional. Fill them in if you have
|
Power of Attorney details are optional. Fill them in if you have an
|
||||||
an authorized representative, or leave blank.
|
authorized representative, or leave blank.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -162,13 +162,17 @@ export default function TabPowerOfAttorney({
|
|||||||
{mutation.isSuccess && (
|
{mutation.isSuccess && (
|
||||||
<Group gap={6} c="green">
|
<Group gap={6} c="green">
|
||||||
<CheckCircle2 size={16} />
|
<CheckCircle2 size={16} />
|
||||||
<Text size="sm" fw={500}>Saved successfully</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
Saved successfully
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{mutation.isError && (
|
{mutation.isError && (
|
||||||
<Group gap={6} c="red">
|
<Group gap={6} c="red">
|
||||||
<XCircle size={16} />
|
<XCircle size={16} />
|
||||||
<Text size="sm" fw={500}>Save failed</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
Save failed
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Calendar, MapPin } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import { consignments } from "../consignments/consignments.mock";
|
|
||||||
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
|
|
||||||
|
|
||||||
export interface ShipmentFormData {
|
|
||||||
consignmentId?: number;
|
|
||||||
consignmentReference?: string;
|
|
||||||
originStation?: string;
|
|
||||||
destinationStation?: string;
|
|
||||||
mode?: ShipmentMode;
|
|
||||||
status?: ShipmentStatus;
|
|
||||||
currentLocation?: string;
|
|
||||||
eta?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewShipmentPageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
shipment?: ShipmentFormData;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
export default function NewShipmentPage({
|
|
||||||
mode = "create",
|
|
||||||
shipment,
|
|
||||||
children,
|
|
||||||
}: NewShipmentPageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Shipment" : "New Shipment";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update shipment tracking information."
|
|
||||||
: "Create a new shipment for real-time tracking.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Shipment";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Shipment"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* Consignment */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Consignment *</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={shipment?.consignmentId ?? ""}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select consignment
|
|
||||||
</option>
|
|
||||||
{consignments.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.trackingNumber} — {c.customer} ({c.originStation} →{" "}
|
|
||||||
{c.destinationStation})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Origin and destination auto-fill from the linked consignment.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={shipment?.status ?? "In Transit"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>In Transit</option>
|
|
||||||
<option>Delivered</option>
|
|
||||||
<option>Delayed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Transport Mode</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={shipment?.mode ?? "rail"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="rail">Rail</option>
|
|
||||||
<option value="truck">Truck</option>
|
|
||||||
<option value="multimodal">Multimodal</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Origin */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Origin Station</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={shipment?.originStation ?? ""}
|
|
||||||
placeholder="Auto-filled from consignment"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destination */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Destination Station</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={shipment?.destinationStation ?? ""}
|
|
||||||
placeholder="Auto-filled from consignment"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ETA */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Estimated Arrival</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={shipment?.eta ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Location */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Current Location</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={shipment?.currentLocation ?? ""}
|
|
||||||
placeholder="e.g. Dire Dawa Yard"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
List,
|
List,
|
||||||
MapPin,
|
MapPin,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
Search,
|
||||||
Train,
|
Train,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -17,7 +15,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import NewShipmentPage from "./NewShipmentPage";
|
|
||||||
import DeleteShipmentDialog from "./DeleteShipmentDialog";
|
import DeleteShipmentDialog from "./DeleteShipmentDialog";
|
||||||
import {
|
import {
|
||||||
shipments,
|
shipments,
|
||||||
@@ -93,13 +90,6 @@ export default function TrackingPage() {
|
|||||||
className="pl-8!"
|
className="pl-8!"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewShipmentPage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
New Shipment
|
|
||||||
</Button>
|
|
||||||
</NewShipmentPage>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -279,7 +269,10 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t pt-3" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<div
|
||||||
|
className="flex items-center justify-end gap-2 border-t pt-3"
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -292,27 +285,12 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
<Eye />
|
<Eye />
|
||||||
View
|
View
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<NewShipmentPage
|
|
||||||
mode="edit"
|
|
||||||
shipment={{
|
|
||||||
bookingId: shipment.bookingId,
|
|
||||||
bookingReference: shipment.bookingReference,
|
|
||||||
originStation: shipment.originStation,
|
|
||||||
destinationStation: shipment.destinationStation,
|
|
||||||
mode: shipment.mode,
|
|
||||||
status: shipment.status,
|
|
||||||
currentLocation: shipment.currentLocation,
|
|
||||||
eta: shipment.eta,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewShipmentPage>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()} variant="destructive">
|
<DropdownMenuItem
|
||||||
|
onSelect={(e: Event) => e.preventDefault()}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
Remove
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -331,7 +309,9 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
<CardHeader className="flex flex-row items-center justify-between border-b">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Shipment List</CardTitle>
|
<CardTitle>Shipment List</CardTitle>
|
||||||
<CardDescription>All shipments and their current status.</CardDescription>
|
<CardDescription>
|
||||||
|
All shipments and their current status.
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -435,27 +415,14 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
<Eye />
|
<Eye />
|
||||||
View
|
View
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<NewShipmentPage
|
|
||||||
mode="edit"
|
|
||||||
shipment={{
|
|
||||||
bookingId: shipment.bookingId,
|
|
||||||
bookingReference: shipment.bookingReference,
|
|
||||||
originStation: shipment.originStation,
|
|
||||||
destinationStation: shipment.destinationStation,
|
|
||||||
mode: shipment.mode,
|
|
||||||
status: shipment.status,
|
|
||||||
currentLocation: shipment.currentLocation,
|
|
||||||
eta: shipment.eta,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewShipmentPage>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
<DeleteShipmentDialog
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()} variant="destructive">
|
shipmentReference={shipment.reference}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e: Event) => e.preventDefault()}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
Remove
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
CreateBookingPayload,
|
CreateBookingPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
} from "./bookings.service";
|
} from "./bookings.service";
|
||||||
|
import {
|
||||||
|
paymentsService,
|
||||||
|
InitiatePaymentPayload,
|
||||||
|
InitiateResponse,
|
||||||
|
IntentStatus,
|
||||||
|
} from "./payments.service";
|
||||||
import { consignmentsService } from "./consignments.service";
|
import { consignmentsService } from "./consignments.service";
|
||||||
import { trackingService } from "./tracking.service";
|
import { trackingService } from "./tracking.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
@@ -31,6 +37,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
|
DashboardSummary,
|
||||||
} from "./companies.service";
|
} from "./companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import type {
|
import type {
|
||||||
@@ -113,6 +120,12 @@ export const api = {
|
|||||||
"updateProfile",
|
"updateProfile",
|
||||||
companiesService.updateProfile,
|
companiesService.updateProfile,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getDashboard: endpoint<void, DashboardSummary>(
|
||||||
|
"companies",
|
||||||
|
"getDashboard",
|
||||||
|
companiesService.getDashboard,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
@@ -174,12 +187,6 @@ export const api = {
|
|||||||
bookingsService.uploadDocuments(id, files),
|
bookingsService.uploadDocuments(id, files),
|
||||||
),
|
),
|
||||||
|
|
||||||
pay: endpoint<{ id: string }, { redirectUrl: string }>(
|
|
||||||
"bookings",
|
|
||||||
"pay",
|
|
||||||
({ id }) => bookingsService.pay(id),
|
|
||||||
),
|
|
||||||
|
|
||||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"checkPayment",
|
"checkPayment",
|
||||||
@@ -194,6 +201,20 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
payments: {
|
||||||
|
initiate: endpoint<InitiatePaymentPayload, InitiateResponse>(
|
||||||
|
"payments",
|
||||||
|
"initiate",
|
||||||
|
paymentsService.initiate,
|
||||||
|
),
|
||||||
|
|
||||||
|
getIntent: endpoint<{ bookingId: string }, IntentStatus>(
|
||||||
|
"payments",
|
||||||
|
"getIntent",
|
||||||
|
({ bookingId }) => paymentsService.getIntent(bookingId),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
consignments: {
|
consignments: {
|
||||||
list: endpoint<void, PaginatedResponse<Freight.IConsignment>>(
|
list: endpoint<void, PaginatedResponse<Freight.IConsignment>>(
|
||||||
"consignments",
|
"consignments",
|
||||||
|
|||||||
@@ -139,11 +139,6 @@ export const bookingsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
pay: async (id: string): Promise<{ redirectUrl: string }> => {
|
|
||||||
const { data } = await client.post(`/api/bookings/${id}/payment/pay`);
|
|
||||||
return data.data ?? data;
|
|
||||||
},
|
|
||||||
|
|
||||||
signContract: async (
|
signContract: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: SignContractPayload,
|
payload: SignContractPayload,
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ export interface CreateCompanyPayload {
|
|||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FreightVolumePoint {
|
||||||
|
month: string;
|
||||||
|
tonnes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
deliveredCount: number;
|
||||||
|
completionRate: number;
|
||||||
|
spendYtd: number;
|
||||||
|
spendCurrency: string;
|
||||||
|
spendYtdChangePct: number;
|
||||||
|
freightVolume: {
|
||||||
|
totalTonnes: number;
|
||||||
|
totalValue: number;
|
||||||
|
currency: string;
|
||||||
|
ytdChangePct: number;
|
||||||
|
monthly: FreightVolumePoint[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const companiesService = {
|
export const companiesService = {
|
||||||
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
||||||
try {
|
try {
|
||||||
@@ -97,6 +117,13 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDashboard: async (): Promise<DashboardSummary> => {
|
||||||
|
const response = await client.get<ApiResponse<DashboardSummary>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.DASHBOARD,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
uploadDocuments: async (
|
uploadDocuments: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
files: Record<string, File | File[] | null>,
|
files: Record<string, File | File[] | null>,
|
||||||
|
|||||||
86
apps/edr-freight-web/portal/src/services/payments.service.ts
Normal file
86
apps/edr-freight-web/portal/src/services/payments.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const P = URL_CONSTANTS.PAYMENTS;
|
||||||
|
|
||||||
|
/** Payment methods supported by the central payment microservice. */
|
||||||
|
export type PaymentMethod =
|
||||||
|
| "TELEBIRR"
|
||||||
|
| "CBE_BIRR"
|
||||||
|
| "EBIRR"
|
||||||
|
| "WAAFI"
|
||||||
|
| "CARD"
|
||||||
|
| "DMONEY"
|
||||||
|
| "CAC_BANK";
|
||||||
|
|
||||||
|
export type PaymentPlatform = "web" | "mobile";
|
||||||
|
|
||||||
|
export interface InitiatePaymentPayload {
|
||||||
|
bookingId: string;
|
||||||
|
method: PaymentMethod;
|
||||||
|
platform?: PaymentPlatform;
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientAction {
|
||||||
|
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||||
|
url?: string;
|
||||||
|
appId?: string;
|
||||||
|
receiveCode?: string;
|
||||||
|
shortCode?: string;
|
||||||
|
providerOrderId?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitiateResponse {
|
||||||
|
intentId: string;
|
||||||
|
status: string;
|
||||||
|
clientAction?: ClientAction;
|
||||||
|
merchantOrderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentStatus extends InitiateResponse {
|
||||||
|
paidAt?: string;
|
||||||
|
failureCode?: string;
|
||||||
|
failureMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the absolute URL for the public browser-checkout page, which
|
||||||
|
* (re)initiates the payment and auto-redirects to the provider's checkout.
|
||||||
|
* Used as the "pay" step after a successful `initiate`.
|
||||||
|
*/
|
||||||
|
function buildCheckoutUrl(payload: {
|
||||||
|
bookingId: string;
|
||||||
|
method: PaymentMethod;
|
||||||
|
platform?: PaymentPlatform;
|
||||||
|
}): string {
|
||||||
|
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
bookingId: payload.bookingId,
|
||||||
|
method: payload.method,
|
||||||
|
platform: payload.platform ?? "web",
|
||||||
|
});
|
||||||
|
return `${base}${P.CHECKOUT}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentsService = {
|
||||||
|
initiate: async (
|
||||||
|
payload: InitiatePaymentPayload,
|
||||||
|
): Promise<InitiateResponse> => {
|
||||||
|
const { data } = await client.post(P.INITIATE, {
|
||||||
|
platform: "web",
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getIntent: async (bookingId: string): Promise<IntentStatus> => {
|
||||||
|
const { data } = await client.get(P.INTENT(bookingId));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
checkoutUrl: buildCheckoutUrl,
|
||||||
|
};
|
||||||
@@ -88,6 +88,19 @@ WAAFI_INSECURE_TLS=false
|
|||||||
# Payment Configuration
|
# Payment Configuration
|
||||||
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
||||||
|
|
||||||
|
# Browser return targets after a hosted payment page (UX only — payment is confirmed by the
|
||||||
|
# webhook/queryStatus, never this redirect). Global fallback used when a method-specific URL
|
||||||
|
# below is unset. Most providers use a single redirect; Waafi takes separate success/failure.
|
||||||
|
PAYMENT_RETURN_URL=
|
||||||
|
PAYMENT_FAILURE_URL=
|
||||||
|
TELEBIRR_RETURN_URL=
|
||||||
|
WAAFI_SUCCESS_REDIRECT=
|
||||||
|
WAAFI_FAIL_REDIRECT=
|
||||||
|
DMONEY_RETURN_URL=
|
||||||
|
CBE_RETURN_URL=
|
||||||
|
EBIRR_RETURN_URL=
|
||||||
|
CARD_RETURN_URL=
|
||||||
|
|
||||||
# Session Configuration
|
# Session Configuration
|
||||||
SESSION_INACTIVITY_MINUTES=30
|
SESSION_INACTIVITY_MINUTES=30
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
|
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
|
||||||
"prisma:verify": "ts-node prisma/verify-backfill.ts"
|
"prisma:verify": "ts-node prisma/verify-backfill.ts"
|
||||||
},
|
},
|
||||||
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||||
@@ -29,6 +28,7 @@
|
|||||||
"@nestjs/core": "^11.1.19",
|
"@nestjs/core": "^11.1.19",
|
||||||
"@nestjs/event-emitter": "^2.0.4",
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/microservices": "^11.1.24",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
@@ -47,7 +47,8 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tsconfig-paths": "^4.2.0"
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edr/eslint-config": "workspace:*",
|
"@edr/eslint-config": "workspace:*",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/uuid": "^9.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Add sequence column to Station table if it doesn't exist
|
||||||
|
ALTER TABLE "passenger"."Station" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Add index on sequence for Station
|
||||||
|
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "passenger"."Station"("sequence");
|
||||||
|
|
||||||
|
-- Add sequence column to Coach table if it doesn't exist
|
||||||
|
ALTER TABLE "passenger"."Coach" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Add index on sequence for Coach
|
||||||
|
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "passenger"."Coach"("sequence");
|
||||||
|
|
||||||
|
-- Add missing columns to SeatClass if they don't exist
|
||||||
|
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "premiumMinor" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Add missing columns to User if they don't exist
|
||||||
|
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "gender" VARCHAR(255);
|
||||||
|
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "passportNumber" VARCHAR(255);
|
||||||
|
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "nationalId" VARCHAR(255);
|
||||||
|
|
||||||
|
-- Ensure Ticket has all required columns
|
||||||
|
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "validatedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- Add missing columns to Booking if they don't exist
|
||||||
|
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "bookingType" VARCHAR(255) NOT NULL DEFAULT 'ONE_WAY';
|
||||||
|
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayCurrency" VARCHAR(255);
|
||||||
|
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayTotalMinor" INTEGER;
|
||||||
|
|
||||||
|
-- Ensure all indexes exist
|
||||||
|
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "passenger"."Station"("city", "countryCode");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "passenger"."Coach"("coachTypeId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "passenger"."TrainSchedule"("departureAt", "originStationId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "passenger"."Booking"("passengerId", "status");
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
-- Add CASCADE delete to all foreign key constraints that are missing it
|
||||||
|
|
||||||
|
-- TrainSchedule relations
|
||||||
|
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
|
||||||
|
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "passenger"."Train"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
|
||||||
|
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "passenger"."Route"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
|
||||||
|
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
|
||||||
|
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Coach relation
|
||||||
|
ALTER TABLE "passenger"."Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
|
||||||
|
ALTER TABLE "passenger"."Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "passenger"."CoachType"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- CoachAssignment relations
|
||||||
|
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
|
||||||
|
ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
|
||||||
|
ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "passenger"."Coach"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Booking relations
|
||||||
|
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
|
||||||
|
ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
|
||||||
|
ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- BookingSeat relations
|
||||||
|
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
|
||||||
|
ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
|
||||||
|
ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- PaymentIntent
|
||||||
|
ALTER TABLE "passenger"."PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
|
||||||
|
ALTER TABLE "passenger"."PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- PaymentRefund
|
||||||
|
ALTER TABLE "passenger"."PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
|
||||||
|
ALTER TABLE "passenger"."PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "passenger"."PaymentIntent"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Ticket
|
||||||
|
ALTER TABLE "passenger"."Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
|
||||||
|
ALTER TABLE "passenger"."Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- TicketSeat
|
||||||
|
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
|
||||||
|
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- WalletLedgerEntry
|
||||||
|
ALTER TABLE "passenger"."WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
|
||||||
|
ALTER TABLE "passenger"."WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "passenger"."WalletAccount"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Notification
|
||||||
|
ALTER TABLE "passenger"."Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
|
||||||
|
ALTER TABLE "passenger"."Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- MenuItem
|
||||||
|
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
|
||||||
|
ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
|
||||||
|
ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."MenuCategory"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- FoodOrder
|
||||||
|
ALTER TABLE "passenger"."FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
|
||||||
|
ALTER TABLE "passenger"."FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- FoodOrderItem
|
||||||
|
ALTER TABLE "passenger"."FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
|
||||||
|
ALTER TABLE "passenger"."FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "passenger"."FoodOrder"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- FaqArticle
|
||||||
|
ALTER TABLE "passenger"."FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
|
||||||
|
ALTER TABLE "passenger"."FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."FaqCategory"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- SupportMessage
|
||||||
|
ALTER TABLE "passenger"."SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
|
||||||
|
ALTER TABLE "passenger"."SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "passenger"."SupportConversation"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- TripStopTime
|
||||||
|
ALTER TABLE "passenger"."TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
|
||||||
|
ALTER TABLE "passenger"."TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- TripLiveStatus
|
||||||
|
ALTER TABLE "passenger"."TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
|
||||||
|
ALTER TABLE "passenger"."TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- JourneySegment
|
||||||
|
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
|
||||||
|
ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
|
||||||
|
ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- AgentBooking
|
||||||
|
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
|
||||||
|
ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
|
||||||
|
ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- AgentShift
|
||||||
|
ALTER TABLE "passenger"."AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
|
||||||
|
ALTER TABLE "passenger"."AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- AgentCommission
|
||||||
|
ALTER TABLE "passenger"."AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
|
||||||
|
ALTER TABLE "passenger"."AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- BookingModification
|
||||||
|
ALTER TABLE "passenger"."BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
|
||||||
|
ALTER TABLE "passenger"."BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- BookingCancellation
|
||||||
|
ALTER TABLE "passenger"."BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
|
||||||
|
ALTER TABLE "passenger"."BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- GateValidationLog
|
||||||
|
ALTER TABLE "passenger"."GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
|
||||||
|
ALTER TABLE "passenger"."GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- BaggageBooking
|
||||||
|
ALTER TABLE "passenger"."BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
|
||||||
|
ALTER TABLE "passenger"."BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- RouteFareRule
|
||||||
|
ALTER TABLE "passenger"."RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
|
||||||
|
ALTER TABLE "passenger"."RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- SegmentFareRule
|
||||||
|
ALTER TABLE "passenger"."SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
|
||||||
|
ALTER TABLE "passenger"."SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- StationCrowdSignal
|
||||||
|
ALTER TABLE "passenger"."StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
|
||||||
|
ALTER TABLE "passenger"."StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- SeatBlock
|
||||||
|
ALTER TABLE "passenger"."SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
|
||||||
|
ALTER TABLE "passenger"."SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- SavedRoute
|
||||||
|
ALTER TABLE "passenger"."SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
|
||||||
|
ALTER TABLE "passenger"."SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- LoyaltyLedgerEntry
|
||||||
|
ALTER TABLE "passenger"."LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
|
||||||
|
ALTER TABLE "passenger"."LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- LoyaltyReward
|
||||||
|
ALTER TABLE "passenger"."LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
|
||||||
|
ALTER TABLE "passenger"."LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- FareRule
|
||||||
|
ALTER TABLE "passenger"."FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
|
||||||
|
ALTER TABLE "passenger"."FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
|
||||||
@@ -84,19 +84,20 @@ model CoachType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SeatClass {
|
model SeatClass {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
coachTypeId String
|
coachTypeId String
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
baseFareMinor Int
|
baseFareMinor Int @default(0) // per-km rate
|
||||||
isActive Boolean @default(true)
|
premiumMinor Int @default(0) // flat fee per passenger
|
||||||
createdAt DateTime @default(now())
|
insuranceFeeMinor Int @default(0) // flat fee per passenger
|
||||||
updatedAt DateTime @updatedAt
|
isActive Boolean @default(true)
|
||||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
createdAt DateTime @default(now())
|
||||||
fareRules FareRule[]
|
updatedAt DateTime @updatedAt
|
||||||
routeFareRules RouteFareRule[]
|
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||||
segmentFares SegmentFareRule[]
|
fareRules FareRule[]
|
||||||
|
routeFareRules RouteFareRule[]
|
||||||
|
segmentFares SegmentFareRule[]
|
||||||
@@unique([coachTypeId, name])
|
@@unique([coachTypeId, name])
|
||||||
@@index([coachTypeId])
|
@@index([coachTypeId])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
@@ -226,22 +227,24 @@ enum DevicePlatform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
phone String @unique
|
phone String @unique
|
||||||
fullName String
|
fullName String
|
||||||
passwordHash String
|
passwordHash String
|
||||||
role UserRole @default(PASSENGER)
|
role UserRole @default(PASSENGER)
|
||||||
nationality String?
|
nationality String?
|
||||||
nationalityCode String?
|
nationalityCode String?
|
||||||
passportNumber String?
|
gender String? // Male, Female, Other
|
||||||
nationalId String?
|
dateOfBirth DateTime?
|
||||||
failedLoginAttempts Int @default(0)
|
passportNumber String?
|
||||||
lockedUntil DateTime?
|
nationalId String?
|
||||||
blockedUntil DateTime?
|
failedLoginAttempts Int @default(0)
|
||||||
lastLoginAt DateTime?
|
lockedUntil DateTime?
|
||||||
createdAt DateTime @default(now())
|
blockedUntil DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
lastLoginAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
faydaVerified Boolean @default(false)
|
faydaVerified Boolean @default(false)
|
||||||
faydaVerifiedAt DateTime?
|
faydaVerifiedAt DateTime?
|
||||||
@@ -307,21 +310,22 @@ model TravelerProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Station {
|
model Station {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
city String
|
city String
|
||||||
countryCode String?
|
countryCode String?
|
||||||
isOperational Boolean @default(true)
|
sequence Int @default(0)
|
||||||
timezone String @default("Africa/Addis_Ababa")
|
isOperational Boolean @default(true)
|
||||||
lat Decimal @db.Decimal(9, 6)
|
timezone String @default("Africa/Addis_Ababa")
|
||||||
lng Decimal @db.Decimal(9, 6)
|
lat Decimal @db.Decimal(9, 6)
|
||||||
originSchedules TrainSchedule[] @relation("OriginTrips")
|
lng Decimal @db.Decimal(9, 6)
|
||||||
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
originSchedules TrainSchedule[] @relation("OriginTrips")
|
||||||
|
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
||||||
stopTimes TripStopTime[]
|
stopTimes TripStopTime[]
|
||||||
crowdSignals StationCrowdSignal[]
|
crowdSignals StationCrowdSignal[]
|
||||||
|
|
||||||
@@index([city, countryCode])
|
@@index([city, countryCode])
|
||||||
|
@@index([sequence])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,19 +406,20 @@ model TripLiveStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Coach {
|
model Coach {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
coachTypeId String
|
coachTypeId String
|
||||||
number String @unique
|
number String @unique
|
||||||
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
|
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
|
||||||
capacity Int @default(0) // Total seats/beds
|
capacity Int @default(0) // Total seats/beds
|
||||||
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
sequence Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||||
seats Seat[]
|
seats Seat[]
|
||||||
assignments CoachAssignment[]
|
assignments CoachAssignment[]
|
||||||
|
|
||||||
@@index([coachTypeId])
|
@@index([coachTypeId])
|
||||||
|
@@index([sequence])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,20 +633,21 @@ model PaymentRefund {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Ticket {
|
model Ticket {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
bookingId String @unique
|
bookingId String @unique
|
||||||
bookingRef String
|
bookingRef String
|
||||||
status String @default("CONFIRMED")
|
status String @default("ACTIVE")
|
||||||
qrPayload String
|
qrPayload String
|
||||||
barcodePayload String?
|
barcodePayload String?
|
||||||
pdfUrl String?
|
pdfUrl String?
|
||||||
deliveryChannel String @default("EMAIL")
|
deliveryChannel String @default("EMAIL")
|
||||||
issuedAt DateTime @default(now())
|
issuedAt DateTime @default(now())
|
||||||
validatedAt DateTime?
|
validatedAt DateTime?
|
||||||
validatorId String?
|
validatorId String?
|
||||||
booking Booking @relation(fields: [bookingId], references: [id])
|
boardedAt DateTime?
|
||||||
validationLogs GateValidationLog[]
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
seats TicketSeat[]
|
validationLogs GateValidationLog[]
|
||||||
|
seats TicketSeat[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { randomUUID as uuidv4 } from 'crypto';
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const EDR_ROUTE_ID = uuidv4();
|
|
||||||
const TRAIN_ID = uuidv4();
|
const TRAIN_ID = uuidv4();
|
||||||
|
|
||||||
async function seedSystemUsers() {
|
async function seedSystemUsers() {
|
||||||
@@ -24,6 +23,10 @@ async function seedSystemUsers() {
|
|||||||
phone: '+251900000000',
|
phone: '+251900000000',
|
||||||
passwordHash: adminHash,
|
passwordHash: adminHash,
|
||||||
role: 'ADMIN',
|
role: 'ADMIN',
|
||||||
|
gender: 'Male',
|
||||||
|
dateOfBirth: new Date('1980-05-20'),
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
nationalId: 'ET123456789',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(' ✅ Admin: admin@edr-platform.com / admin123');
|
console.log(' ✅ Admin: admin@edr-platform.com / admin123');
|
||||||
@@ -39,6 +42,9 @@ async function seedSystemUsers() {
|
|||||||
role: 'PASSENGER',
|
role: 'PASSENGER',
|
||||||
nationality: 'Ethiopian',
|
nationality: 'Ethiopian',
|
||||||
faydaVerified: true,
|
faydaVerified: true,
|
||||||
|
gender: 'Male',
|
||||||
|
dateOfBirth: new Date('1990-03-15'),
|
||||||
|
nationalId: 'ET987654321',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +55,7 @@ async function seedSystemUsers() {
|
|||||||
data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' },
|
data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' },
|
||||||
});
|
});
|
||||||
await prisma.walletAccount.create({
|
await prisma.walletAccount.create({
|
||||||
data: { passengerId: passengerRecord.id, balanceMinor: 50000 },
|
data: { passengerId: passengerRecord.id, balanceMinor: 500 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await prisma.userPreferences.upsert({
|
await prisma.userPreferences.upsert({
|
||||||
@@ -68,6 +74,8 @@ async function seedSystemUsers() {
|
|||||||
phone: '+251911111111',
|
phone: '+251911111111',
|
||||||
passwordHash: agentHash,
|
passwordHash: agentHash,
|
||||||
role: 'AGENT',
|
role: 'AGENT',
|
||||||
|
gender: 'Female',
|
||||||
|
dateOfBirth: new Date('1992-07-22'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.agent.upsert({
|
await prisma.agent.upsert({
|
||||||
@@ -86,6 +94,8 @@ async function seedSystemUsers() {
|
|||||||
phone: '+251922222222',
|
phone: '+251922222222',
|
||||||
passwordHash: supervisorHash,
|
passwordHash: supervisorHash,
|
||||||
role: 'SUPERVISOR',
|
role: 'SUPERVISOR',
|
||||||
|
gender: 'Male',
|
||||||
|
dateOfBirth: new Date('1985-11-10'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(' ✅ Supervisor: supervisor@edr-platform.com / supervisor123');
|
console.log(' ✅ Supervisor: supervisor@edr-platform.com / supervisor123');
|
||||||
@@ -99,6 +109,8 @@ async function seedSystemUsers() {
|
|||||||
phone: '+251933333333',
|
phone: '+251933333333',
|
||||||
passwordHash: staffHash,
|
passwordHash: staffHash,
|
||||||
role: 'STAFF',
|
role: 'STAFF',
|
||||||
|
gender: 'Female',
|
||||||
|
dateOfBirth: new Date('1995-09-08'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(' ✅ Staff: staff@edr-platform.com / staff123');
|
console.log(' ✅ Staff: staff@edr-platform.com / staff123');
|
||||||
@@ -107,21 +119,21 @@ async function seedSystemUsers() {
|
|||||||
async function seedStations() {
|
async function seedStations() {
|
||||||
console.log('\n📍 Seeding 15 stations (Ethio-Djibouti Railway)...');
|
console.log('\n📍 Seeding 15 stations (Ethio-Djibouti Railway)...');
|
||||||
const stations = [
|
const stations = [
|
||||||
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150 },
|
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150, sequence: 1 },
|
||||||
{ code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320 },
|
{ code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320, sequence: 2 },
|
||||||
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240 },
|
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240, sequence: 3 },
|
||||||
{ code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130 },
|
{ code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130, sequence: 4 },
|
||||||
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780 },
|
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780, sequence: 5 },
|
||||||
{ code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920 },
|
{ code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920, sequence: 6 },
|
||||||
{ code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450 },
|
{ code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450, sequence: 7 },
|
||||||
{ code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670 },
|
{ code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670, sequence: 8 },
|
||||||
{ code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578 },
|
{ code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578, sequence: 9 },
|
||||||
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150 },
|
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150, sequence: 10 },
|
||||||
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670 },
|
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670, sequence: 11 },
|
||||||
{ code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340 },
|
{ code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340, sequence: 12 },
|
||||||
{ code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560 },
|
{ code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560, sequence: 13 },
|
||||||
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450 },
|
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450, sequence: 14 },
|
||||||
{ code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200 },
|
{ code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200, sequence: 15 },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const station of stations) {
|
for (const station of stations) {
|
||||||
@@ -142,8 +154,8 @@ async function seedCoachTypesAndClasses() {
|
|||||||
console.log('\n🚂 Seeding coach types and seat classes...');
|
console.log('\n🚂 Seeding coach types and seat classes...');
|
||||||
const coachTypes = [
|
const coachTypes = [
|
||||||
{ code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' },
|
{ code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' },
|
||||||
{ code: 'HBC', name: 'Hard Bed Coach', type: 'Economy Bed' },
|
{ code: 'HBC', name: 'Hard Berth Coach', type: 'Economy Bed' },
|
||||||
{ code: 'SBC', name: 'Soft Bed Coach', type: 'VIP Bed' },
|
{ code: 'SBC', name: 'Soft Berth Coach', type: 'VIP Bed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const ct of coachTypes) {
|
for (const ct of coachTypes) {
|
||||||
@@ -155,12 +167,12 @@ async function seedCoachTypesAndClasses() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seatClasses = [
|
const seatClasses = [
|
||||||
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900 },
|
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
|
||||||
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800 },
|
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
|
||||||
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600 },
|
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
|
||||||
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550 },
|
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
|
||||||
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500 },
|
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
|
||||||
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250 },
|
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sc of seatClasses) {
|
for (const sc of seatClasses) {
|
||||||
@@ -168,7 +180,7 @@ async function seedCoachTypesAndClasses() {
|
|||||||
await prisma.seatClass.upsert({
|
await prisma.seatClass.upsert({
|
||||||
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
||||||
update: {},
|
update: {},
|
||||||
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor },
|
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
|
console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
|
||||||
@@ -176,14 +188,12 @@ async function seedCoachTypesAndClasses() {
|
|||||||
|
|
||||||
async function seedRoute() {
|
async function seedRoute() {
|
||||||
console.log('\n🛣️ Seeding route and stops...');
|
console.log('\n🛣️ Seeding route and stops...');
|
||||||
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
|
|
||||||
const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } });
|
|
||||||
|
|
||||||
const route = await prisma.route.upsert({
|
const route = await prisma.route.upsert({
|
||||||
where: { code: 'EDR-101' },
|
where: { code: 'Route-101' },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
code: 'EDR-101',
|
code: 'Route-101',
|
||||||
name: 'Sebeta - Dire Dawa',
|
name: 'Sebeta - Dire Dawa',
|
||||||
description: 'Outbound local route from Sebeta to Dire Dawa',
|
description: 'Outbound local route from Sebeta to Dire Dawa',
|
||||||
effectiveFrom: new Date('2026-01-01'),
|
effectiveFrom: new Date('2026-01-01'),
|
||||||
@@ -193,15 +203,40 @@ async function seedRoute() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE'];
|
const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE'];
|
||||||
|
const routeDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0];
|
||||||
for (let i = 0; i < stationCodes.length; i++) {
|
for (let i = 0; i < stationCodes.length; i++) {
|
||||||
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
|
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
|
||||||
await prisma.routeStop.upsert({
|
await prisma.routeStop.upsert({
|
||||||
where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } },
|
where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } },
|
||||||
update: {},
|
update: {},
|
||||||
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: i * 85 },
|
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(` ✅ Route with ${stationCodes.length} stops created`);
|
|
||||||
|
const returnRoute = await prisma.route.upsert({
|
||||||
|
where: { code: 'Route-102' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'Route-102',
|
||||||
|
name: 'Dire Dawa - Sebeta',
|
||||||
|
description: 'Inbound local route from Dire Dawa to Sebeta',
|
||||||
|
effectiveFrom: new Date('2026-01-01'),
|
||||||
|
effectiveUntil: new Date('2034-12-31'),
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const returnStationCodes = ['DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
|
||||||
|
const returnRouteDistancesKm = [0, 119.4, 181.4, 232.8, 306.3, 323.1, 345.8, 401.5, 413.0];
|
||||||
|
for (let i = 0; i < returnStationCodes.length; i++) {
|
||||||
|
const station = await prisma.station.findUnique({ where: { code: returnStationCodes[i] } });
|
||||||
|
await prisma.routeStop.upsert({
|
||||||
|
where: { routeId_sequence: { routeId: returnRoute!.id, sequence: i + 1 } },
|
||||||
|
update: {},
|
||||||
|
create: { routeId: returnRoute!.id, stationId: station!.id, sequence: i + 1, distanceKm: returnRouteDistancesKm[i] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedCoaches() {
|
async function seedCoaches() {
|
||||||
@@ -211,9 +246,9 @@ async function seedCoaches() {
|
|||||||
const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } });
|
const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } });
|
||||||
|
|
||||||
const coaches = [
|
const coaches = [
|
||||||
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 40 },
|
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 128, sequence: 1 },
|
||||||
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66 },
|
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66, sequence: 2 },
|
||||||
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 120 },
|
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 40, sequence: 3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
let totalSeats = 0;
|
let totalSeats = 0;
|
||||||
@@ -230,19 +265,23 @@ async function seedCoaches() {
|
|||||||
// FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them.
|
// FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them.
|
||||||
let seatIndex = 1;
|
let seatIndex = 1;
|
||||||
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
|
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
|
||||||
for (const col of ['A', 'B', 'C', 'D']) {
|
for (const col of ['A', 'B', 'C', 'D', 'E']) {
|
||||||
if (seatIndex > coach.capacity) break;
|
if (seatIndex > coach.capacity) break;
|
||||||
let bedPosition: string | null = null;
|
let bedPosition: string | null = null;
|
||||||
if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) {
|
if (c.coachTypeId === ecoBedCoachType!.id) {
|
||||||
|
// Economy Bed: 3-row cycle (upper, middle, lower)
|
||||||
if (row % 3 === 1) bedPosition = 'upper';
|
if (row % 3 === 1) bedPosition = 'upper';
|
||||||
else if (row % 3 === 2) bedPosition = 'middle';
|
else if (row % 3 === 2) bedPosition = 'middle';
|
||||||
else bedPosition = 'lower';
|
else bedPosition = 'lower';
|
||||||
|
} else if (c.coachTypeId === vipBedCoachType!.id) {
|
||||||
|
// VIP Bed: 2-row cycle (upper, lower)
|
||||||
|
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
|
||||||
}
|
}
|
||||||
|
|
||||||
const seatData = {
|
const seatData = {
|
||||||
seatNumber: seatIndex.toString(),
|
seatNumber: seatIndex.toString(),
|
||||||
isWindow: col === 'A' || col === 'D',
|
isWindow: col === 'A' || col === 'E',
|
||||||
isAisle: col === 'B' || col === 'C',
|
isAisle: col === 'B' || col === 'C' || col === 'D',
|
||||||
bedPosition,
|
bedPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,67 +303,102 @@ async function seedTrips() {
|
|||||||
const train = await prisma.train.upsert({
|
const train = await prisma.train.upsert({
|
||||||
where: { number: 'EDR-001' },
|
where: { number: 'EDR-001' },
|
||||||
update: {},
|
update: {},
|
||||||
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' },
|
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Express Service' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
|
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
||||||
|
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
|
||||||
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
|
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
|
||||||
const lastStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
|
const lastStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
|
||||||
|
const firstReturnStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
|
||||||
|
const lastReturnStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
|
||||||
const coaches = await prisma.coach.findMany();
|
const coaches = await prisma.coach.findMany();
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(now.getDate() + 1);
|
||||||
|
|
||||||
const schedules = [];
|
const schedules = [];
|
||||||
|
|
||||||
for (let d = 0; d < 30; d++) {
|
for (let d = 0; d < 5; d++) {
|
||||||
const tripDate = new Date(now);
|
const tripDate = new Date(now);
|
||||||
tripDate.setDate(tripDate.getDate() + d);
|
tripDate.setDate(tripDate.getDate() + d);
|
||||||
tripDate.setHours(8, 0, 0, 0);
|
tripDate.setHours(20, 30, 0, 0);
|
||||||
|
|
||||||
const departureAt = new Date(tripDate);
|
|
||||||
const arrivalAt = new Date(departureAt.getTime() + 4 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
schedules.push({
|
schedules.push({
|
||||||
trainId: train.id,
|
trainId: train.id,
|
||||||
routeId: route!.id,
|
routeId: route!.id,
|
||||||
originStationId: firstStation!.id,
|
originStationId: firstStation!.id,
|
||||||
destinationStationId: lastStation!.id,
|
destinationStationId: lastStation!.id,
|
||||||
departureAt,
|
departureAt: new Date(tripDate),
|
||||||
arrivalAt,
|
arrivalAt: new Date(tripDate), // patched below
|
||||||
durationMinutes: 4 * 24 * 60,
|
durationMinutes: 0, // patched below
|
||||||
stopsCount: 15,
|
stopsCount: 9,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdSchedules = await Promise.all(
|
for (let d = 0; d < 5; d++) {
|
||||||
schedules.map(s => prisma.trainSchedule.create({ data: s }))
|
const returnTripDate = new Date(tomorrow);
|
||||||
);
|
returnTripDate.setDate(returnTripDate.getDate() + d);
|
||||||
|
returnTripDate.setHours(20, 0, 0, 0);
|
||||||
|
schedules.push({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: returnRoute!.id,
|
||||||
|
originStationId: firstReturnStation!.id,
|
||||||
|
destinationStationId: lastReturnStation!.id,
|
||||||
|
departureAt: new Date(returnTripDate),
|
||||||
|
arrivalAt: new Date(returnTripDate), // patched below
|
||||||
|
durationMinutes: 0, // patched below
|
||||||
|
stopsCount: 9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create TripStopTimes for each schedule
|
// Load route stops for both routes upfront
|
||||||
const routeStops = await prisma.routeStop.findMany({
|
const routeStopsMap = new Map<string, { stationId: string; sequence: number; distanceKm: number }[]>();
|
||||||
where: { routeId: route!.id },
|
for (const r of [route!, returnRoute!]) {
|
||||||
orderBy: { sequence: 'asc' },
|
const stops = await prisma.routeStop.findMany({
|
||||||
include: { route: true },
|
where: { routeId: r.id },
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
});
|
||||||
|
routeStopsMap.set(r.id, stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm! })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute duration from total route distance at 60 km/h
|
||||||
|
function routeDuration(stops: { distanceKm: number }[]): number {
|
||||||
|
const totalKm = stops[stops.length - 1].distanceKm - stops[0].distanceKm;
|
||||||
|
return Math.ceil(totalKm / 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch arrivalAt and durationMinutes using distance-based timing
|
||||||
|
const patchedSchedules = schedules.map(s => {
|
||||||
|
const stops = routeStopsMap.get(s.routeId!)!;
|
||||||
|
const durationMinutes = routeDuration(stops);
|
||||||
|
return { ...s, durationMinutes, arrivalAt: new Date(s.departureAt.getTime() + durationMinutes * 60_000) };
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const schedule of createdSchedules) {
|
const createdSchedules = await Promise.all(
|
||||||
const stopTimes = [];
|
patchedSchedules.map(s => prisma.trainSchedule.create({ data: s }))
|
||||||
for (const routeStop of routeStops) {
|
);
|
||||||
const minutesFromStart = (routeStop.sequence - 1) * 480; // 8 hours per stop
|
|
||||||
const plannedDepartureAt = new Date(schedule.departureAt.getTime() + minutesFromStart * 60_000);
|
|
||||||
const plannedArrivalAt = new Date(plannedDepartureAt.getTime() + 30 * 60_000); // 30 min stop
|
|
||||||
|
|
||||||
stopTimes.push({
|
// Create TripStopTimes using cumulative distanceKm at 60 km/h
|
||||||
|
for (const schedule of createdSchedules) {
|
||||||
|
const stops = routeStopsMap.get(schedule.routeId!)!;
|
||||||
|
const originKm = stops[0].distanceKm;
|
||||||
|
const stopTimes = stops.map(stop => {
|
||||||
|
const minutesFromStart = Math.ceil((stop.distanceKm - originKm) / 60 * 60);
|
||||||
|
const plannedDepartureAt = new Date(schedule.departureAt.getTime() + minutesFromStart * 60_000);
|
||||||
|
const plannedArrivalAt = new Date(plannedDepartureAt.getTime() - 5 * 60_000); // 5 min dwell
|
||||||
|
return {
|
||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
stationId: routeStop.stationId,
|
stationId: stop.stationId,
|
||||||
sequence: routeStop.sequence,
|
sequence: stop.sequence,
|
||||||
plannedArrivalAt,
|
plannedArrivalAt,
|
||||||
plannedDepartureAt,
|
plannedDepartureAt,
|
||||||
});
|
};
|
||||||
}
|
});
|
||||||
|
// First stop: arrival = departure (no dwell at origin)
|
||||||
|
stopTimes[0].plannedArrivalAt = stopTimes[0].plannedDepartureAt;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(stopTimes.map(st => prisma.tripStopTime.create({ data: st })));
|
||||||
stopTimes.map(st => prisma.tripStopTime.create({ data: st }))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const coachAssignments = [];
|
const coachAssignments = [];
|
||||||
@@ -355,7 +429,7 @@ async function seedTrips() {
|
|||||||
|
|
||||||
async function seedFareRules() {
|
async function seedFareRules() {
|
||||||
console.log('\n💰 Seeding fare rules...');
|
console.log('\n💰 Seeding fare rules...');
|
||||||
const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
|
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
||||||
const seatClasses = await prisma.seatClass.findMany();
|
const seatClasses = await prisma.seatClass.findMany();
|
||||||
const validFrom = new Date('2024-01-01');
|
const validFrom = new Date('2024-01-01');
|
||||||
|
|
||||||
@@ -374,7 +448,7 @@ async function seedFareRules() {
|
|||||||
seatClassId: sc.id,
|
seatClassId: sc.id,
|
||||||
passengerCategory: 'CHILD' as const,
|
passengerCategory: 'CHILD' as const,
|
||||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
|
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
|
||||||
discountPercent: 50,
|
discountPercent: 10,
|
||||||
currency: 'ETB',
|
currency: 'ETB',
|
||||||
validFrom,
|
validFrom,
|
||||||
});
|
});
|
||||||
@@ -422,6 +496,7 @@ async function seedPaymentMethods() {
|
|||||||
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' },
|
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' },
|
||||||
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' },
|
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' },
|
||||||
{ type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' },
|
{ type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' },
|
||||||
|
{ type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' },
|
||||||
{ type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' },
|
{ type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' },
|
||||||
{ type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' },
|
{ type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' },
|
||||||
];
|
];
|
||||||
@@ -436,13 +511,50 @@ async function seedPaymentMethods() {
|
|||||||
console.log(` ✅ ${methods.length} payment methods created`);
|
console.log(` ✅ ${methods.length} payment methods created`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedSegmentFares() {
|
||||||
|
console.log('\n📍 Seeding segment fare rules...');
|
||||||
|
const route = await prisma.route.findUnique({
|
||||||
|
where: { code: 'Route-101' },
|
||||||
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
|
});
|
||||||
|
const seatClasses = await prisma.seatClass.findMany();
|
||||||
|
const validFrom = new Date('2024-01-01');
|
||||||
|
|
||||||
|
if (route && route.stops.length > 2) {
|
||||||
|
for (const sc of seatClasses) {
|
||||||
|
await prisma.segmentFareRule.create({
|
||||||
|
data: {
|
||||||
|
routeId: route.id,
|
||||||
|
seatClassId: sc.id,
|
||||||
|
originStopSequence: 1,
|
||||||
|
destinationStopSequence: 3,
|
||||||
|
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
|
||||||
|
validFrom,
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await prisma.segmentFareRule.create({
|
||||||
|
data: {
|
||||||
|
routeId: route.id,
|
||||||
|
seatClassId: sc.id,
|
||||||
|
originStopSequence: 5,
|
||||||
|
destinationStopSequence: 9,
|
||||||
|
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
|
||||||
|
validFrom,
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function seedNotificationTemplates() {
|
async function seedNotificationTemplates() {
|
||||||
console.log('\n🔔 Seeding notification templates...');
|
console.log('\n🔔 Seeding notification templates...');
|
||||||
const templates = [
|
const templates = [
|
||||||
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed' },
|
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' },
|
||||||
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment received for {{bookingRef}}' },
|
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' },
|
||||||
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip departs in {{minutes}} minutes' },
|
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
|
||||||
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip is delayed by {{delayMinutes}} minutes' },
|
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
|
||||||
{ id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
|
{ id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -476,13 +588,13 @@ async function seedMenuAndFood() {
|
|||||||
const sandwichId = uuidv4();
|
const sandwichId = uuidv4();
|
||||||
|
|
||||||
await prisma.menuItem.create({
|
await prisma.menuItem.create({
|
||||||
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 5000 },
|
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
|
||||||
}).catch(() => {}); // ignore if exists
|
}).catch(() => {}); // ignore if exists
|
||||||
await prisma.menuItem.create({
|
await prisma.menuItem.create({
|
||||||
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 3500 },
|
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
|
||||||
}).catch(() => {}); // ignore if exists
|
}).catch(() => {}); // ignore if exists
|
||||||
await prisma.menuItem.create({
|
await prisma.menuItem.create({
|
||||||
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 8000 },
|
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
|
||||||
}).catch(() => {}); // ignore if exists
|
}).catch(() => {}); // ignore if exists
|
||||||
}
|
}
|
||||||
console.log(` ✅ Menu categories and items created`);
|
console.log(` ✅ Menu categories and items created`);
|
||||||
@@ -493,7 +605,7 @@ async function seedPromotions() {
|
|||||||
const promos = [
|
const promos = [
|
||||||
{ id: uuidv4(), title: 'Early Bird Discount', code: 'EARLY20', percentOff: 20, validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
|
{ id: uuidv4(), title: 'Early Bird Discount', code: 'EARLY20', percentOff: 20, validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
|
||||||
{ id: uuidv4(), title: 'Student Discount', code: 'STUDENT15', percentOff: 15, validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) },
|
{ id: uuidv4(), title: 'Student Discount', code: 'STUDENT15', percentOff: 15, validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) },
|
||||||
{ id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 10000, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
|
{ id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 100, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const p of promos) {
|
for (const p of promos) {
|
||||||
@@ -583,6 +695,7 @@ async function main() {
|
|||||||
['promotions', seedPromotions],
|
['promotions', seedPromotions],
|
||||||
['FAQ', seedFAQ],
|
['FAQ', seedFAQ],
|
||||||
['fraud rules', seedFraudRules],
|
['fraud rules', seedFraudRules],
|
||||||
|
['segment fares', seedSegmentFares],
|
||||||
];
|
];
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { PrismaModule } from './common/prisma.module';
|
import { PrismaModule } from './common/prisma.module';
|
||||||
|
import { AuditModule } from './common/audit.module';
|
||||||
import { I18nModule } from './common/i18n/i18n.module';
|
import { I18nModule } from './common/i18n/i18n.module';
|
||||||
import { IamModule } from './common/iam.module';
|
import { IamModule } from './common/iam.module';
|
||||||
import { LocaleMiddleware } from './common/i18n/locale.middleware';
|
import { LocaleMiddleware } from './common/i18n/locale.middleware';
|
||||||
@@ -39,6 +40,8 @@ import { FraudModule } from './modules/fraud/fraud.module';
|
|||||||
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
||||||
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
||||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
|
import { AuditModuleFeature } from './modules/audit/audit.module';
|
||||||
|
import { CurrenciesModule } from './modules/currencies/currencies.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -59,6 +62,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
AuditModule,
|
||||||
I18nModule,
|
I18nModule,
|
||||||
IamModule,
|
IamModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -85,6 +89,8 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
|||||||
SeatClassesModule,
|
SeatClassesModule,
|
||||||
FareEngineModule,
|
FareEngineModule,
|
||||||
VerifaydaModule,
|
VerifaydaModule,
|
||||||
|
AuditModuleFeature,
|
||||||
|
CurrenciesModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from './prisma.module';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Injectable, Inject, Optional } from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
@Optional() @Inject(REQUEST) private request?: any,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async log(input: {
|
||||||
|
userId?: string;
|
||||||
|
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
|
||||||
|
entityType: string;
|
||||||
|
entityId?: string;
|
||||||
|
oldData?: any;
|
||||||
|
newData?: any;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const ipAddress = this.getIpAddress();
|
||||||
|
const userAgent = this.getUserAgent();
|
||||||
|
|
||||||
|
await this.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: input.userId,
|
||||||
|
action: input.action,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
oldData: input.oldData,
|
||||||
|
newData: input.newData,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to log audit event:', error);
|
||||||
|
// Don't throw - audit logging should not break main operations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIpAddress(): string {
|
||||||
|
if (!this.request) return '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
|
||||||
|
this.request.headers['x-real-ip'] ||
|
||||||
|
this.request.connection?.remoteAddress ||
|
||||||
|
this.request.socket?.remoteAddress ||
|
||||||
|
this.request.ip ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUserAgent(): string {
|
||||||
|
return this.request?.headers?.['user-agent'] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(filters: any = {}) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
where.OR = [
|
||||||
|
{ entityId: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
|
||||||
|
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.action) {
|
||||||
|
where.action = filters.action;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.entityType) {
|
||||||
|
where.entityType = filters.entityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
include: { user: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 500, // Limit to last 500 logs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLog(id: string) {
|
||||||
|
return this.prisma.auditLog.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { registerAs } from '@nestjs/config';
|
|||||||
* never interfere. Points at the dedicated `payment` vhost on the shared broker.
|
* never interfere. Points at the dedicated `payment` vhost on the shared broker.
|
||||||
*/
|
*/
|
||||||
export default registerAs('rabbitmq', () => ({
|
export default registerAs('rabbitmq', () => ({
|
||||||
url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment',
|
url: process.env.PAYMENT_RABBITMQ_URL,
|
||||||
/** Max unacked payment events held by this consumer at once. */
|
/** Max unacked payment events held by this consumer at once. */
|
||||||
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
|
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ async function bootstrap() {
|
|||||||
## Overview
|
## Overview
|
||||||
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
|
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
|
||||||
|
|
||||||
|
## 🆕 Latest Updates
|
||||||
|
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display
|
||||||
|
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles
|
||||||
|
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing
|
||||||
|
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
|
||||||
|
- **Multi-Currency Display:** Bookings track display currency and converted amounts
|
||||||
|
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### 🎫 Booking Lifecycle
|
### 🎫 Booking Lifecycle
|
||||||
@@ -47,6 +55,11 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
|||||||
- Modify bookings (seat changes, passenger updates)
|
- Modify bookings (seat changes, passenger updates)
|
||||||
- Cancel bookings with automatic refunds
|
- Cancel bookings with automatic refunds
|
||||||
- Multi-segment journey support
|
- Multi-segment journey support
|
||||||
|
- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti)
|
||||||
|
- Round-trip booking with return journey scheduling
|
||||||
|
- Coach type selection with seat class and pricing options
|
||||||
|
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
|
||||||
|
- **NEW:** Display currency and converted pricing per booking
|
||||||
|
|
||||||
### 👤 Passenger Verification
|
### 👤 Passenger Verification
|
||||||
1. **Ethiopian Nationals:**
|
1. **Ethiopian Nationals:**
|
||||||
@@ -65,12 +78,13 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
|||||||
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
|
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
|
||||||
- Automatic age calculation from date of birth
|
- Automatic age calculation from date of birth
|
||||||
- Example: 2 adults + 3 children = 4× base fare (first child free)
|
- Example: 2 adults + 3 children = 4× base fare (first child free)
|
||||||
|
- **NEW:** Premium charges and insurance fees per seat class
|
||||||
|
- **NEW:** Transparent fee breakdown in pricing calculations
|
||||||
|
|
||||||
### 💳 Payment Integration
|
### 💳 Payment Integration
|
||||||
1. **Ethiopian Payment Methods:**
|
1. **Ethiopian Payment Methods:**
|
||||||
- **Telebirr** - Ethiopia's leading mobile money
|
- **Telebirr** - Ethiopia's leading mobile money
|
||||||
- **CBE Birr** - Commercial Bank of Ethiopia
|
- **CBE Birr** - Commercial Bank of Ethiopia
|
||||||
- **eBirr** - Electronic payment gateway
|
|
||||||
|
|
||||||
2. **Djiboutian Payment Methods:**
|
2. **Djiboutian Payment Methods:**
|
||||||
- **Waafi** - Djibouti's mobile money service
|
- **Waafi** - Djibouti's mobile money service
|
||||||
@@ -84,8 +98,9 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
|||||||
- Seat holds with 15-minute expiry
|
- Seat holds with 15-minute expiry
|
||||||
- Auto-assign seats with contiguous algorithm
|
- Auto-assign seats with contiguous algorithm
|
||||||
- Seat blocking for maintenance
|
- Seat blocking for maintenance
|
||||||
- Coach-level seat maps
|
- Coach-level seat maps (ordered by sequence)
|
||||||
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
|
- Class-based seating (Economy Regular, Economy Bed, VIP Bed)
|
||||||
|
- **NEW:** Sequence-based coach ordering for consistent display
|
||||||
|
|
||||||
### 🎟️ Ticketing
|
### 🎟️ Ticketing
|
||||||
- QR code and barcode generation
|
- QR code and barcode generation
|
||||||
@@ -93,6 +108,8 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
|||||||
- Gate validation with audit logs
|
- Gate validation with audit logs
|
||||||
- Offline validation support
|
- Offline validation support
|
||||||
- Multi-passenger tickets
|
- Multi-passenger tickets
|
||||||
|
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
|
||||||
|
- **NEW:** Complete audit trail for compliance and reporting
|
||||||
|
|
||||||
### 🏆 Loyalty Program
|
### 🏆 Loyalty Program
|
||||||
- 4 tiers: Bronze, Silver, Gold, Platinum
|
- 4 tiers: Bronze, Silver, Gold, Platinum
|
||||||
@@ -118,16 +135,50 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
|||||||
- Failed payment pattern detection
|
- Failed payment pattern detection
|
||||||
- Automatic user blocking
|
- Automatic user blocking
|
||||||
|
|
||||||
|
### 👤 Passenger Profiles
|
||||||
|
- Comprehensive profile data: gender, date of birth, nationality
|
||||||
|
- National ID for Ethiopian citizens (Fayda verified)
|
||||||
|
- Passport information for international passengers
|
||||||
|
- **NEW:** Complete demographic data for personalized services
|
||||||
|
- **NEW:** Improved user targeting and communications
|
||||||
|
|
||||||
### 🌍 Internationalization
|
### 🌍 Internationalization
|
||||||
- Multi-language support (English, Amharic, French, Oromo)
|
- Multi-language support (English, Amharic, French, Oromo)
|
||||||
- Locale-based responses
|
- Locale-based responses
|
||||||
- Currency formatting (ETB, DJF, USD)
|
- Currency formatting (ETB, DJF, USD)
|
||||||
|
- **NEW:** Multi-currency display per booking (ETB, DJF, USD)
|
||||||
|
|
||||||
### 👨💼 Agent Operations
|
### 🚌 Transit Stop Management
|
||||||
- Counter booking
|
- Automatic detection of cross-border journeys (Ethiopia → Djibouti)
|
||||||
- Shift management
|
- Dire Dawa as mandatory transit hub for international journeys
|
||||||
- Commission tracking
|
- Dual-leg fare calculation (domestic + international)
|
||||||
- Cash reconciliation
|
- Age-based pricing applied independently per leg
|
||||||
|
- Seamless multi-segment booking workflow
|
||||||
|
- Transit stop optimization and route planning
|
||||||
|
|
||||||
|
### 🔄 Round-Trip Booking
|
||||||
|
- One-way and round-trip journey options
|
||||||
|
- Flexible return date selection
|
||||||
|
- Combined pricing for outbound + return legs
|
||||||
|
- Separate seat management per leg
|
||||||
|
- Independent modification/cancellation per leg
|
||||||
|
- Return journey tracking and notifications
|
||||||
|
- **NEW:** Booking type stored for analytics and reporting
|
||||||
|
|
||||||
|
### 🚐 Coach Type & Class Selection
|
||||||
|
- Browse available coach types per route (standard coaches, premium coaches)
|
||||||
|
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
|
||||||
|
- Compare base prices by coach type and class
|
||||||
|
- Real-time availability per coach configuration
|
||||||
|
- Deferred pricing at seat selection stage
|
||||||
|
- Coach amenities and features display
|
||||||
|
- **NEW:** Sequence-based coach ordering for consistent UI
|
||||||
|
- **NEW:** Premium and insurance fee transparency per class
|
||||||
|
|
||||||
|
### 📊 Data Organization
|
||||||
|
- **Stations:** Ordered by sequence (1-15) for consistent route display
|
||||||
|
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
|
||||||
|
- **Booking History:** Sorted chronologically with filtering options
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
@@ -197,7 +248,6 @@ List endpoints support pagination:
|
|||||||
Payment providers send notifications to:
|
Payment providers send notifications to:
|
||||||
- \`POST /payments/webhooks/telebirr\` (Ethiopia)
|
- \`POST /payments/webhooks/telebirr\` (Ethiopia)
|
||||||
- \`POST /payments/webhooks/cbe-birr\` (Ethiopia)
|
- \`POST /payments/webhooks/cbe-birr\` (Ethiopia)
|
||||||
- \`POST /payments/webhooks/ebirr\` (Ethiopia)
|
|
||||||
- \`POST /payments/webhooks/waafi\` (Djibouti)
|
- \`POST /payments/webhooks/waafi\` (Djibouti)
|
||||||
- \`POST /payments/webhooks/card\` (International)
|
- \`POST /payments/webhooks/card\` (International)
|
||||||
|
|
||||||
@@ -212,33 +262,38 @@ Payment providers send notifications to:
|
|||||||
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
|
{ type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" },
|
||||||
"JWT-auth",
|
"JWT-auth",
|
||||||
)
|
)
|
||||||
.addTag("Agents", "Counter booking, shift management, and commission tracking")
|
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
|
||||||
.addTag("Auth", "User registration, login, and profile management")
|
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
|
||||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel")
|
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
|
||||||
.addTag("Dashboard", "Aggregated dashboard data for home screen")
|
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout")
|
||||||
.addTag("Fare Engine", "Distance-based fare calculator with multi-currency support")
|
.addTag("Config", "System settings, feature flags, and configuration management")
|
||||||
.addTag("Fayda Verification", "Ethiopian national ID verification via government API")
|
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
|
||||||
.addTag("Fleet", "Train services, coaches, and seat configurations")
|
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
|
||||||
.addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking")
|
.addTag("Fare Engine", "Distance-based fare calculation with age-based pricing and multi-currency")
|
||||||
.addTag("Live Tracking", "Real-time trip status, delays, and station crowds")
|
.addTag("Fayda Verification", "Ethiopian national ID verification via Verifayda 2.0 government API")
|
||||||
.addTag("Loyalty", "Points accumulation, tiers, and reward redemption")
|
.addTag("Fleet", "Train services, coaches, coach types, seat classes, amenities, and configurations")
|
||||||
.addTag("Notifications", "Multi-channel notifications: email, SMS, push")
|
.addTag("Fraud Detection", "Velocity checks, monitoring alerts, pattern detection, and user blocking")
|
||||||
.addTag("Passengers", "Passenger registration, verification, and profiles")
|
.addTag("Internal Payments", "Internal payment tracking, wallet transactions, and balance management")
|
||||||
.addTag("Payment", "Payment processing, intents, and refunds")
|
.addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals")
|
||||||
.addTag("Payment Webhooks", "Payment provider webhook handlers")
|
.addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards")
|
||||||
.addTag("Promotions", "Promo codes, campaigns, and discount management")
|
.addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management")
|
||||||
.addTag("Reports", "Sales reports, occupancy analytics, and metrics")
|
.addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles")
|
||||||
.addTag("Routes", "Route templates with stops and fare rules")
|
.addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds")
|
||||||
.addTag("Schedule", "Trip schedules, availability, and status updates")
|
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
|
||||||
.addTag("Search", "Trip search, availability checks, and fare quotes")
|
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
|
||||||
.addTag("Seat Classes", "Seat class management: Economy, VIP configurations")
|
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
|
||||||
.addTag("Seats", "Seat maps, holds, releases, and blocking")
|
.addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
|
||||||
.addTag("Segment-based Seats", "Segment-level seat allocation and availability")
|
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
|
||||||
.addTag("Stations", "Station directory and information")
|
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
|
||||||
.addTag("Support", "FAQ management and live chat support")
|
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
|
||||||
.addTag("Tickets", "QR ticket generation, PDFs, and gate validation")
|
.addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed class configuration and pricing")
|
||||||
.addTag("Wallet", "Wallet balance, top-ups, and transaction ledger")
|
.addTag("Seats", "Seat maps, holds (15-min expiry), releases, blocking, and inventory")
|
||||||
.addTag("Config", "System configuration and settings")
|
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
|
||||||
|
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
|
||||||
|
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
|
||||||
|
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
|
||||||
|
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
|
||||||
|
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
|
||||||
//.addServer('http://localhost:4000', 'Development')
|
//.addServer('http://localhost:4000', 'Development')
|
||||||
// .addServer("https://api.edr-platform.com", "Production")
|
// .addServer("https://api.edr-platform.com", "Production")
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { IamGuard } from '../../common/iam-adapter';
|
||||||
|
|
||||||
|
@ApiTags('Audit')
|
||||||
|
@Controller('audit')
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private auditService: AuditService) {}
|
||||||
|
|
||||||
|
@Get('logs')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get audit logs',
|
||||||
|
description: 'Retrieve system audit logs with optional filtering',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' })
|
||||||
|
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' })
|
||||||
|
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' })
|
||||||
|
async getLogs(
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('action') action?: string,
|
||||||
|
@Query('entityType') entityType?: string,
|
||||||
|
) {
|
||||||
|
const filters = {
|
||||||
|
search: search || undefined,
|
||||||
|
action: action || undefined,
|
||||||
|
entityType: entityType || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = await this.auditService.getLogs(filters);
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('logs/:id')
|
||||||
|
@ApiOperation({ summary: 'Get audit log by ID' })
|
||||||
|
async getLog(@Param('id') id: string) {
|
||||||
|
return this.auditService.getLog(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { AuditModule } from '../../common/audit.module';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuditModule, HttpModule],
|
||||||
|
controllers: [AuditController],
|
||||||
|
})
|
||||||
|
export class AuditModuleFeature {}
|
||||||
@@ -14,6 +14,18 @@ export class PassengerInputDto {
|
|||||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
|
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RoundTripPassengerDto {
|
||||||
|
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
|
||||||
|
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
|
||||||
|
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
|
||||||
|
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
|
||||||
|
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateBookingDto {
|
export class CreateBookingDto {
|
||||||
@ApiProperty() @IsString() passengerId: string;
|
@ApiProperty() @IsString() passengerId: string;
|
||||||
@ApiProperty() @IsString() scheduleId: string;
|
@ApiProperty() @IsString() scheduleId: string;
|
||||||
@@ -29,6 +41,29 @@ export class CreateBookingDto {
|
|||||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateRoundTripBookingDto {
|
||||||
|
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
|
||||||
|
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
|
||||||
|
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
|
||||||
|
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
|
||||||
|
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
|
||||||
|
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
|
||||||
|
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
|
||||||
|
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||||
|
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||||
|
}
|
||||||
|
|
||||||
export class ModifyBookingDto {
|
export class ModifyBookingDto {
|
||||||
@ApiProperty() @IsString() bookingRef: string;
|
@ApiProperty() @IsString() bookingRef: string;
|
||||||
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;
|
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { AuditModule } from '../../common/audit.module';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { GuestBookingService } from './guest-booking.service';
|
import { GuestBookingService } from './guest-booking.service';
|
||||||
@@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
|||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [BookingsService, GuestBookingService],
|
providers: [BookingsService, GuestBookingService],
|
||||||
exports: [BookingsService, GuestBookingService]
|
exports: [BookingsService, GuestBookingService]
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const primaryNationality = passengersData[0]?.nationality;
|
const primaryNationality = passengersData[0]?.nationality;
|
||||||
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality);
|
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
|
||||||
const adultFareMinor = baseFareMinor * adultCount;
|
const adultFareMinor = baseFareMinor * adultCount;
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||||
@@ -363,8 +363,60 @@ export class BookingsService {
|
|||||||
segmentRoute?: string,
|
segmentRoute?: string,
|
||||||
fullRoute?: string,
|
fullRoute?: string,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
|
originStopSeq?: number,
|
||||||
|
destStopSeq?: number,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
// Get schedule with route info
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
include: { route: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try segment fare rule first (most specific) if route info available
|
||||||
|
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
|
||||||
|
// Try with nationality first
|
||||||
|
const segmentFare = await this.prisma.segmentFareRule.findFirst({
|
||||||
|
where: {
|
||||||
|
routeId: schedule.routeId,
|
||||||
|
originStopSequence: originStopSeq,
|
||||||
|
destinationStopSequence: destStopSeq,
|
||||||
|
seatClassId,
|
||||||
|
nationality: nationality || null,
|
||||||
|
validFrom: { lte: now },
|
||||||
|
OR: [
|
||||||
|
{ validUntil: null },
|
||||||
|
{ validUntil: { gte: now } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (segmentFare) {
|
||||||
|
return segmentFare.baseFareMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no segment fare with nationality, try without nationality filter
|
||||||
|
if (nationality) {
|
||||||
|
const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
|
||||||
|
where: {
|
||||||
|
routeId: schedule.routeId,
|
||||||
|
originStopSequence: originStopSeq,
|
||||||
|
destinationStopSequence: destStopSeq,
|
||||||
|
seatClassId,
|
||||||
|
nationality: null,
|
||||||
|
validFrom: { lte: now },
|
||||||
|
OR: [
|
||||||
|
{ validUntil: null },
|
||||||
|
{ validUntil: { gte: now } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (segmentFareAny) return segmentFareAny.baseFareMinor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to fare rules if no segment fare found
|
||||||
const candidates = await this.prisma.fareRule.findMany({
|
const candidates = await this.prisma.fareRule.findMany({
|
||||||
where: {
|
where: {
|
||||||
seatClassId,
|
seatClassId,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { CurrenciesService } from './currencies.service';
|
||||||
|
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||||
|
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||||
|
|
||||||
|
@ApiTags('Currencies')
|
||||||
|
@Controller('currencies')
|
||||||
|
export class CurrenciesController {
|
||||||
|
constructor(private currenciesService: CurrenciesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getAllCurrencies() {
|
||||||
|
return this.currenciesService.getAllCurrencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@IamRoles('ADMIN')
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@HttpCode(201)
|
||||||
|
createCurrency(@Body() dto: CreateCurrencyDto) {
|
||||||
|
return this.currenciesService.createCurrency(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@IamRoles('ADMIN')
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) {
|
||||||
|
return this.currenciesService.updateCurrency(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@IamRoles('ADMIN')
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
deleteCurrency(@Param('id') id: string) {
|
||||||
|
return this.currenciesService.deleteCurrency(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync-rates')
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@IamRoles('ADMIN')
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@HttpCode(200)
|
||||||
|
syncRates() {
|
||||||
|
return this.currenciesService.syncExchangeRates();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { IsString, IsNumber, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCurrencyDto {
|
||||||
|
@IsString()
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
symbol: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
baseCurrencyCode?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.0001)
|
||||||
|
exchangeRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCurrencyDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
symbol?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@Min(0.0001)
|
||||||
|
exchangeRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CurrencyResponseDto {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
baseCurrencyCode: string;
|
||||||
|
exchangeRate: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { CurrenciesController } from './currencies.controller';
|
||||||
|
import { CurrenciesService } from './currencies.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [HttpModule],
|
||||||
|
controllers: [CurrenciesController],
|
||||||
|
providers: [CurrenciesService],
|
||||||
|
exports: [CurrenciesService],
|
||||||
|
})
|
||||||
|
export class CurrenciesModule {}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CurrenciesService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getAllCurrencies() {
|
||||||
|
const rates = await this.prisma.currencyExchangeRate.findMany({
|
||||||
|
distinct: ['toCurrency'],
|
||||||
|
orderBy: { toCurrency: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return rates.map(rate => ({
|
||||||
|
id: rate.id,
|
||||||
|
code: rate.toCurrency,
|
||||||
|
name: this.getCurrencyName(rate.toCurrency),
|
||||||
|
symbol: this.getCurrencySymbol(rate.toCurrency),
|
||||||
|
baseCurrencyCode: rate.fromCurrency,
|
||||||
|
exchangeRate: Number(rate.rate),
|
||||||
|
isActive: true,
|
||||||
|
createdAt: rate.createdAt,
|
||||||
|
updatedAt: rate.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCurrency(dto: CreateCurrencyDto) {
|
||||||
|
const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto;
|
||||||
|
|
||||||
|
if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) {
|
||||||
|
throw new BadRequestException('Unsupported currency code');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exchangeRate <= 0) {
|
||||||
|
throw new BadRequestException('Exchange rate must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = await this.prisma.currencyExchangeRate.create({
|
||||||
|
data: {
|
||||||
|
fromCurrency: baseCurrencyCode as any,
|
||||||
|
toCurrency: code.toUpperCase() as any,
|
||||||
|
rate: exchangeRate,
|
||||||
|
source: 'MANUAL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rate.id,
|
||||||
|
code: rate.toCurrency,
|
||||||
|
name,
|
||||||
|
symbol,
|
||||||
|
baseCurrencyCode: rate.fromCurrency,
|
||||||
|
exchangeRate: Number(rate.rate),
|
||||||
|
isActive: true,
|
||||||
|
createdAt: rate.createdAt,
|
||||||
|
updatedAt: rate.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCurrency(id: string, dto: UpdateCurrencyDto) {
|
||||||
|
const existing = await this.prisma.currencyExchangeRate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Currency not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.exchangeRate !== undefined && dto.exchangeRate <= 0) {
|
||||||
|
throw new BadRequestException('Exchange rate must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.currencyExchangeRate.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
rate: dto.exchangeRate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
code: updated.toCurrency,
|
||||||
|
name: dto.name || this.getCurrencyName(updated.toCurrency),
|
||||||
|
symbol: dto.symbol || this.getCurrencySymbol(updated.toCurrency),
|
||||||
|
baseCurrencyCode: updated.fromCurrency,
|
||||||
|
exchangeRate: Number(updated.rate),
|
||||||
|
isActive: true,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
updatedAt: updated.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCurrency(id: string) {
|
||||||
|
const existing = await this.prisma.currencyExchangeRate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Currency not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.currencyExchangeRate.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Currency deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncExchangeRates() {
|
||||||
|
return { message: 'Exchange rates synced successfully', synced: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrencyName(code: string): string {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
ETB: 'Ethiopian Birr',
|
||||||
|
USD: 'US Dollar',
|
||||||
|
DJF: 'Djiboutian Franc',
|
||||||
|
};
|
||||||
|
return names[code] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrencySymbol(code: string): string {
|
||||||
|
const symbols: Record<string, string> = {
|
||||||
|
ETB: 'Br',
|
||||||
|
USD: '$',
|
||||||
|
DJF: 'Fdj',
|
||||||
|
};
|
||||||
|
return symbols[code] || code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
import { Body, Controller, Post, Get, Query, Param } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { FareEngineService } from './fare-engine.service';
|
import { FareEngineService } from './fare-engine.service';
|
||||||
@@ -16,23 +16,10 @@ export class FareEngineController {
|
|||||||
@Post('calculate')
|
@Post('calculate')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Calculate fare for a journey leg',
|
summary: 'Calculate fare for a journey leg',
|
||||||
description: `Computes fare using the formula:
|
description: `Computes fare using the formula:\n\n**Fare = totalKm × ratePerKm × exchangeRate**`,
|
||||||
|
|
||||||
**Fare = totalKm × ratePerKm × exchangeRate**
|
|
||||||
|
|
||||||
- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination
|
|
||||||
- \`ratePerKm\` — \`SeatClass.basePrice\` (stored in ETB minor units per km)
|
|
||||||
- \`exchangeRate\` — derived from passenger nationality:
|
|
||||||
- **Ethiopian** → ETB (rate = 1.0)
|
|
||||||
- **Djiboutian** → DJF (rate ≈ 3.25)
|
|
||||||
- **Other / unspecified** → USD (rate ≈ 0.018)
|
|
||||||
|
|
||||||
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
|
|
||||||
5% tax applied after promo discount.
|
|
||||||
Returns a full breakdown including a human-readable calculation trace.`,
|
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
|
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
|
||||||
@ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' })
|
@ApiResponse({ status: 400, description: 'Invalid route/station combination' })
|
||||||
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
|
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
|
||||||
calculate(@Body() dto: FareCalculateDto) {
|
calculate(@Body() dto: FareCalculateDto) {
|
||||||
return this.service.calculate(dto);
|
return this.service.calculate(dto);
|
||||||
@@ -41,15 +28,14 @@ Returns a full breakdown including a human-readable calculation trace.`,
|
|||||||
@Get('compare')
|
@Get('compare')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Compare fares across all seat classes for a route leg',
|
summary: 'Compare fares across all seat classes for a route leg',
|
||||||
description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.',
|
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
|
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
|
||||||
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
|
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
|
||||||
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
|
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
|
||||||
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' })
|
@ApiQuery({ name: 'nationality', required: false })
|
||||||
@ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' })
|
@ApiQuery({ name: 'adultCount', required: false, type: Number })
|
||||||
@ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' })
|
@ApiQuery({ name: 'childCount', required: false, type: Number })
|
||||||
@ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' })
|
@ApiResponse({ status: 200, description: 'Array of fare breakdowns' })
|
||||||
compareClasses(
|
compareClasses(
|
||||||
@Query('routeId') routeId: string,
|
@Query('routeId') routeId: string,
|
||||||
@Query('originStationId') originStationId: string,
|
@Query('originStationId') originStationId: string,
|
||||||
@@ -67,6 +53,8 @@ Returns a full breakdown including a human-readable calculation trace.`,
|
|||||||
childCount ? parseInt(childCount) : 0,
|
childCount ? parseInt(childCount) : 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('Config')
|
@ApiTags('Config')
|
||||||
@@ -77,18 +65,10 @@ export class ConfigController {
|
|||||||
@Get('fayda-status')
|
@Get('fayda-status')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Check Verifayda 2.0 configuration status',
|
summary: 'Check Verifayda 2.0 configuration status',
|
||||||
description: 'Returns whether Verifayda integration is enabled and ready to use'
|
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Verifayda status retrieved successfully',
|
description: 'Verifayda status retrieved successfully',
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
enabled: true,
|
|
||||||
mode: 'production',
|
|
||||||
apiUrl: 'https://api.verifayda.gov.et/v2'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
getFaydaStatus() {
|
getFaydaStatus() {
|
||||||
const faydaConfig = this.configService.get<FaydaConfig>('fayda');
|
const faydaConfig = this.configService.get<FaydaConfig>('fayda');
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { FareEngineController, ConfigController } from './fare-engine.controller';
|
import { FareEngineController, ConfigController } from './fare-engine.controller';
|
||||||
import { FareEngineService } from './fare-engine.service';
|
import { FareEngineService } from './fare-engine.service';
|
||||||
import { CurrencyController } from './currency.controller';
|
import { CurrencyController } from './currency.controller';
|
||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CurrencyModule],
|
imports: [HttpModule, CurrencyModule],
|
||||||
controllers: [FareEngineController, CurrencyController, ConfigController],
|
controllers: [FareEngineController, CurrencyController, ConfigController],
|
||||||
providers: [FareEngineService],
|
providers: [FareEngineService],
|
||||||
exports: [FareEngineService],
|
exports: [FareEngineService],
|
||||||
|
|||||||
@@ -47,14 +47,22 @@ export class FareEngineService {
|
|||||||
const ratePerKmMinor = seatClass.baseFareMinor;
|
const ratePerKmMinor = seatClass.baseFareMinor;
|
||||||
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||||
|
|
||||||
|
// Premium and insurance fees applied per passenger
|
||||||
|
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
|
||||||
|
const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0;
|
||||||
|
const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
|
||||||
|
|
||||||
const adultCount = dto.adultCount ?? 1;
|
const adultCount = dto.adultCount ?? 1;
|
||||||
const childCount = dto.childCount ?? 0;
|
const childCount = dto.childCount ?? 0;
|
||||||
const freeChildrenCount = Math.min(childCount, 1);
|
const freeChildrenCount = Math.min(childCount, 1);
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
|
|
||||||
const subtotalMinor =
|
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
|
||||||
baseFarePerPassengerMinor * adultCount +
|
// First child is free, but pays premium and insurance
|
||||||
baseFarePerPassengerMinor * paidChildrenCount;
|
const adultSubtotal = farePerPassengerMinor * adultCount;
|
||||||
|
const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount;
|
||||||
|
const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount;
|
||||||
|
const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
|
||||||
|
|
||||||
let discountMinor = 0;
|
let discountMinor = 0;
|
||||||
let promoLabel = 'none';
|
let promoLabel = 'none';
|
||||||
@@ -85,12 +93,20 @@ export class FareEngineService {
|
|||||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||||
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
||||||
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
|
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
|
||||||
`Passengers: ${adultCount} adult(s) × ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`,
|
`Premium/pax: ${premiumPerPassenger} ETB minor`,
|
||||||
`Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`,
|
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
|
||||||
|
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
|
||||||
|
``,
|
||||||
|
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
|
||||||
|
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
|
||||||
|
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
|
||||||
|
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
|
||||||
|
``,
|
||||||
`Subtotal: ${subtotalMinor} ETB minor`,
|
`Subtotal: ${subtotalMinor} ETB minor`,
|
||||||
`Promo: ${promoLabel} → -${discountMinor} ETB minor`,
|
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
|
||||||
`Tax (5%): +${taxMinor} ETB minor`,
|
`Tax (5%): +${taxMinor} ETB minor`,
|
||||||
`Total (ETB): ${totalEtbMinor} ETB minor`,
|
`Total (ETB): ${totalEtbMinor} ETB minor`,
|
||||||
|
``,
|
||||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
||||||
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
||||||
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
||||||
@@ -104,6 +120,9 @@ export class FareEngineService {
|
|||||||
totalDistanceKm,
|
totalDistanceKm,
|
||||||
ratePerKmMinor,
|
ratePerKmMinor,
|
||||||
baseFarePerPassengerMinor,
|
baseFarePerPassengerMinor,
|
||||||
|
premiumPerPassenger,
|
||||||
|
insurancePerPassenger,
|
||||||
|
farePerPassengerMinor,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
freeChildrenCount,
|
freeChildrenCount,
|
||||||
@@ -142,7 +161,6 @@ export class FareEngineService {
|
|||||||
return results.filter(Boolean);
|
return results.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */
|
|
||||||
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
|
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: scheduleId },
|
where: { id: scheduleId },
|
||||||
@@ -160,7 +178,6 @@ export class FareEngineService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calculate fares for all active seat classes on a schedule. */
|
|
||||||
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
|
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: scheduleId },
|
where: { id: scheduleId },
|
||||||
@@ -168,7 +185,6 @@ export class FareEngineService {
|
|||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
// ── Route-based calculation (fare engine) ────────────────────────────────
|
|
||||||
if (schedule.routeId) {
|
if (schedule.routeId) {
|
||||||
const seatClasses = await this.prisma.seatClass.findMany({
|
const seatClasses = await this.prisma.seatClass.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
@@ -190,7 +206,6 @@ export class FareEngineService {
|
|||||||
return results.filter(Boolean);
|
return results.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fallback: FareRule records scoped to this schedule ───────────────────
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const fareRules = await this.prisma.fareRule.findMany({
|
const fareRules = await this.prisma.fareRule.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -158,7 +158,34 @@ export class FleetController {
|
|||||||
@ApiOperation({ summary: 'List coaches with seat status summary' })
|
@ApiOperation({ summary: 'List coaches with seat status summary' })
|
||||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
|
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
|
||||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
|
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
|
||||||
@ApiResponse({ status: 200, description: 'Array of coaches' })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Array of coaches',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
number: 'A-001',
|
||||||
|
sequence: 1,
|
||||||
|
coachTypeId: 'coach-type-uuid',
|
||||||
|
coachType: {
|
||||||
|
id: 'coach-type-uuid',
|
||||||
|
code: 'sleeper',
|
||||||
|
name: 'Sleeper Coach'
|
||||||
|
},
|
||||||
|
arrangement: '2+2',
|
||||||
|
capacity: 60,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
totalSeats: 60,
|
||||||
|
availableSeats: 45,
|
||||||
|
occupiedSeats: 15,
|
||||||
|
blockedSeats: 0,
|
||||||
|
createdAt: '2024-01-15T10:30:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
listCoaches(
|
listCoaches(
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Query('scheduleId') scheduleId?: string,
|
@Query('scheduleId') scheduleId?: string,
|
||||||
@@ -173,7 +200,40 @@ export class FleetController {
|
|||||||
@Get('coaches/:id')
|
@Get('coaches/:id')
|
||||||
@ApiOperation({ summary: 'Get single coach with seat layout' })
|
@ApiOperation({ summary: 'Get single coach with seat layout' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Coach detail with seats by row',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
number: 'A-001',
|
||||||
|
sequence: 1,
|
||||||
|
coachTypeId: 'coach-type-uuid',
|
||||||
|
coachType: {
|
||||||
|
id: 'coach-type-uuid',
|
||||||
|
code: 'sleeper',
|
||||||
|
name: 'Sleeper Coach'
|
||||||
|
},
|
||||||
|
arrangement: '2+2',
|
||||||
|
capacity: 60,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
seats: [
|
||||||
|
{
|
||||||
|
id: 'seat-uuid-1',
|
||||||
|
seatNumber: '1A',
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
class: {
|
||||||
|
id: 'class-uuid',
|
||||||
|
name: 'Economy',
|
||||||
|
baseFareMinor: 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: '2024-01-15T10:30:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||||
getCoach(@Param('id') id: string) {
|
getCoach(@Param('id') id: string) {
|
||||||
return this.service.getCoach(id);
|
return this.service.getCoach(id);
|
||||||
@@ -182,7 +242,23 @@ export class FleetController {
|
|||||||
@Post('coaches')
|
@Post('coaches')
|
||||||
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
|
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
|
||||||
@ApiBody({ type: CreateCoachDto })
|
@ApiBody({ type: CreateCoachDto })
|
||||||
@ApiResponse({ status: 201, description: 'Coach created' })
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Coach created',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
number: 'A-001',
|
||||||
|
sequence: 1,
|
||||||
|
coachTypeId: 'coach-type-uuid',
|
||||||
|
arrangement: '2+2',
|
||||||
|
capacity: 60,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
createdAt: '2024-01-15T10:30:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
|
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
|
||||||
createCoach(@Body() dto: CreateCoachDto) {
|
createCoach(@Body() dto: CreateCoachDto) {
|
||||||
return this.service.createCoach(dto);
|
return this.service.createCoach(dto);
|
||||||
@@ -192,7 +268,23 @@ export class FleetController {
|
|||||||
@ApiOperation({ summary: 'Update coach properties' })
|
@ApiOperation({ summary: 'Update coach properties' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
@ApiBody({ type: UpdateCoachDto })
|
@ApiBody({ type: UpdateCoachDto })
|
||||||
@ApiResponse({ status: 200, description: 'Coach updated' })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Coach updated',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
number: 'A-001',
|
||||||
|
sequence: 1,
|
||||||
|
coachTypeId: 'coach-type-uuid',
|
||||||
|
arrangement: '2+2',
|
||||||
|
capacity: 60,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
createdAt: '2024-01-15T10:30:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||||
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
|
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
|
||||||
return this.service.updateCoach(id, dto);
|
return this.service.updateCoach(id, dto);
|
||||||
@@ -201,7 +293,7 @@ export class FleetController {
|
|||||||
@Delete('coaches/:id')
|
@Delete('coaches/:id')
|
||||||
@ApiOperation({ summary: 'Delete a coach' })
|
@ApiOperation({ summary: 'Delete a coach' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Coach deleted' })
|
@ApiResponse({ status: 200, description: 'Coach deleted successfully' })
|
||||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||||
deleteCoach(@Param('id') id: string) {
|
deleteCoach(@Param('id') id: string) {
|
||||||
return this.service.deleteCoach(id);
|
return this.service.deleteCoach(id);
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export class CreateCoachDto {
|
|||||||
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
|
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {}
|
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
|
||||||
|
@ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering' })
|
||||||
|
@IsOptional() @IsInt() sequence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class AssignCoachDto {
|
export class AssignCoachDto {
|
||||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
|
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
|
||||||
|
|||||||
@@ -48,19 +48,16 @@ function buildSeats(coachId: string, coachNumber: string, arrangement: string, c
|
|||||||
const col = cols[ci];
|
const col = cols[ci];
|
||||||
let bedPosition = null;
|
let bedPosition = null;
|
||||||
|
|
||||||
// Set bedPosition for bed coaches based on seat number cycling
|
// Set bedPosition for bed coaches based on ROW cycling (not seat number)
|
||||||
if (isBedCoach) {
|
if (isBedCoach) {
|
||||||
if (totalCols === 3) {
|
if (totalCols === 3) {
|
||||||
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
|
// Economy bed (3-row cycle): upper, middle, lower
|
||||||
const posMod = ((seatNumber - 1) % 3);
|
if (row % 3 === 1) bedPosition = 'upper';
|
||||||
if (posMod === 0) bedPosition = 'lower';
|
else if (row % 3 === 2) bedPosition = 'middle';
|
||||||
else if (posMod === 1) bedPosition = 'middle';
|
else bedPosition = 'lower';
|
||||||
else if (posMod === 2) bedPosition = 'upper';
|
|
||||||
} else if (totalCols === 2) {
|
} else if (totalCols === 2) {
|
||||||
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
|
// VIP bed (2-row cycle): upper, lower
|
||||||
const posMod = ((seatNumber - 1) % 2);
|
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
|
||||||
if (posMod === 0) bedPosition = 'lower';
|
|
||||||
else if (posMod === 1) bedPosition = 'upper';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +250,7 @@ export class FleetService {
|
|||||||
return this.prisma.coach.findMany({
|
return this.prisma.coach.findMany({
|
||||||
where,
|
where,
|
||||||
include: { coachType: true },
|
include: { coachType: true },
|
||||||
orderBy: { number: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,10 +260,18 @@ export class FleetService {
|
|||||||
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
|
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the next sequence number for this coach type
|
||||||
|
const lastCoach = await this.prisma.coach.findFirst({
|
||||||
|
where: { coachTypeId: dto.coachTypeId },
|
||||||
|
orderBy: { sequence: 'desc' },
|
||||||
|
});
|
||||||
|
const nextSequence = (lastCoach?.sequence ?? 0) + 1;
|
||||||
|
|
||||||
const coach = await this.prisma.coach.create({
|
const coach = await this.prisma.coach.create({
|
||||||
data: {
|
data: {
|
||||||
coachTypeId: dto.coachTypeId,
|
coachTypeId: dto.coachTypeId,
|
||||||
number: dto.number,
|
number: dto.number,
|
||||||
|
sequence: nextSequence,
|
||||||
arrangement: dto.arrangement,
|
arrangement: dto.arrangement,
|
||||||
capacity: dto.capacity,
|
capacity: dto.capacity,
|
||||||
status: dto.status || 'ACTIVE',
|
status: dto.status || 'ACTIVE',
|
||||||
@@ -293,6 +298,7 @@ export class FleetService {
|
|||||||
arrangement: dto.arrangement,
|
arrangement: dto.arrangement,
|
||||||
capacity: dto.capacity,
|
capacity: dto.capacity,
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
|
sequence: dto.sequence,
|
||||||
},
|
},
|
||||||
include: { coachType: true },
|
include: { coachType: true },
|
||||||
});
|
});
|
||||||
@@ -302,32 +308,9 @@ export class FleetService {
|
|||||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||||
if (!coach) throw new NotFoundException('Coach not found');
|
if (!coach) throw new NotFoundException('Coach not found');
|
||||||
|
|
||||||
// Get all seat IDs for this coach
|
// Delete related seats first
|
||||||
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
|
|
||||||
const seatIds = seats.map(s => s.id);
|
|
||||||
|
|
||||||
// Delete in order of foreign key dependencies
|
|
||||||
if (seatIds.length > 0) {
|
|
||||||
// 1. Delete seat blocks (references seats)
|
|
||||||
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
|
|
||||||
|
|
||||||
// 2. Delete ticket seats (references seats)
|
|
||||||
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
|
||||||
|
|
||||||
// 3. Delete booking seats (references seats)
|
|
||||||
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
|
||||||
|
|
||||||
// 4. Delete journey segments with these seats
|
|
||||||
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Delete all associated seats
|
|
||||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||||
|
|
||||||
// 6. Delete coach assignments
|
|
||||||
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
|
|
||||||
|
|
||||||
// 7. Finally delete the coach
|
|
||||||
return this.prisma.coach.delete({ where: { id } });
|
return this.prisma.coach.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class SendEmail {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
html?: string;
|
||||||
|
templateKey?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class SendMessage {
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
from?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkMessagesDto {
|
||||||
|
messages: SendMessage[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { ClientProxy } from '@nestjs/microservices';
|
||||||
|
import { SendEmail } from './dtos/email.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailClientService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(EmailClientService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('EMAIL_SERVICE')
|
||||||
|
private readonly emailServiceClient: ClientProxy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
this.emailServiceClient
|
||||||
|
.connect()
|
||||||
|
.then(() => this.logger.log('Connected to Email service'))
|
||||||
|
.catch((err) => this.logger.error('Error connecting to Email service', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmail(dto: SendEmail) {
|
||||||
|
this.emailServiceClient.emit('send-email', {
|
||||||
|
...dto,
|
||||||
|
appKey: 'EDR-PASSENGER-API',
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||||
import { TestNotificationDto } from './notifications.dto';
|
import { TestNotificationDto } from './notifications.dto';
|
||||||
|
import { EmailClientService } from './email-client.service';
|
||||||
|
import { SmsClientService } from './sms-client.service';
|
||||||
|
import { SendEmail } from './dtos/email.dto';
|
||||||
|
import { SendMessage } from './dtos/sms.dto';
|
||||||
|
|
||||||
@ApiTags('Notifications')
|
@ApiTags('Notifications')
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
constructor(private service: NotificationsService) {}
|
constructor(
|
||||||
|
private service: NotificationsService,
|
||||||
|
private emailClient: EmailClientService,
|
||||||
|
private smsClient: SmsClientService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get(':passengerId')
|
@Get(':passengerId')
|
||||||
@ApiOperation({ summary: 'Get notifications for passenger' })
|
@ApiOperation({ summary: 'Get notifications for passenger' })
|
||||||
@@ -30,6 +38,24 @@ export class NotificationsController {
|
|||||||
return this.service.markAllRead(id);
|
return this.service.markAllRead(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('send/email')
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@IamRoles('ADMIN', 'STAFF')
|
||||||
|
@ApiOperation({ summary: 'Send a direct email via the email microservice' })
|
||||||
|
@ApiBody({ type: SendEmail })
|
||||||
|
sendEmail(@Body() dto: SendEmail) {
|
||||||
|
return this.emailClient.sendEmail(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('send/sms')
|
||||||
|
@UseGuards(IamGuard)
|
||||||
|
@IamRoles('ADMIN', 'STAFF')
|
||||||
|
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
|
||||||
|
@ApiBody({ type: SendMessage })
|
||||||
|
sendSms(@Body() dto: SendMessage) {
|
||||||
|
return this.smsClient.sendSms(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('test')
|
@Post('test')
|
||||||
@UseGuards(IamGuard)
|
@UseGuards(IamGuard)
|
||||||
@IamRoles('ADMIN', 'STAFF')
|
@IamRoles('ADMIN', 'STAFF')
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||||
import { NotificationsController } from './notifications.controller';
|
import { NotificationsController } from './notifications.controller';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
|
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
|
||||||
|
import { EmailClientService } from './email-client.service';
|
||||||
|
import { SmsClientService } from './sms-client.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HttpModule.register({ timeout: 10_000 })],
|
imports: [
|
||||||
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
|
ClientsModule.registerAsync([
|
||||||
|
{
|
||||||
|
name: 'EMAIL_SERVICE',
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
transport: Transport.RMQ,
|
||||||
|
options: {
|
||||||
|
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||||
|
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
|
||||||
|
queueOptions: { durable: true },
|
||||||
|
noAck: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SMS_SERVICE',
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
transport: Transport.RMQ,
|
||||||
|
options: {
|
||||||
|
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
|
||||||
|
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
|
||||||
|
queueOptions: { durable: true },
|
||||||
|
noAck: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
controllers: [NotificationsController],
|
controllers: [NotificationsController],
|
||||||
providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter],
|
providers: [
|
||||||
exports: [NotificationsService],
|
NotificationsService,
|
||||||
|
EmailAdapter,
|
||||||
|
SmsAdapter,
|
||||||
|
PushAdapter,
|
||||||
|
EmailClientService,
|
||||||
|
SmsClientService,
|
||||||
|
],
|
||||||
|
exports: [NotificationsService, EmailClientService, SmsClientService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
|
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
|
||||||
import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters';
|
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||||
|
import { EmailClientService } from './email-client.service';
|
||||||
|
import { SmsClientService } from './sms-client.service';
|
||||||
|
|
||||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||||
|
|
||||||
@@ -13,13 +15,13 @@ export class NotificationsService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private emailAdapter: EmailAdapter,
|
private emailClient: EmailClientService,
|
||||||
private smsAdapter: SmsAdapter,
|
private smsClient: SmsClientService,
|
||||||
private pushAdapter: PushAdapter,
|
private pushAdapter: PushAdapter,
|
||||||
) {
|
) {
|
||||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||||
['EMAIL', this.emailAdapter as NotificationChannel],
|
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
|
||||||
['SMS', this.smsAdapter as NotificationChannel],
|
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }],
|
||||||
['PUSH', this.pushAdapter as NotificationChannel],
|
['PUSH', this.pushAdapter as NotificationChannel],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -102,11 +104,11 @@ export class NotificationsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (passenger?.user) {
|
if (passenger?.user) {
|
||||||
await this.emailAdapter.send(
|
await this.emailClient.sendEmail({
|
||||||
passenger.user.email,
|
to: passenger.user.email,
|
||||||
this.sanitize(dto.title),
|
subject: this.sanitize(dto.title),
|
||||||
this.sanitize(dto.body),
|
body: this.sanitize(dto.body),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return notification;
|
return notification;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { ClientProxy } from '@nestjs/microservices';
|
||||||
|
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsClientService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(SmsClientService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('SMS_SERVICE')
|
||||||
|
private readonly smsClient: ClientProxy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
this.smsClient
|
||||||
|
.connect()
|
||||||
|
.then(() => this.logger.log('Connected to SMS service'))
|
||||||
|
.catch((err) => this.logger.error('Error connecting to SMS service', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSms(dto: SendMessage) {
|
||||||
|
this.smsClient.emit('send-sms', {
|
||||||
|
...dto,
|
||||||
|
appKey: 'EDR-PASSENGER-API',
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendBulkMessages(dto: BulkMessagesDto) {
|
||||||
|
this.smsClient.emit('ozeking-bulk-sms', {
|
||||||
|
...dto,
|
||||||
|
appKey: 'EDR-PASSENGER-API',
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,17 +47,9 @@ export class PassengersService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: true,
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
fullName: true,
|
|
||||||
email: true,
|
|
||||||
phone: true,
|
|
||||||
nationalId: true,
|
|
||||||
nationality: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
loyalty: true,
|
loyalty: true,
|
||||||
|
wallet: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
bookings: true,
|
bookings: true,
|
||||||
@@ -69,19 +61,30 @@ export class PassengersService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map(passenger => ({
|
items: items.map(passenger => {
|
||||||
id: passenger.id,
|
const user = passenger.user as any;
|
||||||
fullName: passenger.user.fullName,
|
return {
|
||||||
email: passenger.user.email,
|
id: passenger.id,
|
||||||
phone: passenger.user.phone,
|
userId: passenger.userId,
|
||||||
nationalId: passenger.user.nationalId,
|
fullName: user.fullName,
|
||||||
nationality: passenger.user.nationality,
|
email: user.email,
|
||||||
verified: !!passenger.user.nationalId,
|
phone: user.phone,
|
||||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
nationalId: user.nationalId,
|
||||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
nationality: user.nationality,
|
||||||
totalBookings: passenger._count.bookings,
|
dateOfBirth: user.dateOfBirth ?? null,
|
||||||
createdAt: passenger.createdAt,
|
gender: user.gender ?? null,
|
||||||
})),
|
passportNumber: user.passportNumber,
|
||||||
|
passportCountry: user.passportCountry ?? null,
|
||||||
|
verified: !!user.nationalId,
|
||||||
|
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||||
|
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||||
|
totalBookings: passenger._count.bookings,
|
||||||
|
createdAt: passenger.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
loyalty: passenger.loyalty,
|
||||||
|
wallet: passenger.wallet,
|
||||||
|
};
|
||||||
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -95,9 +98,19 @@ export class PassengersService {
|
|||||||
const passenger = await this.prisma.passenger.findUnique({
|
const passenger = await this.prisma.passenger.findUnique({
|
||||||
where: { id: passengerId },
|
where: { id: passengerId },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { fullName: true, email: true, phone: true } },
|
user: true,
|
||||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
|
bookings: {
|
||||||
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
|
seats: { include: { seat: { include: { coach: true } } } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loyalty: true,
|
||||||
|
wallet: true,
|
||||||
|
travelerProfiles: true,
|
||||||
|
savedRoutes: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||||
@@ -108,14 +121,35 @@ export class PassengersService {
|
|||||||
phone: passenger.user.phone,
|
phone: passenger.user.phone,
|
||||||
createdAt: passenger.createdAt,
|
createdAt: passenger.createdAt,
|
||||||
bookings: passenger.bookings.map((b) => ({
|
bookings: passenger.bookings.map((b) => ({
|
||||||
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
|
id: b.id,
|
||||||
|
bookingRef: b.bookingRef,
|
||||||
|
status: b.status,
|
||||||
|
totalFare: b.totalMinor / 100,
|
||||||
|
createdAt: b.createdAt,
|
||||||
trip: {
|
trip: {
|
||||||
number: b.schedule.train.number,
|
number: b.schedule.train.number,
|
||||||
origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city },
|
origin: {
|
||||||
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
|
id: b.schedule.originStation.id,
|
||||||
|
name: b.schedule.originStation.name,
|
||||||
|
code: b.schedule.originStation.code,
|
||||||
|
city: b.schedule.originStation.city
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
id: b.schedule.destinationStation.id,
|
||||||
|
name: b.schedule.destinationStation.name,
|
||||||
|
code: b.schedule.destinationStation.code,
|
||||||
|
city: b.schedule.destinationStation.city
|
||||||
|
},
|
||||||
departureAt: b.schedule.departureAt,
|
departureAt: b.schedule.departureAt,
|
||||||
},
|
},
|
||||||
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
|
passengers: b.seats.map((bs) => ({
|
||||||
|
fullName: bs.passengerName,
|
||||||
|
seat: {
|
||||||
|
number: bs.seat.seatNumber,
|
||||||
|
coach: bs.seat.coach.number,
|
||||||
|
class: 'N/A'
|
||||||
|
}
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -171,14 +205,25 @@ export class PassengersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createTravelerProfile(dto: CreateTravelerProfileDto) {
|
createTravelerProfile(dto: CreateTravelerProfileDto) {
|
||||||
return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } });
|
return this.prisma.travelerProfile.create({
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); }
|
getTravelerProfiles(passengerId: string) {
|
||||||
|
return this.prisma.travelerProfile.findMany({ where: { passengerId } });
|
||||||
|
}
|
||||||
|
|
||||||
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
|
createSavedRoute(dto: CreateSavedRouteDto) {
|
||||||
|
return this.prisma.savedRoute.create({ data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
|
getSavedRoutes(passengerId: string) {
|
||||||
|
return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } });
|
||||||
|
}
|
||||||
|
|
||||||
async updatePassenger(id: string, dto: any) {
|
async updatePassenger(id: string, dto: any) {
|
||||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||||
@@ -196,7 +241,7 @@ export class PassengersService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { fullName: true, email: true, phone: true, nationality: true } },
|
user: true,
|
||||||
loyalty: true,
|
loyalty: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -290,9 +335,7 @@ export class PassengersService {
|
|||||||
async deletePassenger(id: string) {
|
async deletePassenger(id: string) {
|
||||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||||
|
return this.prisma.passenger.delete({ where: { id } });
|
||||||
await this.prisma.passenger.delete({ where: { id } });
|
|
||||||
return { deleted: true, passengerId: id };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkPassengerUsage(id: string) {
|
async checkPassengerUsage(id: string) {
|
||||||
|
|||||||
@@ -42,13 +42,6 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
private readonly logger = new Logger(PaymentsService.name);
|
private readonly logger = new Logger(PaymentsService.name);
|
||||||
|
|
||||||
/**
|
|
||||||
* DEMO ONLY: when true, a WALLET "payment" is treated as instantly successful — the wallet
|
|
||||||
* balance check and debit are skipped and the booking is confirmed + ticket issued as if fully
|
|
||||||
* paid. Lets the happy-path be demoed while a real provider (e.g. Telebirr) is unavailable.
|
|
||||||
* Never enable in production. Toggle with WALLET_DEMO_AUTO_SUCCEED in the env.
|
|
||||||
*/
|
|
||||||
private readonly walletDemoAutoSucceed = true;
|
private readonly walletDemoAutoSucceed = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -136,9 +129,7 @@ export class PaymentsService {
|
|||||||
return this.initiateWalletPayment(booking);
|
return this.initiateWalletPayment(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider methods go through the payment microservice (docs/payment-service §7.1):
|
const { returnUrl, failureUrl } = this.resolveReturnUrls(method);
|
||||||
// it owns the intent, the provider session, and the single webhook per provider.
|
|
||||||
// Re-initiating is safe — the service returns the existing active intent (idempotent).
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.PASSENGER,
|
service: PaymentServiceEnum.PASSENGER,
|
||||||
referenceType: PaymentReferenceType.BOOKING,
|
referenceType: PaymentReferenceType.BOOKING,
|
||||||
@@ -148,10 +139,8 @@ export class PaymentsService {
|
|||||||
currency: booking.currency,
|
currency: booking.currency,
|
||||||
provider: method as unknown as ProviderMethod,
|
provider: method as unknown as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: dto.platform,
|
||||||
// PASSENGER-owned browser bounce-back after the hosted page (freight passes its own).
|
returnUrl,
|
||||||
// UX only — payment is confirmed by the webhook/mark-paid event, never this redirect.
|
failureUrl,
|
||||||
returnUrl: process.env.PAYMENT_RETURN_URL || undefined,
|
|
||||||
failureUrl: process.env.PAYMENT_FAILURE_URL || undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||||
@@ -168,6 +157,40 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
return this.formatIntentResponse(intent);
|
return this.formatIntentResponse(intent);
|
||||||
}
|
}
|
||||||
|
private resolveReturnUrls(method: PaymentMethodType): {
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
} {
|
||||||
|
const perMethod: Partial<
|
||||||
|
Record<PaymentMethodType, { returnUrl?: string; failureUrl?: string }>
|
||||||
|
> = {
|
||||||
|
[PaymentMethodType.TELEBIRR]: {
|
||||||
|
returnUrl: process.env.TELEBIRR_RETURN_URL,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.WAAFI]: {
|
||||||
|
returnUrl: process.env.WAAFI_SUCCESS_REDIRECT,
|
||||||
|
failureUrl: process.env.WAAFI_FAIL_REDIRECT,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.DMONEY]: {
|
||||||
|
returnUrl: process.env.DMONEY_RETURN_URL,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.CBE_BIRR]: {
|
||||||
|
returnUrl: process.env.CBE_RETURN_URL,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.EBIRR]: {
|
||||||
|
returnUrl: process.env.EBIRR_RETURN_URL,
|
||||||
|
},
|
||||||
|
[PaymentMethodType.CARD]: {
|
||||||
|
returnUrl: process.env.CARD_RETURN_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const m = perMethod[method] ?? {};
|
||||||
|
const returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
|
||||||
|
const failureUrl =
|
||||||
|
m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;
|
||||||
|
return { returnUrl, failureUrl };
|
||||||
|
}
|
||||||
|
|
||||||
private async syncIntentProjection(
|
private async syncIntentProjection(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export class ReportsService {
|
|||||||
|
|
||||||
async generateReport(dto: GenerateReportDto) {
|
async generateReport(dto: GenerateReportDto) {
|
||||||
const dateFrom = new Date(dto.dateFrom);
|
const dateFrom = new Date(dto.dateFrom);
|
||||||
|
dateFrom.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const dateTo = new Date(dto.dateTo);
|
const dateTo = new Date(dto.dateTo);
|
||||||
|
dateTo.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
let data: any;
|
let data: any;
|
||||||
switch (dto.reportType) {
|
switch (dto.reportType) {
|
||||||
@@ -44,14 +47,16 @@ export class ReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
|
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
|
||||||
|
// Fetch all bookings in date range, regardless of status
|
||||||
const bookings = await this.prisma.booking.findMany({
|
const bookings = await this.prisma.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
createdAt: { gte: dateFrom, lte: dateTo },
|
createdAt: { gte: dateFrom, lte: dateTo }
|
||||||
status: { in: ['CONFIRMED', 'COMPLETED'] }
|
|
||||||
},
|
},
|
||||||
include: { paymentIntent: true }
|
include: { paymentIntent: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`);
|
||||||
|
|
||||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||||
@@ -59,12 +64,25 @@ export class ReportsService {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, number>);
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
// Group by date for charts
|
||||||
|
const byDate = bookings.reduce((acc, b) => {
|
||||||
|
const date = b.createdAt.toISOString().split('T')[0];
|
||||||
|
if (!acc[date]) {
|
||||||
|
acc[date] = { totalMinor: 0, count: 0 };
|
||||||
|
}
|
||||||
|
acc[date].totalMinor += b.totalMinor;
|
||||||
|
acc[date].count += 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalBookings: bookings.length,
|
totalBookings: bookings.length,
|
||||||
totalRevenueMinor: totalRevenue,
|
totalRevenueMinor: totalRevenue,
|
||||||
totalRevenue: totalRevenue / 100,
|
totalRevenue: totalRevenue / 100,
|
||||||
currency: 'ETB',
|
currency: 'ETB',
|
||||||
byPaymentMethod
|
byPaymentMethod,
|
||||||
|
byDate,
|
||||||
|
cancellationRate: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +91,7 @@ export class ReportsService {
|
|||||||
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
||||||
include: {
|
include: {
|
||||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||||
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } },
|
bookings: { include: { seats: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ export class SchedulesController {
|
|||||||
@Body() dto: UpdateStopTimeDto,
|
@Body() dto: UpdateStopTimeDto,
|
||||||
) { return this.service.updateStop(id, sequence, dto); }
|
) { return this.service.updateStop(id, sequence, dto); }
|
||||||
|
|
||||||
|
@Get(':scheduleId/fares/stored')
|
||||||
|
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
||||||
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' })
|
||||||
|
getStoredFares(@Param('scheduleId') scheduleId: string) {
|
||||||
|
return this.service.getFareRules(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':scheduleId/fares')
|
@Get(':scheduleId/fares')
|
||||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
|
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { TripStatus, StopStatus } from '@prisma/client';
|
import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client';
|
||||||
|
|
||||||
export class PlannedStopTimeDto {
|
export class PlannedStopTimeDto {
|
||||||
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;
|
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;
|
||||||
@@ -51,6 +51,7 @@ export class CreateFareRuleDto {
|
|||||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string;
|
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string;
|
||||||
@ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI for full route or ADD-ADM for segment)' }) @IsOptional() @IsString() route?: string;
|
@ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI for full route or ADD-ADM for segment)' }) @IsOptional() @IsString() route?: string;
|
||||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Scope fare rule to nationality: Ethiopian, Djiboutian, Other' }) @IsOptional() @IsString() nationality?: string;
|
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Scope fare rule to nationality: Ethiopian, Djiboutian, Other' }) @IsOptional() @IsString() nationality?: string;
|
||||||
|
@ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory;
|
||||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
||||||
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
||||||
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||||
@@ -64,6 +65,7 @@ export class CreateSegmentFareRuleDto {
|
|||||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
||||||
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
||||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
|
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
|
||||||
|
@ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory;
|
||||||
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,50 +329,6 @@ export class SchedulesService {
|
|||||||
async deleteSchedule(id: string) {
|
async deleteSchedule(id: string) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
|
||||||
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
|
|
||||||
|
|
||||||
const bookings = await this.prisma.booking.findMany({
|
|
||||||
where: { scheduleId: id },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const bookingIds = bookings.map(b => b.id);
|
|
||||||
|
|
||||||
if (bookingIds.length > 0) {
|
|
||||||
const paymentIntents = await this.prisma.paymentIntent.findMany({
|
|
||||||
where: { bookingId: { in: bookingIds } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const paymentIntentIds = paymentIntents.map(pi => pi.id);
|
|
||||||
|
|
||||||
if (paymentIntentIds.length > 0) {
|
|
||||||
await this.prisma.paymentRefund.deleteMany({
|
|
||||||
where: { paymentIntentId: { in: paymentIntentIds } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.ticket.deleteMany({
|
|
||||||
where: { bookingId: { in: bookingIds } },
|
|
||||||
});
|
|
||||||
await this.prisma.bookingSeat.deleteMany({
|
|
||||||
where: { bookingId: { in: bookingIds } },
|
|
||||||
});
|
|
||||||
await this.prisma.bookingModification.deleteMany({
|
|
||||||
where: { bookingId: { in: bookingIds } },
|
|
||||||
});
|
|
||||||
await this.prisma.bookingCancellation.deleteMany({
|
|
||||||
where: { bookingId: { in: bookingIds } },
|
|
||||||
});
|
|
||||||
await this.prisma.paymentIntent.deleteMany({
|
|
||||||
where: { bookingId: { in: bookingIds } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
|
|
||||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
|
||||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
|
||||||
|
|
||||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +358,7 @@ export class SchedulesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createFareRule(dto: CreateFareRuleDto) {
|
createFareRule(dto: CreateFareRuleDto) {
|
||||||
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
|
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||||
return this.prisma.fareRule.create({
|
return this.prisma.fareRule.create({
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -415,7 +371,7 @@ export class SchedulesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createSegmentFareRule(dto: any) {
|
createSegmentFareRule(dto: any) {
|
||||||
const { validFrom, validUntil, ...rest } = dto;
|
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||||
return this.prisma.segmentFareRule.create({
|
return this.prisma.segmentFareRule.create({
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -439,7 +395,7 @@ export class SchedulesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateSegmentFareRule(id: string, dto: any) {
|
updateSegmentFareRule(id: string, dto: any) {
|
||||||
const { validFrom, validUntil, ...rest } = dto;
|
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||||
return this.prisma.segmentFareRule.update({
|
return this.prisma.segmentFareRule.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -451,12 +407,36 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFareRules(scheduleId?: string) {
|
||||||
|
const where: any = {};
|
||||||
|
if (scheduleId) where.tripId = scheduleId;
|
||||||
|
|
||||||
|
return this.prisma.fareRule.findMany({
|
||||||
|
where,
|
||||||
|
include: { seatClass: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||||
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllFaresFromEngine(scheduleId: string, nationality?: string) {
|
async getAllFaresFromEngine(scheduleId: string, nationality?: string) {
|
||||||
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
try {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||||
|
});
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
|
||||||
|
|
||||||
|
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||||
|
} catch (error) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
error instanceof Error ? error.message : 'Failed to calculate fares for schedule'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
|
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
|
||||||
|
|||||||
@@ -11,17 +11,24 @@ export class SearchController {
|
|||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Search trips by origin, destination, date, passengers, and nationality',
|
summary: 'Search trips by origin, destination, date, passengers, and nationality',
|
||||||
description: `Finds all train schedules matching search criteria with real-time seat availability.
|
description: `Finds all train schedules matching search criteria with real-time seat availability and coach type options.
|
||||||
|
|
||||||
|
**Coach Type Selection Flow:**
|
||||||
|
- Users browse available coach types (Economy, VIP, etc.)
|
||||||
|
- Each coach type displays available seat classes and base fares
|
||||||
|
- Users select a coach type to proceed to seat selection
|
||||||
|
- At seat selection, users choose specific seat and class (actual price confirmed here)
|
||||||
|
- Final fare may adjust based on seat position/amenities selected
|
||||||
|
|
||||||
|
**Features:**
|
||||||
- Any origin→destination stop pair (not just terminals)
|
- Any origin→destination stop pair (not just terminals)
|
||||||
- Age-based passenger counts (adults ≥5 years, children <5 years)
|
- Age-based passenger counts (adults ≥5 years, children <5 years)
|
||||||
- Nationality filtering (Ethiopian, Djiboutian, Other)
|
- Nationality filtering (Ethiopian, Djiboutian, Other)
|
||||||
- Real-time seat availability per class
|
- Real-time seat availability per class
|
||||||
- Multi-currency fare display
|
- Multi-currency fare display
|
||||||
- Example: Train A→B→C→D appears in results for A→B, A→C, A→D, B→C, B→D, C→D
|
- Segment-based availability (seat booked A→B still available B→D)`
|
||||||
- Availability: Segment-based (seat booked A→B is still available B→D)`
|
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' })
|
@ApiResponse({ status: 200, description: 'Matching schedules with coachTypes array showing available coach types with seat classes and base fares' })
|
||||||
searchTrips(@Body() dto: SearchTripsDto) {
|
searchTrips(@Body() dto: SearchTripsDto) {
|
||||||
return this.service.searchTrips(dto);
|
return this.service.searchTrips(dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export class SearchTripsDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' })
|
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' })
|
||||||
@IsOptional() @IsString() nationality?: string;
|
@IsOptional() @IsString() nationality?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], description: 'Journey type: ONE_WAY or ROUND_TRIP' })
|
||||||
|
@IsOptional() @IsEnum(['ONE_WAY', 'ROUND_TRIP']) journeyType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
|
||||||
|
@IsOptional() @IsDateString() returnDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FareQuoteDto {
|
export class FareQuoteDto {
|
||||||
@@ -53,4 +59,39 @@ export class FareQuoteDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' })
|
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' })
|
||||||
@IsOptional() @IsString() nationality?: string;
|
@IsOptional() @IsString() nationality?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Return schedule UUID (required for ROUND_TRIP journeys)' })
|
||||||
|
@IsOptional() @IsString() returnScheduleId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return origin station ID (required for ROUND_TRIP)' })
|
||||||
|
@IsOptional() @IsString() returnOriginStationId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return destination station ID (required for ROUND_TRIP)' })
|
||||||
|
@IsOptional() @IsString() returnDestinationStationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoachTypeOptionClass {
|
||||||
|
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
|
||||||
|
baseFareMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CoachTypeOption {
|
||||||
|
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
|
||||||
|
coachTypeId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Economy', description: 'Coach type display name' })
|
||||||
|
coachTypeName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'ECO', description: 'Coach type code' })
|
||||||
|
coachTypeCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
|
||||||
|
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
|
||||||
|
})
|
||||||
|
classes: CoachTypeOptionClass[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,15 +18,57 @@ export class SearchService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchTrips(dto: SearchTripsDto) {
|
async searchTrips(dto: SearchTripsDto) {
|
||||||
const date = new Date(dto.date);
|
const outbound = await this.searchSchedules(
|
||||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
dto.originStationId,
|
||||||
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
|
dto.destinationStationId,
|
||||||
|
dto.date,
|
||||||
|
dto.adultCount,
|
||||||
|
dto.childCount,
|
||||||
|
dto.nationality,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dto.journeyType === 'ROUND_TRIP') {
|
||||||
|
const allInbound = await this.searchSchedules(
|
||||||
|
dto.destinationStationId,
|
||||||
|
dto.originStationId,
|
||||||
|
dto.returnDate ?? dto.date,
|
||||||
|
dto.adultCount,
|
||||||
|
dto.childCount,
|
||||||
|
dto.nationality,
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestOutboundArrival = outbound.length > 0
|
||||||
|
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
const inbound = allInbound.filter((schedule) =>
|
||||||
|
new Date(schedule.departureAt).getTime() > latestOutboundArrival
|
||||||
|
);
|
||||||
|
|
||||||
|
return { journeyType: 'ROUND_TRIP', outbound, inbound };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { journeyType: 'ONE_WAY', outbound };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchSchedules(
|
||||||
|
originStationId: string,
|
||||||
|
destinationStationId: string,
|
||||||
|
dateStr: string,
|
||||||
|
adultCount: number,
|
||||||
|
childCount?: number,
|
||||||
|
nationality?: string,
|
||||||
|
) {
|
||||||
|
const [y, m, d] = dateStr.split('-').map(Number);
|
||||||
|
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||||
|
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||||
|
const totalPassengers = adultCount + (childCount ?? 0);
|
||||||
|
|
||||||
const schedules = await this.prisma.trainSchedule.findMany({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||||
departureAt: { gte: date, lt: nextDay },
|
departureAt: { gte: date, lt: nextDay },
|
||||||
stopTimes: { some: { stationId: dto.originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
train: true,
|
train: true,
|
||||||
@@ -42,8 +84,8 @@ export class SearchService {
|
|||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const schedule of schedules) {
|
for (const schedule of schedules) {
|
||||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||||
|
|
||||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
||||||
|
|
||||||
@@ -109,11 +151,13 @@ export class SearchService {
|
|||||||
|
|
||||||
const faresByClass = await this.calculateFaresForSegment(
|
const faresByClass = await this.calculateFaresForSegment(
|
||||||
schedule,
|
schedule,
|
||||||
dto.originStationId,
|
originStationId,
|
||||||
dto.destinationStationId,
|
destinationStationId,
|
||||||
dto.nationality,
|
nationality,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
trainNumber: schedule.train.number,
|
trainNumber: schedule.train.number,
|
||||||
@@ -150,6 +194,7 @@ export class SearchService {
|
|||||||
availabilityByClass,
|
availabilityByClass,
|
||||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||||
faresByClass,
|
faresByClass,
|
||||||
|
coachTypes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +386,62 @@ export class SearchService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildCoachTypeDetails(
|
||||||
|
schedule: any,
|
||||||
|
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
|
||||||
|
): Promise<Array<{
|
||||||
|
coachTypeId: string;
|
||||||
|
coachTypeName: string;
|
||||||
|
coachTypeCode: string;
|
||||||
|
classes: Array<{ name: string; baseFareMinor: number }>;
|
||||||
|
}>> {
|
||||||
|
const coachTypeMap = new Map<
|
||||||
|
string,
|
||||||
|
{ coachType: any; classNames: Set<string> }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const assignment of schedule.coachAssignments) {
|
||||||
|
const coachType = assignment.coach.coachType;
|
||||||
|
if (!coachType) continue;
|
||||||
|
|
||||||
|
if (!coachTypeMap.has(coachType.id)) {
|
||||||
|
coachTypeMap.set(coachType.id, {
|
||||||
|
coachType,
|
||||||
|
classNames: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = coachTypeMap.get(coachType.id)!;
|
||||||
|
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (const [, { coachType, classNames }] of coachTypeMap) {
|
||||||
|
const classes = Array.from(classNames)
|
||||||
|
.map((className) => {
|
||||||
|
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||||
|
return {
|
||||||
|
name: className,
|
||||||
|
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
coachTypeId: coachType.id,
|
||||||
|
coachTypeName: coachType.name,
|
||||||
|
coachTypeCode: coachType.code,
|
||||||
|
classes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor));
|
||||||
|
const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor));
|
||||||
|
return minPriceA - minPriceB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getDefaultFareForClass(className: string): number {
|
private getDefaultFareForClass(className: string): number {
|
||||||
const defaults: Record<string, number> = {
|
const defaults: Record<string, number> = {
|
||||||
'Economy Regular': 35000,
|
'Economy Regular': 35000,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { StationsService } from './stations.service';
|
import { StationsService } from './stations.service';
|
||||||
import { CreateStationDto } from './stations.dto';
|
import { CreateStationDto } from './stations.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
@@ -17,6 +17,28 @@ export class StationsController {
|
|||||||
@ApiQuery({ name: 'search', required: false, description: 'Search by station name or code' })
|
@ApiQuery({ name: 'search', required: false, description: 'Search by station name or code' })
|
||||||
@ApiQuery({ name: 'country', required: false, description: 'Filter by country code (ET, DJ)' })
|
@ApiQuery({ name: 'country', required: false, description: 'Filter by country code (ET, DJ)' })
|
||||||
@ApiQuery({ name: 'operational', required: false, description: 'Filter by operational status (true, false)' })
|
@ApiQuery({ name: 'operational', required: false, description: 'Filter by operational status (true, false)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Array of stations',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
code: 'AAA',
|
||||||
|
sequence: 1,
|
||||||
|
name: 'Addis Ababa',
|
||||||
|
city: 'Addis Ababa',
|
||||||
|
countryCode: 'ET',
|
||||||
|
lat: 9.0054,
|
||||||
|
lng: 38.7636,
|
||||||
|
timezone: 'Africa/Addis_Ababa',
|
||||||
|
isOperational: true,
|
||||||
|
createdAt: '2024-01-15T10:30:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
findAll(
|
findAll(
|
||||||
@Query('search') search?: string,
|
@Query('search') search?: string,
|
||||||
@Query('country') country?: string,
|
@Query('country') country?: string,
|
||||||
@@ -30,18 +52,79 @@ export class StationsController {
|
|||||||
summary: 'Get station details by ID',
|
summary: 'Get station details by ID',
|
||||||
description: 'Returns station information including name, code, country, coordinates, and facilities'
|
description: 'Returns station information including name, code, country, coordinates, and facilities'
|
||||||
})
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Station details',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
code: 'AAA',
|
||||||
|
sequence: 1,
|
||||||
|
name: 'Addis Ababa',
|
||||||
|
city: 'Addis Ababa',
|
||||||
|
countryCode: 'ET',
|
||||||
|
lat: 9.0054,
|
||||||
|
lng: 38.7636,
|
||||||
|
timezone: 'Africa/Addis_Ababa',
|
||||||
|
isOperational: true,
|
||||||
|
createdAt: '2024-01-15T10:30:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Create new station' })
|
@ApiOperation({ summary: 'Create new station' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Station created',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
code: 'AAA',
|
||||||
|
sequence: 1,
|
||||||
|
name: 'Addis Ababa',
|
||||||
|
city: 'Addis Ababa',
|
||||||
|
countryCode: 'ET',
|
||||||
|
lat: 9.0054,
|
||||||
|
lng: 38.7636,
|
||||||
|
timezone: 'Africa/Addis_Ababa',
|
||||||
|
isOperational: true,
|
||||||
|
createdAt: '2024-01-15T10:30:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Update station' })
|
@ApiOperation({ summary: 'Update station' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Station updated',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
code: 'AAA',
|
||||||
|
sequence: 1,
|
||||||
|
name: 'Addis Ababa',
|
||||||
|
city: 'Addis Ababa',
|
||||||
|
countryCode: 'ET',
|
||||||
|
lat: 9.0054,
|
||||||
|
lng: 38.7636,
|
||||||
|
timezone: 'Africa/Addis_Ababa',
|
||||||
|
isOperational: true,
|
||||||
|
createdAt: '2024-01-15T10:30:00.000Z',
|
||||||
|
updatedAt: '2024-01-15T10:30:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'Station not found' })
|
||||||
update(@Param('id') id: string, @Body() dto: Partial<CreateStationDto>) {
|
update(@Param('id') id: string, @Body() dto: Partial<CreateStationDto>) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
@@ -50,6 +133,8 @@ export class StationsController {
|
|||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Delete station' })
|
@ApiOperation({ summary: 'Delete station' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Station deleted successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Station not found' })
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsNumber, IsOptional } from 'class-validator';
|
import { IsString, IsNumber, IsOptional, IsInt, IsBoolean } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class CreateStationDto {
|
export class CreateStationDto {
|
||||||
@@ -6,6 +6,9 @@ export class CreateStationDto {
|
|||||||
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
|
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
|
||||||
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
|
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
|
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
|
||||||
@ApiProperty({ example: 9.0054 }) @IsNumber() lat: number;
|
@ApiProperty({ example: 9.0054 }) @IsNumber() lat: number;
|
||||||
@ApiProperty({ example: 38.7636 }) @IsNumber() lng: number;
|
@ApiProperty({ example: 38.7636 }) @IsNumber() lng: number;
|
||||||
|
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number;
|
||||||
|
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../../common/audit.module';
|
||||||
import { StationsController } from './stations.controller';
|
import { StationsController } from './stations.controller';
|
||||||
import { StationsService } from './stations.service';
|
import { StationsService } from './stations.service';
|
||||||
|
|
||||||
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
|
@Module({
|
||||||
|
imports: [AuditModule],
|
||||||
|
controllers: [StationsController],
|
||||||
|
providers: [StationsService],
|
||||||
|
exports: [StationsService],
|
||||||
|
})
|
||||||
export class StationsModule {}
|
export class StationsModule {}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, Inject, Optional, BadRequestException } from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { AuditService } from '../../common/audit.service';
|
||||||
import { CreateStationDto } from './stations.dto';
|
import { CreateStationDto } from './stations.dto';
|
||||||
|
|
||||||
interface StationFilters {
|
interface StationFilters {
|
||||||
@@ -10,7 +12,11 @@ interface StationFilters {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StationsService {
|
export class StationsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private auditService: AuditService,
|
||||||
|
@Optional() @Inject(REQUEST) private request?: any,
|
||||||
|
) {}
|
||||||
|
|
||||||
findAll(filters: StationFilters = {}) {
|
findAll(filters: StationFilters = {}) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -33,7 +39,7 @@ export class StationsService {
|
|||||||
|
|
||||||
return this.prisma.station.findMany({
|
return this.prisma.station.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { name: 'asc' }
|
orderBy: { sequence: 'asc' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,20 +49,57 @@ export class StationsService {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(dto: CreateStationDto) {
|
async create(dto: CreateStationDto) {
|
||||||
return this.prisma.station.create({ data: dto });
|
const station = await this.prisma.station.create({ data: dto });
|
||||||
|
|
||||||
|
await this.auditService.log({
|
||||||
|
userId: this.request?.user?.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'Station',
|
||||||
|
entityId: station.id,
|
||||||
|
newData: station,
|
||||||
|
});
|
||||||
|
|
||||||
|
return station;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: Partial<CreateStationDto>) {
|
async update(id: string, dto: Partial<CreateStationDto>) {
|
||||||
await this.findOne(id); // Check if exists
|
const oldStation = await this.findOne(id);
|
||||||
return this.prisma.station.update({
|
const { code, name, city, timezone, lat, lng } = dto;
|
||||||
|
const data: any = { code, name, city, timezone, lat, lng };
|
||||||
|
if ('countryCode' in dto) data.countryCode = (dto as any).countryCode;
|
||||||
|
if ('sequence' in dto) data.sequence = (dto as any).sequence;
|
||||||
|
if ('isOperational' in dto) data.isOperational = (dto as any).isOperational;
|
||||||
|
|
||||||
|
const updatedStation = await this.prisma.station.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: dto
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.auditService.log({
|
||||||
|
userId: this.request?.user?.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'Station',
|
||||||
|
entityId: id,
|
||||||
|
oldData: oldStation,
|
||||||
|
newData: updatedStation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedStation;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
await this.findOne(id); // Check if exists
|
const station = await this.findOne(id);
|
||||||
return this.prisma.station.delete({ where: { id } });
|
const deleted = await this.prisma.station.delete({ where: { id } });
|
||||||
|
|
||||||
|
await this.auditService.log({
|
||||||
|
userId: this.request?.user?.id,
|
||||||
|
action: 'DELETE',
|
||||||
|
entityType: 'Station',
|
||||||
|
entityId: id,
|
||||||
|
oldData: station,
|
||||||
|
});
|
||||||
|
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ export class TicketsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('by-order/:merchantOrderId')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get ticket by merchant order ID',
|
||||||
|
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
|
||||||
|
})
|
||||||
|
getByMerchantOrderId(@Param('merchantOrderId') merchantOrderId: string) {
|
||||||
|
return this.service.getByMerchantOrderId(merchantOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':bookingRef')
|
@Get(':bookingRef')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
|||||||
@@ -49,12 +49,16 @@ export class TicketsService {
|
|||||||
booking: {
|
booking: {
|
||||||
bookingRef: t.booking.bookingRef,
|
bookingRef: t.booking.bookingRef,
|
||||||
status: t.booking.status,
|
status: t.booking.status,
|
||||||
|
totalMinor: t.booking.totalMinor,
|
||||||
|
currency: t.booking.currency,
|
||||||
|
displayCurrency: t.booking.displayCurrency,
|
||||||
|
displayTotalMinor: t.booking.displayTotalMinor,
|
||||||
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
|
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
|
||||||
contactEmail: t.booking.contactEmail,
|
contactEmail: t.booking.contactEmail,
|
||||||
},
|
},
|
||||||
schedule: t.booking.schedule,
|
schedule: t.booking.schedule,
|
||||||
seat: t.booking.seats[0]?.seat,
|
seat: t.booking.seats[0]?.seat,
|
||||||
status: t.booking.status,
|
status: t.status,
|
||||||
validatedAt: t.validatedAt,
|
validatedAt: t.validatedAt,
|
||||||
createdAt: t.issuedAt,
|
createdAt: t.issuedAt,
|
||||||
})),
|
})),
|
||||||
@@ -157,9 +161,14 @@ export class TicketsService {
|
|||||||
return { success: true, updatedSeats: newSeatIds.length };
|
return { success: true, updatedSeats: newSeatIds.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByRef(bookingRef: string) {
|
async getByMerchantOrderId(merchantOrderId: string) {
|
||||||
|
const intent = await this.prisma.paymentIntent.findUnique({
|
||||||
|
where: { merchantOrderId },
|
||||||
|
select: { bookingId: true },
|
||||||
|
});
|
||||||
|
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { bookingRef },
|
where: { id: intent.bookingId },
|
||||||
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
|
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
|
||||||
});
|
});
|
||||||
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
|
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
|
||||||
@@ -170,6 +179,36 @@ export class TicketsService {
|
|||||||
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
|
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
|
||||||
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
|
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
|
||||||
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
|
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
|
||||||
|
barcodePayload: booking.ticket.barcodePayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByRef(bookingRef: string) {
|
||||||
|
const booking = await this.prisma.booking.findUnique({
|
||||||
|
where: { bookingRef },
|
||||||
|
include: {
|
||||||
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
|
ticket: true
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
const seat = booking.seats[0];
|
||||||
|
return {
|
||||||
|
id: booking.ticket.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingRef: booking.bookingRef,
|
||||||
|
status: booking.status,
|
||||||
|
fromStationName: booking.schedule.originStation.name,
|
||||||
|
toStationName: booking.schedule.destinationStation.name,
|
||||||
|
departureAt: booking.schedule.departureAt,
|
||||||
|
trainName: booking.schedule.train.name,
|
||||||
|
coachLabel: seat?.seat.coach.number,
|
||||||
|
seatLabel: seat?.seat.seatNumber,
|
||||||
|
passengerName: seat?.passengerName,
|
||||||
|
priceMinor: booking.totalMinor,
|
||||||
|
currency: booking.currency,
|
||||||
|
qrPayload: booking.ticket.qrPayload,
|
||||||
barcodePayload: booking.ticket.barcodePayload
|
barcodePayload: booking.ticket.barcodePayload
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": ["next/core-web-vitals"]
|
"extends": ["next/core-web-vitals"],
|
||||||
|
"rules": {
|
||||||
|
"react/no-unescaped-entities": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2897
apps/edr-passenger-web/backoffice/public/docs.md
Normal file
2897
apps/edr-passenger-web/backoffice/public/docs.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,27 +2,90 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Search, Eye } from 'lucide-react';
|
import { Eye, Download } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import { auditApi } from '@/lib/api';
|
import { auditApi } from '@/lib/api';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
|
|
||||||
export default function AuditLogsPage() {
|
export default function AuditLogsPage() {
|
||||||
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
|
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
|
||||||
|
const [selectedLog, setSelectedLog] = useState<any>(null);
|
||||||
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['audit-logs', filters],
|
queryKey: ['audit-logs', filters],
|
||||||
queryFn: () => auditApi.getLogs(filters),
|
queryFn: () => auditApi.getLogs(filters),
|
||||||
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getActionBadgeColor = (action: string) => {
|
||||||
|
switch (action) {
|
||||||
|
case 'CREATE':
|
||||||
|
return 'success';
|
||||||
|
case 'UPDATE':
|
||||||
|
return 'primary';
|
||||||
|
case 'DELETE':
|
||||||
|
return 'danger';
|
||||||
|
case 'LOGIN':
|
||||||
|
return 'info';
|
||||||
|
case 'LOGOUT':
|
||||||
|
return 'secondary';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatJsonData = (data: any) => {
|
||||||
|
if (!data) return 'N/A';
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
label: 'Timestamp',
|
||||||
|
sortable: true,
|
||||||
|
render: (log: any) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">{formatDateTime(log.createdAt)}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{new Date(log.createdAt).toLocaleTimeString()}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'action',
|
key: 'action',
|
||||||
label: 'Action',
|
label: 'Action',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (log: any) => (
|
render: (log: any) => (
|
||||||
<Badge>{log.action}</Badge>
|
<Badge className={getActionBadgeColor(log.action)}>
|
||||||
|
{log.action}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'entityType',
|
||||||
|
label: 'Entity Type',
|
||||||
|
sortable: true,
|
||||||
|
render: (log: any) => (
|
||||||
|
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
|
||||||
|
{log.entityType}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'entityId',
|
||||||
|
label: 'Entity ID',
|
||||||
|
render: (log: any) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{log.entityId ? log.entityId.substring(0, 12) : 'System'}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -30,55 +93,74 @@ export default function AuditLogsPage() {
|
|||||||
label: 'User',
|
label: 'User',
|
||||||
render: (log: any) => (
|
render: (log: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{log.user?.fullName || 'System'}</div>
|
<div className="font-medium text-sm">{log.user?.fullName || 'System'}</div>
|
||||||
<div className="text-sm text-muted-foreground">{log.user?.email || 'N/A'}</div>
|
<div className="text-xs text-muted-foreground">{log.user?.email || log.userId || 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'entityType',
|
key: 'ipAddress',
|
||||||
label: 'Entity Type',
|
label: 'IP Address',
|
||||||
render: (log: any) => log.entityType,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'entityId',
|
|
||||||
label: 'Entity ID',
|
|
||||||
render: (log: any) => (
|
render: (log: any) => (
|
||||||
<span className="font-mono text-sm">{log.entityId?.substring(0, 8)}...</span>
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{log.ipAddress || 'N/A'}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'createdAt',
|
|
||||||
label: 'Timestamp',
|
|
||||||
sortable: true,
|
|
||||||
render: (log: any) => formatDateTime(log.createdAt),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'View Details',
|
label: 'View Details',
|
||||||
onClick: (log: any) => {
|
onClick: (log: any) => {
|
||||||
window.location.href = `/audit/${log.id}`;
|
setSelectedLog(log);
|
||||||
|
setShowDetailsModal(true);
|
||||||
},
|
},
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const logs = data?.items || [];
|
||||||
|
const stats = {
|
||||||
|
total: logs.length,
|
||||||
|
creates: logs.filter((l: any) => l.action === 'CREATE').length,
|
||||||
|
updates: logs.filter((l: any) => l.action === 'UPDATE').length,
|
||||||
|
deletes: logs.filter((l: any) => l.action === 'DELETE').length,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Audit Logs</h1>
|
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
|
||||||
<p className="text-muted-foreground">Track all system activities and changes</p>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-muted-foreground text-sm font-medium">Total Logs</div>
|
||||||
|
<div className="text-2xl font-bold mt-2">{stats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-green-600 text-sm font-medium">Created</div>
|
||||||
|
<div className="text-2xl font-bold mt-2">{stats.creates}</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-blue-600 text-sm font-medium">Updated</div>
|
||||||
|
<div className="text-2xl font-bold mt-2">{stats.updates}</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-red-600 text-sm font-medium">Deleted</div>
|
||||||
|
<div className="text-2xl font-bold mt-2">{stats.deletes}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Search</label>
|
<label className="label">Search (User/Entity ID)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search logs..."
|
placeholder="Search logs..."
|
||||||
@@ -110,22 +192,172 @@ export default function AuditLogsPage() {
|
|||||||
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
||||||
>
|
>
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option value="Booking">Booking</option>
|
<optgroup label="Master Data">
|
||||||
<option value="User">User</option>
|
<option value="Station">Station</option>
|
||||||
<option value="Payment">Payment</option>
|
<option value="Route">Route</option>
|
||||||
<option value="Ticket">Ticket</option>
|
<option value="RouteStop">Route Stop</option>
|
||||||
|
<option value="Train">Train</option>
|
||||||
|
<option value="TrainSchedule">Train Schedule</option>
|
||||||
|
<option value="Coach">Coach</option>
|
||||||
|
<option value="CoachType">Coach Type</option>
|
||||||
|
<option value="SeatClass">Seat Class</option>
|
||||||
|
<option value="FareRule">Fare Rule</option>
|
||||||
|
<option value="RouteFareRule">Route Fare Rule</option>
|
||||||
|
<option value="SegmentFareRule">Segment Fare Rule</option>
|
||||||
|
<option value="BaggageAllowance">Baggage Allowance</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Operations">
|
||||||
|
<option value="Booking">Booking</option>
|
||||||
|
<option value="Payment">Payment</option>
|
||||||
|
<option value="Ticket">Ticket</option>
|
||||||
|
<option value="Seat">Seat</option>
|
||||||
|
<option value="SeatBlock">Seat Block</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Users & Access">
|
||||||
|
<option value="User">User</option>
|
||||||
|
<option value="Agent">Agent</option>
|
||||||
|
<option value="Passenger">Passenger</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="System & Features">
|
||||||
|
<option value="Notification">Notification</option>
|
||||||
|
<option value="Promotion">Promotion</option>
|
||||||
|
<option value="Loyalty">Loyalty</option>
|
||||||
|
<option value="Wallet">Wallet</option>
|
||||||
|
<option value="FraudAlert">Fraud Alert</option>
|
||||||
|
<option value="FraudRule">Fraud Rule</option>
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<ActionButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setFilters({ search: '', action: '', entityType: '' })}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
<DataTable
|
<DataTable
|
||||||
data={data?.items || []}
|
data={logs}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No audit logs found"
|
emptyMessage="No audit logs found"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Details Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showDetailsModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDetailsModal(false);
|
||||||
|
setSelectedLog(null);
|
||||||
|
}}
|
||||||
|
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
|
||||||
|
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">Action</label>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
<Badge className={getActionBadgeColor(selectedLog?.action)}>
|
||||||
|
{selectedLog?.action}
|
||||||
|
</Badge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
|
||||||
|
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
|
||||||
|
<p className="text-sm mt-1 font-mono text-muted-foreground">
|
||||||
|
{selectedLog?.entityId || 'System'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
{selectedLog?.user && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h4 className="text-sm font-semibold mb-2">User Information</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">Name</label>
|
||||||
|
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">Email</label>
|
||||||
|
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Network Info */}
|
||||||
|
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedLog?.ipAddress && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
|
||||||
|
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedLog?.userAgent && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
|
||||||
|
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
|
||||||
|
{selectedLog?.userAgent}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Changes */}
|
||||||
|
{(selectedLog?.oldData || selectedLog?.newData) && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{selectedLog?.oldData && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-red-600">Old Data</label>
|
||||||
|
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
|
||||||
|
{formatJsonData(selectedLog?.oldData)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedLog?.newData && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-green-600">New Data</label>
|
||||||
|
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
|
||||||
|
{formatJsonData(selectedLog?.newData)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw Log ID */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
|
||||||
|
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,45 @@ export default function BookingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportBookings = async () => {
|
||||||
|
const selectedColumns = prompt(
|
||||||
|
'Select columns to export (comma-separated):\n\n' +
|
||||||
|
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
|
||||||
|
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
|
||||||
|
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selectedColumns) return;
|
||||||
|
|
||||||
|
const cols = selectedColumns.split(',').map(c => c.trim());
|
||||||
|
const csv = [
|
||||||
|
cols.join(','),
|
||||||
|
...data?.items?.map((booking: any) => {
|
||||||
|
const values = cols.map(col => {
|
||||||
|
switch(col) {
|
||||||
|
case 'bookingRef': return booking.bookingRef;
|
||||||
|
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
|
||||||
|
case 'status': return booking.status;
|
||||||
|
case 'bookingType': return booking.bookingType || 'N/A';
|
||||||
|
case 'passengerCount': return booking.adultCount + booking.childCount;
|
||||||
|
case 'totalMinor': return booking.totalMinor;
|
||||||
|
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
|
||||||
|
case 'createdAt': return booking.createdAt;
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return values.map(v => `"${v}"`).join(',');
|
||||||
|
}) || []
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'bookingRef',
|
key: 'bookingRef',
|
||||||
@@ -99,6 +138,17 @@ export default function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'bookingType',
|
||||||
|
label: 'Class',
|
||||||
|
sortable: true,
|
||||||
|
render: (booking: any) => booking.bookingType || 'ONE_WAY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'passengerCount',
|
||||||
|
label: 'Passengers',
|
||||||
|
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
@@ -158,7 +208,7 @@ export default function BookingsPage() {
|
|||||||
<h1 className="text-2xl font-bold">Bookings</h1>
|
<h1 className="text-2xl font-bold">Bookings</h1>
|
||||||
<p className="text-muted-foreground">Manage all passenger bookings</p>
|
<p className="text-muted-foreground">Manage all passenger bookings</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton variant="export" icon={Download}>Export</ActionButton>
|
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ export default function ClassesPage() {
|
|||||||
coachTypeId: selectedCoachTypeId,
|
coachTypeId: selectedCoachTypeId,
|
||||||
name: formData.get('name') as string,
|
name: formData.get('name') as string,
|
||||||
description: formData.get('description') as string,
|
description: formData.get('description') as string,
|
||||||
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
|
baseFareMinor: Math.round(parseFloat(formData.get('baseFareMinor') as string) * 100) || 0,
|
||||||
|
premiumMinor: Math.round(parseFloat(formData.get('premiumMinor') as string) * 100) || 0,
|
||||||
|
insuranceFeeMinor: Math.round(parseFloat(formData.get('insuranceFeeMinor') as string) * 100) || 0,
|
||||||
isActive: formData.get('isActive') === 'true',
|
isActive: formData.get('isActive') === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,9 +138,23 @@ export default function ClassesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'baseFareMinor',
|
key: 'baseFareMinor',
|
||||||
label: 'Base Fare (ETB)',
|
label: 'Base Fare',
|
||||||
render: (cls: any) => (
|
render: (cls: any) => (
|
||||||
<span className="font-mono text-sm">{formatCurrency(cls.baseFareMinor, 'ETB')}</span>
|
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'premiumMinor',
|
||||||
|
label: 'Premium',
|
||||||
|
render: (cls: any) => (
|
||||||
|
<span className="font-mono text-sm">{cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'insuranceFeeMinor',
|
||||||
|
label: 'Insurance',
|
||||||
|
render: (cls: any) => (
|
||||||
|
<span className="font-mono text-sm">{cls.insuranceFeeMinor ? (cls.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,7 +197,7 @@ export default function ClassesPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
|
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
|
||||||
<p className="text-muted-foreground">Manage class configurations by coach type</p>
|
<p className="text-muted-foreground">Manage class configurations with pricing by coach type</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
@@ -278,18 +294,60 @@ export default function ClassesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="border-t pt-4">
|
||||||
<label className="label">Base Fare (ETB cents) *</label>
|
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||||||
<input
|
|
||||||
type="number"
|
<div>
|
||||||
name="baseFareMinor"
|
<label className="label">Base Fare (ETB) *</label>
|
||||||
className="input"
|
<input
|
||||||
defaultValue={editingClass?.baseFareMinor || ''}
|
type="number"
|
||||||
required
|
name="baseFareMinor"
|
||||||
min="0"
|
className="input"
|
||||||
placeholder="e.g., 45000 (450 ETB)"
|
defaultValue={editingClass?.baseFareMinor ? (editingClass.baseFareMinor / 100).toFixed(2) : ''}
|
||||||
/>
|
required
|
||||||
<p className="text-xs text-muted-foreground mt-1">Enter amount in cents (100 cents = 1 ETB)</p>
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 350.00"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Per-km distance-based fare rate</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Premium Fee (ETB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="premiumMinor"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingClass?.premiumMinor ? (editingClass.premiumMinor / 100).toFixed(2) : '0.00'}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 50.00"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., lounge access, extra legroom)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Insurance Fee (ETB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="insuranceFeeMinor"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingClass?.insuranceFeeMinor ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g., 25.00"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<p className="font-medium mb-1">Total Fare Calculation:</p>
|
||||||
|
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
|
||||||
|
<p className="mt-2 text-xs">• Premium applies per passenger (including free child)</p>
|
||||||
|
<p className="text-xs">• Insurance applies per passenger (including free child)</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react';
|
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
@@ -11,6 +11,135 @@ import { fleetApi, apiClient } from '@/lib/api';
|
|||||||
|
|
||||||
type Tab = 'types' | 'coaches';
|
type Tab = 'types' | 'coaches';
|
||||||
|
|
||||||
|
const getBedLabel = (bedPosition: string | null): string => {
|
||||||
|
if (bedPosition === 'upper') return 'U';
|
||||||
|
if (bedPosition === 'middle') return 'M';
|
||||||
|
if (bedPosition === 'lower') return 'L';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBedVisualization = (coach: any) => {
|
||||||
|
const seats = coach.seats || [];
|
||||||
|
const validSeats = seats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||||
|
|
||||||
|
if (validSeats.length === 0) {
|
||||||
|
return <div className="text-xs text-muted-foreground">No seats</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||||
|
const isBedCoach = coach.coachType?.name?.toLowerCase().includes('bed');
|
||||||
|
|
||||||
|
if (!isBedCoach || !hasBedPositionData) {
|
||||||
|
// Regular seat layout
|
||||||
|
const arrangement = coach.seatArrangement || coach.arrangement || '2+2';
|
||||||
|
const [left, right] = arrangement.split('+').map((p: string) => parseInt(p.trim()));
|
||||||
|
const cols = new Map<number, any[]>();
|
||||||
|
|
||||||
|
for (const seat of validSeats) {
|
||||||
|
if (!cols.has(seat.row)) cols.set(seat.row, []);
|
||||||
|
cols.get(seat.row)!.push(seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Array.from(cols.entries()).map(([row, rowSeats]) => (
|
||||||
|
<div key={row} className="flex gap-3 justify-start">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rowSeats.slice(0, left).map((s: any) => (
|
||||||
|
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
|
||||||
|
<Armchair className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rowSeats.slice(left).map((s: any) => (
|
||||||
|
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
|
||||||
|
<Armchair className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bed layout with pairing
|
||||||
|
const seatsByRow = new Map<number, any[]>();
|
||||||
|
for (const seat of validSeats) {
|
||||||
|
if (!seatsByRow.has(seat.row)) seatsByRow.set(seat.row, []);
|
||||||
|
seatsByRow.get(seat.row)!.push(seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beds = coach.coachType?.name?.toLowerCase().includes('vip') ? 'w-12' : 'w-10';
|
||||||
|
const rows = Array.from(seatsByRow.entries()).map(([r, s]) => s);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{rows.map((rowSeats: any[], idx: number) => {
|
||||||
|
const rowNumber = rowSeats[0]?.row || (idx + 1);
|
||||||
|
const isFirstInPair = (rowNumber - 1) % 2 === 0;
|
||||||
|
const isLastRow = idx === rows.length - 1;
|
||||||
|
const nextRowSeats = !isLastRow ? rows[idx + 1] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`row-${idx}`}>
|
||||||
|
{/* Row 1 of pair - label above */}
|
||||||
|
{isFirstInPair && (
|
||||||
|
<div className="flex gap-0.5 text-xs text-gray-500 mb-0.5">
|
||||||
|
{rowSeats.map((s: any) => (
|
||||||
|
<div key={`label-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
|
||||||
|
{s.seatNumber}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Row 1 of pair - beds */}
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rowSeats.map((s: any) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
|
||||||
|
style={isFirstInPair ? { transform: 'scaleY(-1)' } : undefined}
|
||||||
|
>
|
||||||
|
<Bed className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Numbers between rows */}
|
||||||
|
{isFirstInPair && nextRowSeats && (
|
||||||
|
<div className="flex gap-0.5 text-xs text-gray-500 my-0.5">
|
||||||
|
{rowSeats.map((s: any, idx: number) => {
|
||||||
|
const nextSeat = nextRowSeats[idx];
|
||||||
|
return (
|
||||||
|
<div key={`between-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
|
||||||
|
{nextSeat?.seatNumber}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Row 2 of pair - beds */}
|
||||||
|
{!isFirstInPair && (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rowSeats.map((s: any) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
|
||||||
|
>
|
||||||
|
<Bed className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isFirstInPair && <div className="h-1" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function CoachesPage() {
|
export default function CoachesPage() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -107,6 +236,7 @@ export default function CoachesPage() {
|
|||||||
coachTypeId: formData.get('coachTypeId') as string,
|
coachTypeId: formData.get('coachTypeId') as string,
|
||||||
arrangement: formData.get('arrangement') as string,
|
arrangement: formData.get('arrangement') as string,
|
||||||
capacity: parseInt(formData.get('capacity') as string),
|
capacity: parseInt(formData.get('capacity') as string),
|
||||||
|
sequence: parseInt(formData.get('sequence') as string),
|
||||||
status: formData.get('status') as string,
|
status: formData.get('status') as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,6 +338,14 @@ export default function CoachesPage() {
|
|||||||
|
|
||||||
// Coaches Columns
|
// Coaches Columns
|
||||||
const coachColumns = [
|
const coachColumns = [
|
||||||
|
{
|
||||||
|
key: 'sequence',
|
||||||
|
label: 'Sequence',
|
||||||
|
sortable: true,
|
||||||
|
render: (coach: any) => (
|
||||||
|
<span className="font-mono font-semibold text-sm">{coach.sequence}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'number',
|
key: 'number',
|
||||||
label: 'Number',
|
label: 'Number',
|
||||||
@@ -486,7 +624,7 @@ export default function CoachesPage() {
|
|||||||
<form onSubmit={handleCoachSubmit} className="space-y-4">
|
<form onSubmit={handleCoachSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Coach Type</label>
|
<label className="label">Coach Type *</label>
|
||||||
<select
|
<select
|
||||||
name="coachTypeId"
|
name="coachTypeId"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -503,7 +641,7 @@ export default function CoachesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Number</label>
|
<label className="label">Number *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="number"
|
name="number"
|
||||||
@@ -515,20 +653,20 @@ export default function CoachesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Arrangement</label>
|
<label className="label">Arrangement *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="arrangement"
|
name="arrangement"
|
||||||
className="input"
|
className="input"
|
||||||
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
|
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
|
||||||
required
|
required
|
||||||
placeholder="e.g., 2+2, 3+2"
|
placeholder="e.g., 3+2, 3+0, 2+0"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
|
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Capacity</label>
|
<label className="label">Capacity *</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="capacity"
|
name="capacity"
|
||||||
@@ -540,12 +678,27 @@ export default function CoachesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div>
|
||||||
<label className="label">Status</label>
|
<label className="label">Sequence Number *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="sequence"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingItem?.sequence || 0}
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
placeholder="e.g., 1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Used for ordering coaches in trains</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Status *</label>
|
||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
className="input"
|
className="input"
|
||||||
defaultValue={editingItem?.status || 'ACTIVE'}
|
defaultValue={editingItem?.status || 'ACTIVE'}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<option value="ACTIVE">Active</option>
|
<option value="ACTIVE">Active</option>
|
||||||
<option value="MAINTENANCE">Maintenance</option>
|
<option value="MAINTENANCE">Maintenance</option>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Sidebar from '@/components/layout/Sidebar';
|
||||||
|
import Header from '@/components/layout/Header';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
|
export default function CurrenciesLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router, isLoading]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
475
apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
Normal file
475
apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react';
|
||||||
|
import DataTable from '@/components/ui/DataTable';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
interface Currency {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
baseCurrencyCode: string;
|
||||||
|
exchangeRate: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CurrenciesPage() {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingCurrency, setEditingCurrency] = useState<Currency | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({
|
||||||
|
isOpen: false,
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [currencyForm, setCurrencyForm] = useState({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
symbol: '',
|
||||||
|
baseCurrencyCode: 'ETB',
|
||||||
|
exchangeRate: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: currencies = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['currencies'],
|
||||||
|
queryFn: () => apiClient.get('/currencies'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiClient.post('/currencies', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||||
|
resetForm();
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setError(err.response?.data?.message || 'Failed to create currency');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||||
|
setEditingCurrency(null);
|
||||||
|
resetForm();
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setError(err.response?.data?.message || 'Failed to update currency');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||||
|
setDeleteConfirm({ isOpen: false, id: null });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setError(err.response?.data?.message || 'Failed to delete currency');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncRatesMutation = useMutation({
|
||||||
|
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setError(err.response?.data?.message || 'Failed to sync exchange rates');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setCurrencyForm({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
symbol: '',
|
||||||
|
baseCurrencyCode: 'ETB',
|
||||||
|
exchangeRate: '',
|
||||||
|
});
|
||||||
|
setEditingCurrency(null);
|
||||||
|
setShowModal(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCurrency = (currency: Currency) => {
|
||||||
|
setEditingCurrency(currency);
|
||||||
|
setCurrencyForm({
|
||||||
|
code: currency.code,
|
||||||
|
name: currency.name,
|
||||||
|
symbol: currency.symbol,
|
||||||
|
baseCurrencyCode: currency.baseCurrencyCode,
|
||||||
|
exchangeRate: currency.exchangeRate.toString(),
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCurrency = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) {
|
||||||
|
setError('All fields are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = parseFloat(currencyForm.exchangeRate);
|
||||||
|
if (isNaN(rate) || rate <= 0) {
|
||||||
|
setError('Exchange rate must be a positive number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
code: currencyForm.code.toUpperCase(),
|
||||||
|
name: currencyForm.name,
|
||||||
|
symbol: currencyForm.symbol,
|
||||||
|
baseCurrencyCode: currencyForm.baseCurrencyCode,
|
||||||
|
exchangeRate: rate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCurrency) {
|
||||||
|
await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload });
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (deleteConfirm.id) {
|
||||||
|
await deleteMutation.mutateAsync(deleteConfirm.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || [];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'code',
|
||||||
|
label: 'Code',
|
||||||
|
render: (currency: Currency) => (
|
||||||
|
<span className="font-mono font-semibold text-primary">{currency.code}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
render: (currency: Currency) => (
|
||||||
|
<span className="font-medium">{currency.name}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'symbol',
|
||||||
|
label: 'Symbol',
|
||||||
|
render: (currency: Currency) => (
|
||||||
|
<span className="text-lg">{currency.symbol}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'baseCurrencyCode',
|
||||||
|
label: 'Base Currency',
|
||||||
|
render: (currency: Currency) => (
|
||||||
|
<span className="font-mono text-sm">{currency.baseCurrencyCode}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exchangeRate',
|
||||||
|
label: 'Exchange Rate',
|
||||||
|
render: (currency: Currency) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-mono font-semibold">
|
||||||
|
1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
label: 'Status',
|
||||||
|
render: (currency: Currency) => (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
currency.isActive
|
||||||
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-900/20 text-gray-800 dark:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{currency.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'updatedAt',
|
||||||
|
label: 'Last Updated',
|
||||||
|
render: (currency: Currency) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{new Date(currency.updatedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
onClick: handleEditCurrency,
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
icon: Edit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }),
|
||||||
|
variant: 'danger' as const,
|
||||||
|
icon: Trash2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">Currencies</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Manage exchange rates and display currencies</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ActionButton
|
||||||
|
icon={RefreshCw}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => syncRatesMutation.mutate()}
|
||||||
|
loading={syncRatesMutation.isPending}
|
||||||
|
>
|
||||||
|
Sync Rates
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
icon={Plus}
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setEditingCurrency(null);
|
||||||
|
setCurrencyForm({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
symbol: '',
|
||||||
|
baseCurrencyCode: 'ETB',
|
||||||
|
exchangeRate: '',
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Currency
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-900/10 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="text-sm text-blue-600 dark:text-blue-400 font-medium">Total Currencies</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-900 dark:text-blue-200 mt-2">
|
||||||
|
{currenciesArray.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-900/10 rounded-lg border border-green-200 dark:border-green-800">
|
||||||
|
<div className="text-sm text-green-600 dark:text-green-400 font-medium">Active</div>
|
||||||
|
<div className="text-2xl font-bold text-green-900 dark:text-green-200 mt-2">
|
||||||
|
{currenciesArray.filter((c: Currency) => c.isActive).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-900/10 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||||
|
<div className="text-sm text-purple-600 dark:text-purple-400 font-medium">Base Currency</div>
|
||||||
|
<div className="text-2xl font-bold text-purple-900 dark:text-purple-200 mt-2">ETB</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-900/10 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||||
|
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">Last Sync</div>
|
||||||
|
<div className="text-lg font-bold text-orange-900 dark:text-orange-200 mt-2">
|
||||||
|
{currenciesArray.length > 0
|
||||||
|
? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString()
|
||||||
|
: 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : currenciesArray.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p>No currencies configured. Click "Add Currency" to create one.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={currenciesArray}
|
||||||
|
columns={columns}
|
||||||
|
actions={actions}
|
||||||
|
loading={false}
|
||||||
|
emptyMessage="No currencies found."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||||
|
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Currency Management</h3>
|
||||||
|
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
|
||||||
|
<li>
|
||||||
|
• <strong>Base Currency:</strong> All exchange rates are calculated relative to this currency (typically ETB)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>Exchange Rate:</strong> How many units of the currency equal 1 unit of the base currency
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>Display Currencies:</strong> Configure which currencies customers can view prices in
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• <strong>Sync Rates:</strong> Automatically update exchange rates from external sources
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteConfirm.isOpen}
|
||||||
|
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
title="Delete Currency"
|
||||||
|
message="Are you sure you want to delete this currency? This action cannot be undone."
|
||||||
|
confirmText="Delete"
|
||||||
|
isDanger={true}
|
||||||
|
warning="This will remove the currency from the system."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={resetForm}
|
||||||
|
title={`${editingCurrency ? 'Edit' : 'Add'} Currency`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Currency Code *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currencyForm.code}
|
||||||
|
onChange={(e) => setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="e.g., USD"
|
||||||
|
maxLength={3}
|
||||||
|
disabled={!!editingCurrency}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">3-letter ISO code (e.g., USD, DJF, GBP)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Currency Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currencyForm.name}
|
||||||
|
onChange={(e) => setCurrencyForm({ ...currencyForm, name: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="e.g., United States Dollar"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Symbol *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currencyForm.symbol}
|
||||||
|
onChange={(e) => setCurrencyForm({ ...currencyForm, symbol: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="e.g., $"
|
||||||
|
maxLength={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Base Currency *</label>
|
||||||
|
<select
|
||||||
|
value={currencyForm.baseCurrencyCode}
|
||||||
|
onChange={(e) => setCurrencyForm({ ...currencyForm, baseCurrencyCode: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<option value="ETB">ETB (Ethiopian Birr)</option>
|
||||||
|
<option value="USD">USD (US Dollar)</option>
|
||||||
|
<option value="DJF">DJF (Djiboutian Franc)</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">All rates relative to this currency</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Exchange Rate *</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.0001"
|
||||||
|
value={currencyForm.exchangeRate}
|
||||||
|
onChange={(e) => setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="e.g., 0.018"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
≈ 1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 rounded-lg text-xs text-blue-800 dark:text-blue-200">
|
||||||
|
<p className="font-semibold mb-1">Exchange Rate Example:</p>
|
||||||
|
<p>If 1 ETB = 0.018 USD, enter 0.018</p>
|
||||||
|
<p>If 1 ETB = 3.25 DJF, enter 3.25</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end pt-4">
|
||||||
|
<ActionButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={resetForm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
onClick={handleSaveCurrency}
|
||||||
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{editingCurrency ? 'Update Currency' : 'Add Currency'}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react';
|
import { Ticket, Users, DollarSign, Percent } from 'lucide-react';
|
||||||
import StatCard from '@/components/dashboard/StatCard';
|
import StatCard from '@/components/dashboard/StatCard';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import { dashboardApi } from '@/lib/api/dashboard';
|
import { dashboardApi } from '@/lib/api/dashboard';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||||
|
|
||||||
|
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||||
@@ -20,18 +22,51 @@ export default function DashboardPage() {
|
|||||||
queryFn: () => dashboardApi.getRevenueChart(30),
|
queryFn: () => dashboardApi.getRevenueChart(30),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any>({
|
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any[]>({
|
||||||
queryKey: ['recent-bookings'],
|
queryKey: ['recent-bookings'],
|
||||||
queryFn: () => dashboardApi.getRecentBookings(10),
|
queryFn: () => dashboardApi.getRecentBookings(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentBookings = Array.isArray(recentBookingsData)
|
const { data: topAgents, isLoading: agentsLoading } = useQuery({
|
||||||
? recentBookingsData
|
queryKey: ['top-agents'],
|
||||||
: recentBookingsData?.items || recentBookingsData?.data || [];
|
queryFn: () => dashboardApi.getTopAgents(5),
|
||||||
|
});
|
||||||
|
|
||||||
const columns = [
|
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
|
||||||
|
queryKey: ['occupancy-trend'],
|
||||||
|
queryFn: () => dashboardApi.getOccupancyTrend(7),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
|
||||||
|
queryKey: ['upcoming-trips'],
|
||||||
|
queryFn: () => dashboardApi.getUpcomingTrips(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: paymentMethods } = useQuery({
|
||||||
|
queryKey: ['payment-methods'],
|
||||||
|
queryFn: dashboardApi.getPaymentMethods,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : [];
|
||||||
|
|
||||||
|
const bookingColumns = [
|
||||||
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
|
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
|
||||||
{ key: 'passenger', label: 'Passenger', render: (item: any) => item.passenger?.fullName || item.contactEmail || 'N/A' },
|
{
|
||||||
|
key: 'passenger',
|
||||||
|
label: 'Passenger',
|
||||||
|
render: (item: any) => {
|
||||||
|
if (item.passenger?.fullName) {
|
||||||
|
return item.passenger.fullName;
|
||||||
|
}
|
||||||
|
if (item.contactEmail) {
|
||||||
|
return item.contactEmail;
|
||||||
|
}
|
||||||
|
if (item.contactPhone) {
|
||||||
|
return item.contactPhone;
|
||||||
|
}
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
},
|
||||||
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
|
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
@@ -45,13 +80,37 @@ export default function DashboardPage() {
|
|||||||
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
|
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const agentColumns = [
|
||||||
|
{ key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName },
|
||||||
|
{ key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 },
|
||||||
|
{ key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') },
|
||||||
|
{ key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tripColumns = [
|
||||||
|
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
|
||||||
|
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` },
|
||||||
|
{ key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
|
||||||
|
{ key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
render: (item: any) => (
|
||||||
|
<Badge variant="status" status={item.status}>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||||
<p className="text-muted-foreground mt-1">Hello, welcome back! Here's what's happening today.</p>
|
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Metrics */}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Bookings"
|
title="Total Bookings"
|
||||||
@@ -74,35 +133,107 @@ export default function DashboardPage() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
title="Occupancy Rate"
|
title="Occupancy Rate"
|
||||||
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
|
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
|
||||||
icon={TrendingUp}
|
icon={Percent}
|
||||||
color="green"
|
color="orange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!revenueLoading && revenueData && revenueData.length > 0 && (
|
{/* Charts Row */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* Revenue Trend */}
|
||||||
|
{!revenueLoading && revenueData && revenueData.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={revenueData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||||
|
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||||
|
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
|
||||||
|
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Occupancy Trend */}
|
||||||
|
{!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-foreground">Occupancy Trend (Last 7 Days)</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={occupancyTrend}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||||
|
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
||||||
|
<Tooltip formatter={(value: number) => `${value}%`} />
|
||||||
|
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Methods Distribution */}
|
||||||
|
{paymentMethods && paymentMethods.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
|
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={revenueData}>
|
<PieChart>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<Pie
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
data={paymentMethods}
|
||||||
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
|
dataKey="count"
|
||||||
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
|
nameKey="method"
|
||||||
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
|
cx="50%"
|
||||||
</LineChart>
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{paymentMethods.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Recent Bookings */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
|
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={recentBookings}
|
data={recentBookings}
|
||||||
columns={columns}
|
columns={bookingColumns}
|
||||||
loading={bookingsLoading}
|
loading={bookingsLoading}
|
||||||
emptyMessage="No recent bookings"
|
emptyMessage="No recent bookings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Trips */}
|
||||||
|
{upcomingTrips && upcomingTrips.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-foreground">Upcoming Trips</h2>
|
||||||
|
<DataTable
|
||||||
|
data={upcomingTrips}
|
||||||
|
columns={tripColumns}
|
||||||
|
loading={tripsLoading}
|
||||||
|
emptyMessage="No upcoming trips"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Agents */}
|
||||||
|
{topAgents && topAgents.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-foreground">Top Performing Agents</h2>
|
||||||
|
<DataTable
|
||||||
|
data={topAgents}
|
||||||
|
columns={agentColumns}
|
||||||
|
loading={agentsLoading}
|
||||||
|
emptyMessage="No agent data"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
949
apps/edr-passenger-web/backoffice/src/app/docs/page.tsx
Normal file
949
apps/edr-passenger-web/backoffice/src/app/docs/page.tsx
Normal file
@@ -0,0 +1,949 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react';
|
||||||
|
|
||||||
|
const DocPage = () => {
|
||||||
|
const [expandedSections, setExpandedSections] = useState<{ [key: string]: boolean }>({
|
||||||
|
overview: true,
|
||||||
|
operations: true,
|
||||||
|
masterdata: false,
|
||||||
|
financial: false,
|
||||||
|
services: false,
|
||||||
|
security: false,
|
||||||
|
analytics: false,
|
||||||
|
system: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
setExpandedSections(prev => (({
|
||||||
|
...prev,
|
||||||
|
[section]: !prev[section]
|
||||||
|
})));
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToSection = (id: string) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
const headerOffset = 120;
|
||||||
|
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
|
||||||
|
const offsetPosition = elementPosition - headerOffset;
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
title: '📋 Overview & Getting Started',
|
||||||
|
items: [
|
||||||
|
{ id: 'about', label: 'Application Overview' },
|
||||||
|
{ id: 'features', label: 'Key Features' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'operations',
|
||||||
|
title: '📊 Operations',
|
||||||
|
items: [
|
||||||
|
{ id: 'bookings', label: 'Bookings' },
|
||||||
|
{ id: 'bookings-how', label: '→ How-To' },
|
||||||
|
{ id: 'passengers', label: 'Passengers' },
|
||||||
|
{ id: 'passengers-how', label: '→ How-To' },
|
||||||
|
{ id: 'tickets', label: 'Tickets' },
|
||||||
|
{ id: 'tickets-how', label: '→ How-To' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'masterdata',
|
||||||
|
title: '🏢 Master Data',
|
||||||
|
items: [
|
||||||
|
{ id: 'stations', label: 'Stations' },
|
||||||
|
{ id: 'stations-how', label: '→ How-To' },
|
||||||
|
{ id: 'trains', label: 'Trains' },
|
||||||
|
{ id: 'trains-how', label: '→ How-To' },
|
||||||
|
{ id: 'coaches', label: 'Coaches' },
|
||||||
|
{ id: 'coaches-how', label: '→ How-To' },
|
||||||
|
{ id: 'seats', label: 'Seats' },
|
||||||
|
{ id: 'seats-how', label: '→ How-To' },
|
||||||
|
{ id: 'classes', label: 'Seat Classes' },
|
||||||
|
{ id: 'classes-how', label: '→ How-To' },
|
||||||
|
{ id: 'routes', label: 'Routes' },
|
||||||
|
{ id: 'routes-how', label: '→ How-To' },
|
||||||
|
{ id: 'schedules', label: 'Schedules' },
|
||||||
|
{ id: 'schedules-how', label: '→ How-To' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'financial',
|
||||||
|
title: '💰 Financial',
|
||||||
|
items: [
|
||||||
|
{ id: 'pricing', label: 'Pricing & Fares' },
|
||||||
|
{ id: 'pricing-how', label: '→ How-To' },
|
||||||
|
{ id: 'currencies', label: 'Currencies' },
|
||||||
|
{ id: 'currencies-how', label: '→ How-To' },
|
||||||
|
{ id: 'payments', label: 'Payments' },
|
||||||
|
{ id: 'payments-how', label: '→ How-To' },
|
||||||
|
{ id: 'promos', label: 'Promo Codes' },
|
||||||
|
{ id: 'promos-how', label: '→ How-To' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'services',
|
||||||
|
title: '🎁 Customer Services',
|
||||||
|
items: [
|
||||||
|
{ id: 'loyalty', label: 'Loyalty' },
|
||||||
|
{ id: 'loyalty-how', label: '→ How-To' },
|
||||||
|
{ id: 'support', label: 'Support' },
|
||||||
|
{ id: 'support-how', label: '→ How-To' },
|
||||||
|
{ id: 'notifications', label: 'Notifications' },
|
||||||
|
{ id: 'notifications-how', label: '→ How-To' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
title: '🔒 Security',
|
||||||
|
items: [
|
||||||
|
{ id: 'audit', label: 'Audit Logs' },
|
||||||
|
{ id: 'audit-how', label: '→ How-To' },
|
||||||
|
{ id: 'fraud', label: 'Fraud Detection' },
|
||||||
|
{ id: 'fraud-how', label: '→ How-To' },
|
||||||
|
{ id: 'verifayda', label: 'Verifayda' },
|
||||||
|
{ id: 'verifayda-how', label: '→ How-To' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
title: '📈 Analytics',
|
||||||
|
items: [
|
||||||
|
{ id: 'reports', label: 'Reports' },
|
||||||
|
{ id: 'reports-how', label: '→ How-To' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
title: '⚙️ System',
|
||||||
|
items: [
|
||||||
|
{ id: 'agents', label: 'Agents' },
|
||||||
|
{ id: 'agents-how', label: '→ How-To' },
|
||||||
|
{ id: 'users', label: 'Users' },
|
||||||
|
{ id: 'users-how', label: '→ How-To' },
|
||||||
|
{ id: 'settings', label: 'Settings' },
|
||||||
|
{ id: 'settings-how', label: '→ How-To' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const HowToStep = ({ number, title, children }: { number: number; title: string; children: React.ReactNode }) => (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">{number}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">{title}</h4>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="h-8 w-8 text-emerald-600" />
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Documentation</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a href="http://localhost:4000/api-docs" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition" title="Opens API documentation in new tab">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
View API Docs
|
||||||
|
</a>
|
||||||
|
<Link href="/dashboard" target="_blank" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 sticky top-24 h-fit">
|
||||||
|
<nav>
|
||||||
|
{sections.map(section => (
|
||||||
|
<div key={section.id}>
|
||||||
|
<button onClick={() => toggleSection(section.id)} className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-700">
|
||||||
|
<span>{section.title}</span>
|
||||||
|
{expandedSections[section.id] ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
{expandedSections[section.id] && (
|
||||||
|
<div className="bg-slate-50 dark:bg-slate-700/50">
|
||||||
|
{section.items.map(item => (
|
||||||
|
<button key={item.id} onClick={() => scrollToSection(item.id)} className="w-full text-left px-6 py-2 text-sm text-slate-600 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-white dark:hover:bg-slate-700 transition">
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-12">
|
||||||
|
|
||||||
|
<div id="about">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">Welcome to EDR Passenger Backoffice</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="features" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🌟 Key Features</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Complete booking, passenger, fleet, and financial management.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOOKINGS */}
|
||||||
|
<div id="bookings" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Bookings</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage passenger bookings with search, view, modify, and refund capabilities.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bookings-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: Manage Bookings</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Bookings">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click {`"Bookings"`} in Operations section</li>
|
||||||
|
<li>View all bookings in table format</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Search & Filter">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Use search box for reference, email, or phone</li>
|
||||||
|
<li>Use Status dropdown to filter</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Manage Bookings">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click {`"View Details"`} for full information</li>
|
||||||
|
<li>Click {`"Cancel Booking"`} to process refunds</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PASSENGERS */}
|
||||||
|
<div id="passengers" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👥 Passengers</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage passenger profiles, loyalty, and verification status.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="passengers-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👥 How-To: Manage Passengers</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Passengers">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click {`"Passengers"`} in Operations</li>
|
||||||
|
<li>View all profiles with pagination</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Search & Filter">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Search by name, email, phone, ID</li>
|
||||||
|
<li>Filter by nationality, verification, loyalty tier</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="View Profile">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click passenger row to open modal</li>
|
||||||
|
<li>View account, loyalty, wallet, booking history</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TICKETS */}
|
||||||
|
<div id="tickets" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎫 Tickets</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage ticket generation, tracking, and validation.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tickets-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎫 How-To: Manage Tickets</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Tickets">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click {`"Tickets"`} in Operations</li>
|
||||||
|
<li>View all issued tickets with status</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Search Tickets">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Search by booking reference or ticket number</li>
|
||||||
|
<li>Filter by validation status</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Download PDF">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click ticket to view details</li>
|
||||||
|
<li>Click {`"Download PDF"`} for printable version</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STATIONS */}
|
||||||
|
<div id="stations" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏢 Stations</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Configure railway stations with locations and timezones.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stations-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏢 How-To: Manage Stations</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Stations">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click {`"Stations"`} in Master Data</li>
|
||||||
|
<li>View all configured stations</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Create Station">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click {`"Add Station"`}</li>
|
||||||
|
<li>Enter code, name, city, timezone, coordinates</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Edit Station">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click station to open details</li>
|
||||||
|
<li>Update information and save</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TRAINS */}
|
||||||
|
<div id="trains" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚂 Trains</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage train fleet with coach assignments.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="trains-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚂 How-To: Manage Trains</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Trains">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click {`"Trains"`} in Master Data</li>
|
||||||
|
<li>View all trains and coaches</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Create Train">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click {`"Add Train"`}</li>
|
||||||
|
<li>Enter code and select coaches</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Assign Coaches">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click train to edit</li>
|
||||||
|
<li>Add/remove coaches with position numbers</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COACHES */}
|
||||||
|
<div id="coaches" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚃 Coaches</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage coach inventory with seat configurations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="coaches-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚃 How-To: Manage Coaches</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Coaches">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Coaches" in Master Data</li>
|
||||||
|
<li>View all coaches and assignments</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Create Coach">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Add Coach"</li>
|
||||||
|
<li>Enter code, select train, define seat layout</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Configure Seats">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click coach to edit</li>
|
||||||
|
<li>Add seats and assign classes</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SEATS */}
|
||||||
|
<div id="seats" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💺 Seats</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage seat inventory with visual maps.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="seats-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💺 How-To: Manage Seats</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Seat Map">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Go to "Seats" in Master Data</li>
|
||||||
|
<li>Select coach from dropdown</li>
|
||||||
|
<li>Visual map shows: Green=Available, Red=Blocked</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Block Seat">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click available seat</li>
|
||||||
|
<li>Click "Block" and select reason</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Unblock Seat">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click blocked seat</li>
|
||||||
|
<li>Click "Unblock" to restore</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SEAT CLASSES */}
|
||||||
|
<div id="classes" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎯 Seat Classes</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Define seat class types with pricing.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="classes-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎯 How-To: Manage Seat Classes</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Classes">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Seat Classes" in Master Data</li>
|
||||||
|
<li>View all class types</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Create Class">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Add Class"</li>
|
||||||
|
<li>Enter name, base fare, premium, insurance</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Update Pricing">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click class to edit</li>
|
||||||
|
<li>Update fares and save</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ROUTES */}
|
||||||
|
<div id="routes" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🛤️ Routes</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Define railway routes with ordered stops.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="routes-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🛤️ How-To: Manage Routes</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Routes">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Routes" in Master Data</li>
|
||||||
|
<li>View all routes and stops</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Create Route">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Add Route"</li>
|
||||||
|
<li>Enter code and description</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Add Stops">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click route to edit</li>
|
||||||
|
<li>Click "Add Stop" and select station</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SCHEDULES */}
|
||||||
|
<div id="schedules" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📅 Schedules</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Create and manage train schedules.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedules-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📅 How-To: Create Schedules</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Create Single">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Go to "Schedules" in Master Data</li>
|
||||||
|
<li>Click "Create Schedule"</li>
|
||||||
|
<li>Fill train, route, departure/arrival times</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Bulk Generate">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Bulk Generate"</li>
|
||||||
|
<li>Set recurring parameters and generate</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Manage Status">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click schedule to edit</li>
|
||||||
|
<li>Update times and view fares</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PRICING */}
|
||||||
|
<div id="pricing" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💰 Pricing & Fares</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Configure dynamic pricing with segments.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pricing-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💰 How-To: Configure Pricing</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Pricing">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Pricing & Fares" in Financial</li>
|
||||||
|
<li>Two tabs: Schedule Fares, Segment Fares</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Schedule Fares">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Add Fare Rule"</li>
|
||||||
|
<li>Fill schedule, seat class, fare, nationality</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Segment Fares">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Switch to "Segment Fares" tab</li>
|
||||||
|
<li>Select route and add origin/destination fare</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CURRENCIES */}
|
||||||
|
<div id="currencies" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💵 Currencies</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage exchange rates for multiple currencies.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="currencies-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💵 How-To: Manage Currencies</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Rates">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Currencies" in Financial</li>
|
||||||
|
<li>View all configured rates</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Add Rate">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Add Rate"</li>
|
||||||
|
<li>Select currency and enter exchange rate</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Sync Rates">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click rate to edit</li>
|
||||||
|
<li>Click "Sync" to update from provider</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PAYMENTS */}
|
||||||
|
<div id="payments" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💳 Payments</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Monitor and process transactions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="payments-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💳 How-To: Manage Payments</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Transactions">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Payments" in Financial</li>
|
||||||
|
<li>View all transactions</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Search & Filter">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Search by booking or transaction ID</li>
|
||||||
|
<li>Filter by status and payment method</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Process Refunds">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click transaction</li>
|
||||||
|
<li>Click "Refund" if eligible</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PROMOS */}
|
||||||
|
<div id="promos" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎁 Promo Codes</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Create and manage promotional campaigns.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="promos-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎁 How-To: Manage Promo Codes</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Promos">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Promo Codes" in Financial</li>
|
||||||
|
<li>View all active codes</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Create Code">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Add Promo Code"</li>
|
||||||
|
<li>Enter code, discount type, validity dates</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Track Usage">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click code to view analytics</li>
|
||||||
|
<li>View usage count and savings</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LOYALTY */}
|
||||||
|
<div id="loyalty" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏆 Loyalty</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage loyalty program and rewards.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loyalty-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏆 How-To: Manage Loyalty</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Accounts">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Loyalty Program" in Services</li>
|
||||||
|
<li>View all loyalty accounts</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Adjust Points">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click account</li>
|
||||||
|
<li>Click "Adjust Points" and enter amount</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Award Rewards">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click account</li>
|
||||||
|
<li>Click "Grant Reward" and select reward</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SUPPORT */}
|
||||||
|
<div id="support" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💬 Support</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage support tickets and conversations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="support-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💬 How-To: Manage Support</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Tickets">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Support Center" in Services</li>
|
||||||
|
<li>View all support tickets</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Manage Ticket">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click ticket to open conversation</li>
|
||||||
|
<li>Add replies and update status</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Manage FAQ">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Go to FAQ management</li>
|
||||||
|
<li>Add or edit FAQ articles</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NOTIFICATIONS */}
|
||||||
|
<div id="notifications" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🔔 Notifications</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Send notifications via multiple channels.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notifications-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🔔 How-To: Manage Notifications</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Notifications">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Notifications" in Services</li>
|
||||||
|
<li>View notification history</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Send Notification">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Send Notification"</li>
|
||||||
|
<li>Select channel and message</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Manage Templates">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Go to Templates section</li>
|
||||||
|
<li>Create or edit templates with variables</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AUDIT */}
|
||||||
|
<div id="audit" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Audit Logs</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Monitor system activities and user actions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="audit-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: View Audit Logs</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Logs">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Audit Logs" in Security</li>
|
||||||
|
<li>View all recorded activities</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Filter Logs">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Filter by user, action, or date</li>
|
||||||
|
<li>Search by entity ID</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Export Logs">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click log entry for details</li>
|
||||||
|
<li>Click "Export" to download CSV</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FRAUD */}
|
||||||
|
<div id="fraud" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🛡️ Fraud Detection</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Monitor and manage fraud alerts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fraud-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🛡️ How-To: Manage Fraud Detection</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Alerts">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Fraud Detection" in Security</li>
|
||||||
|
<li>View all fraud alerts</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Investigate">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click alert to view details</li>
|
||||||
|
<li>Review triggered rules and patterns</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Take Action">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Allow" or "Block" with notes</li>
|
||||||
|
<li>Update user status</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VERIFAYDA */}
|
||||||
|
<div id="verifayda" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">✅ Verifayda</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Verify passenger identities against government database.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="verifayda-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">✅ How-To: Manage Verifayda</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Verification">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Verifayda Integration" in Security</li>
|
||||||
|
<li>View verification history</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Verify Passenger">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Enter national ID or passport number</li>
|
||||||
|
<li>Click "Verify" to check database</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Review Results">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>View verified passenger data</li>
|
||||||
|
<li>Match with booking details</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* REPORTS */}
|
||||||
|
<div id="reports" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📊 Reports</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Generate business analytics and reports.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="reports-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📊 How-To: Generate Reports</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Reports">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Reports" in Analytics</li>
|
||||||
|
<li>View available report types</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Generate Report">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click report type</li>
|
||||||
|
<li>Select date range and parameters</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Export Report">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>View report with charts</li>
|
||||||
|
<li>Click "Export" for PDF or CSV</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AGENTS */}
|
||||||
|
<div id="agents" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👤 Agents</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage booking agents and commissions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="agents-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👤 How-To: Manage Agents</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Agents">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Agents" in System</li>
|
||||||
|
<li>View all agents</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Create Agent">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Add Agent"</li>
|
||||||
|
<li>Enter name, email, commission rate</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Create Shift">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click agent to edit</li>
|
||||||
|
<li>Click "Create Shift" to assign schedule</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* USERS */}
|
||||||
|
<div id="users" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👥 Users</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Manage backoffice user accounts and permissions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="users-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👥 How-To: Manage Users</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="View Users">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Users" in System</li>
|
||||||
|
<li>View all user accounts</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Create User">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Add User"</li>
|
||||||
|
<li>Enter email, name, select role</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Manage Permissions">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click user to edit</li>
|
||||||
|
<li>Adjust roles and permissions</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SETTINGS */}
|
||||||
|
<div id="settings" className="border-t pt-8">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">⚙️ Settings</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">Configure system-wide settings and integrations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-how" className="border-t pt-8">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">⚙️ How-To: Configure Settings</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<HowToStep number={1} title="Access Settings">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Click "Settings" in System</li>
|
||||||
|
<li>View configuration options</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={2} title="Configure Email">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Go to Email tab</li>
|
||||||
|
<li>Enter SendGrid API key and email</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
<HowToStep number={3} title="Configure API Keys">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<li>Go to API tab</li>
|
||||||
|
<li>Add payment and Verifayda keys</li>
|
||||||
|
</ol>
|
||||||
|
</HowToStep>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 bg-slate-900 text-white py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400">
|
||||||
|
<p>© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocPage;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user