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

This commit is contained in:
marshal
2026-07-03 13:54:29 +03:00
74 changed files with 3992 additions and 1110 deletions

View File

@@ -170,13 +170,36 @@ jobs:
- name: Build ${{ matrix.service }}
run: |
set -euo pipefail
IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
# Tag with git SHA for rollback capability
CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1)
docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true
echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}"
- name: Deploy ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
- name: Verify deployment health
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2)
echo "Waiting for service to become healthy on port ${PORT}..."
for i in $(seq 1 12); do
if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then
echo "Service is healthy."
exit 0
fi
echo "Attempt ${i}/12 — not ready yet, waiting 10s..."
sleep 10
done
echo "Service failed health check after 120s — rolling back"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true
exit 1
- name: Remove npm credentials from workspace
if: always()
run: rm -f .npmrc .npmrc_temp

View File

@@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
### Runtime
The final image runs:
The final image uses Next.js `output: 'standalone'` and runs:
```bash
npx next start
node server.js
```
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on.
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`.
## Rollback Procedure
Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-<service>:<sha8>`).
### Rollback a single service
```bash
# 1. Find the last known-good image tag
docker images | grep passenger-api
# 2. Re-tag it as the current image
docker tag edr-passenger-main-passenger-api:<previous-sha> edr-passenger-main-passenger-api:latest
# 3. Restart the container from the previous image
docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate
```
### Rollback via re-run
Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit.
## Production Security Checklist
Before deploying to production, verify:
- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`)
- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10`
- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production)
- [ ] `NODE_ENV=production` is set
- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token
- [ ] No `.env` files are committed to the repository (`git status` should show none)
## Data Retention Policy
The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes:
| Table | Retention |
|---|---|
| `OtpCode` | 1 hour after expiry or verification |
| `FaydaVerificationSession` | 1 hour after expiry or completion |
| `AuditLog` | 365 days |
| `PaymentWebhookEvent` | 90 days |
| `GateValidationLog` | 180 days |
No manual intervention is required. Monitor the `TasksService` log output for purge counts.
## GitHub Actions Deployment Flow
@@ -147,9 +193,10 @@ For each service:
- Computes branch slug and sets:
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
- Runs:
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image.
- Builds the service image and tags it with the short git SHA.
- Runs `docker compose up -d <service> --force-recreate`.
- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy.
- Cleans `.npmrc`/`.npmrc_temp`.
## Branch/Environment Isolation

View File

@@ -1,21 +1,31 @@
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingView } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing")
@Controller("billing")
@FreightAdmin()
@BookingView()
@ApiBearerAuth()
export class BillingController {
constructor(private readonly billingService: BillingService) { }
constructor(private readonly billingService: BillingService) {}
@Get("invoices")
@ApiOperation({ summary: "List all invoices" })
findAll() {
return this.billingService.findAll();
@ApiOperation({
summary: "List invoices (paginated, filterable by company/status/search)",
})
findAll(@Query() query: FilterInvoiceDto) {
return this.billingService.findAllPaginated(query);
}
@Get("invoices/:id")

View File

@@ -125,7 +125,7 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
) { }
) {}
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -134,9 +134,56 @@ export class BillingService {
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/**
* Paginated invoice list for the backoffice — optionally narrowed to a
* company (customer detail "Invoices" tab) and/or status/search (global
* invoices page).
*/
async findAllPaginated(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<{ items: Invoice[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [items, total] = await qb.getManyAndCount();
return { items, total };
}
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id);
const invoice = await this.invoices.findById(id, {
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
@@ -375,7 +422,7 @@ export class BillingService {
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);

View File

@@ -0,0 +1,42 @@
import { Freight } from "@edr/types";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Min,
} from "class-validator";
export class FilterInvoiceDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
@IsInt()
@Min(1)
pageSize?: number = 20;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: Freight.InvoiceStatus })
@IsOptional()
@IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus;
}

View File

@@ -93,9 +93,8 @@ export class PaymentRepository {
p.paid_at,
p.created_at
FROM freight.payments p
JOIN freight.bookings b ON b.id = p.ref_id
JOIN freight.bookings b ON b.id = p.ref_id::uuid
WHERE b.company_id = $1
AND p.deleted_at IS NULL
AND b.deleted_at IS NULL
ORDER BY p.created_at DESC`,
[companyId],

View File

@@ -12,6 +12,7 @@ import {
PackageCheck,
PackageOpen,
Paperclip,
Receipt,
Send,
Settings,
ShieldCheck,
@@ -32,7 +33,11 @@ import {
useParams,
} from "react-router-dom";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import {
FreightDashboardLayout,
type SidebarItem,
type SidebarSection,
} from "@/components/layout";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
@@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import InvoicesPage from "./pages/invoices/InvoicesPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
@@ -61,7 +68,10 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
import {
FREIGHT_PERMS,
hasPermission as hasFreightPermission,
} from "./lib/permissions";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
@@ -144,6 +154,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.bookings.view,
},
...demoItems,
],
},
@@ -506,7 +522,10 @@ const App = () => {
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route
path="/dashboard"
element={<Navigate to="/dashboard/overview" replace />}
/>
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
@@ -522,8 +541,27 @@ const App = () => {
/>
<Route path="customers" element={<CustomersPage />} />
<Route path="customers/:id" element={<CustomerDetailPage />} />
<Route
path="invoices"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<InvoicesPage />
</RequirePermission>
}
/>
<Route
path="invoices/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<InvoiceDetailPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id"
element={<BookingRequestDetailPage />}
/>
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
@@ -536,7 +574,9 @@ const App = () => {
<Route
path="clearance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -570,7 +610,9 @@ const App = () => {
<Route
path="bookings/:bookingId/clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -578,7 +620,9 @@ const App = () => {
<Route
path="shipment-requests"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<ShipmentRequestsPage />
</RequirePermission>
}
@@ -586,7 +630,9 @@ const App = () => {
<Route
path="shipment-requests/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<ShipmentRequestDetailPage />
</RequirePermission>
}
@@ -618,12 +664,20 @@ const App = () => {
</RequirePermission>
}
/>
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} />
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} />
<Route
path="gl-ethiopia/clearance"
element={<LegacyGlEthiopiaClearanceRedirect />}
/>
<Route
path="gl-ethiopia/clearance/:id"
element={<LegacyGlEthiopiaClearanceRedirect />}
/>
<Route
path="gl-djibouti/clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
>
<GlDjiboutiClearanceListPage />
</RequirePermission>
}
@@ -631,7 +685,9 @@ const App = () => {
<Route
path="gl-djibouti/clearance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
>
<GlClearanceDetailPage />
</RequirePermission>
}
@@ -644,7 +700,9 @@ const App = () => {
<Route
path="contracts/:id/create-booking"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<GlCreateBookingForm />
</RequirePermission>
}
@@ -655,155 +713,174 @@ const App = () => {
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route
path="warehouse-inventory"
element={<WarehouseInventoryPage />}
/>
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="export-djibouti-unloading" element={<ExportDjiboutiUnloadingQueuePage />} />
<Route path="interchange-documents" element={<InterchangeDocumentsPage />} />
<Route
path="export-djibouti-unloading"
element={<ExportDjiboutiUnloadingQueuePage />}
/>
<Route
path="interchange-documents"
element={<InterchangeDocumentsPage />}
/>
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route
path="warehouse-fee-invoices"
element={<WarehouseInvoicesPage />}
/>
<Route
path="warehouse-dashboard"
element={<WarehouseDashboardPage />}
/>
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/first-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<FirstMilePage />
</RequirePermission>
}
/>
<Route
path="operations/last-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<LastMilePage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="vehicles"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="drivers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
element={
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/first-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<FirstMilePage />
</RequirePermission>
}
/>
<Route
path="operations/last-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<LastMilePage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="vehicles"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="drivers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
}
/>
<Route
path="operations/batch-board"
@@ -953,9 +1030,15 @@ const App = () => {
{/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
<Route
path="user-management/position-types"
element={<PositionTypesPage />}
/>
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route
path="user-management/permissions"
element={<PermissionsPage />}
/>
<Route path="user-management/roles" element={<RolesPage />} />
<Route
@@ -977,7 +1060,9 @@ const App = () => {
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
element={
<Navigate to="/dashboard/configuration/cargo-types" replace />
}
/>
<Route
path="configuration/train-scheduling-rules"
@@ -996,8 +1081,14 @@ const App = () => {
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="configuration/cargo-types/:id"
element={<CargoTypesPage />}
/>
<Route
path="configuration/:resource"
element={<RuleEngineResourcePage />}
/>
<Route
path="rules"
@@ -1007,9 +1098,14 @@ const App = () => {
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
element={
<Navigate to="/dashboard/configuration/cargo-types" replace />
}
/>
<Route
path="rule-engine/:resource"
element={<RuleEngineLegacyRedirect />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
@@ -1026,9 +1122,7 @@ const App = () => {
/** Redirect removed milestones page to document clearance. */
function BookingMilestonesRedirect() {
const { id } = useParams();
return (
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
);
return <Navigate to={`/dashboard/bookings/${id}/clearance`} replace />;
}
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */

View File

@@ -1,3 +1,4 @@
import type { Freight } from "@edr/types";
import { Badge, Button, Group, Tooltip } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -88,7 +89,13 @@ export function ProfileChips({
}) {
if (!profiles.length) {
return (
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
<Badge
color="gray"
variant="light"
size="sm"
radius="md"
style={badgeStyle}
>
No profiles
</Badge>
);
@@ -118,7 +125,13 @@ export function ProfileChips({
</Tooltip>
))}
{extra > 0 ? (
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
<Badge
color="gray"
variant="light"
size="sm"
radius="md"
style={badgeStyle}
>
+{extra}
</Badge>
) : null}
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
CANCELLED: "red",
};
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
export function BookingStatusBadge({
status,
}: {
status: CustomerBookingStatus;
}) {
return (
<Badge
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
@@ -194,7 +211,11 @@ const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
refunded: "grape",
};
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
export function PaymentStatusBadge({
status,
}: {
status: CustomerPaymentStatus;
}) {
return (
<Badge
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
@@ -210,6 +231,38 @@ export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }
);
}
const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
DRAFT: "gray",
ISSUED: "cyan",
PENDING: "yellow",
PARTIALLY_PAID: "orange",
PAID: "edr-green",
OVERDUE: "red",
CANCELLED: "gray",
REFUNDED: "grape",
EXPIRED: "red",
};
export function InvoiceStatusBadge({
status,
}: {
status: Freight.InvoiceStatus;
}) {
return (
<Badge
color={INVOICE_STATUS_COLOR[status] ?? "gray"}
variant="light"
size="sm"
radius="md"
tt="capitalize"
fw={600}
style={badgeStyle}
>
{humanize(status)}
</Badge>
);
}
/**
* Inline approval action buttons for a profile row.
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
@@ -225,8 +278,7 @@ export function ProfileApprovalActions({
api.customers.setProfileStatus.mutationOptions(),
);
const act = (next: ProfileStatus) =>
mutate({ profileId, status: next });
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
if (status === "pending") {
return (

View File

@@ -2,6 +2,7 @@ export {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,

View File

@@ -3,6 +3,7 @@ import type { BookingListFilter } from "@/services/bookings.service";
import type { ContractListFilter } from "@/services/contracts.service";
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import type { CompanyListFilter } from "@/types/customer";
import type { InvoiceListFilter } from "@/types/invoice";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
import type { TrainScheduleFilters } from "@/types/trainScheduling";
@@ -16,7 +17,8 @@ export const QUERY_KEYS = {
ROOT: ["file-upload-settings"] as const,
list: () => ["file-upload-settings", "list"] as const,
byId: (id: string) => ["file-upload-settings", "detail", id] as const,
byCode: (code: string) => ["file-upload-settings", "by-code", code] as const,
byCode: (code: string) =>
["file-upload-settings", "by-code", code] as const,
},
DROPDOWN_SETTINGS: {
@@ -33,10 +35,18 @@ export const QUERY_KEYS = {
["customers", "list", filter ?? {}] as const,
byId: (id: string) => ["customers", "detail", id] as const,
bookings: (id: string) => ["customers", "detail", id, "bookings"] as const,
documents: (id: string) => ["customers", "detail", id, "documents"] as const,
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
},
INVOICES: {
ROOT: ["invoices"] as const,
list: (filter?: InvoiceListFilter) =>
["invoices", "list", filter ?? {}] as const,
byId: (id: string) => ["invoices", "detail", id] as const,
},
BOOKINGS: {
ROOT: ["bookings"] as const,
list: (filter?: BookingListFilter) =>
@@ -80,7 +90,12 @@ export const QUERY_KEYS = {
TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const,
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
[
"train-scheduling",
"eligible-bookings",
freightType ?? "CONTAINER",
filters ?? {},
] as const,
locomotives: (routeId?: string) =>
["train-scheduling", "locomotives", routeId ?? "all"] as const,
stations: () => ["train-scheduling", "stations"] as const,
@@ -98,31 +113,37 @@ export const QUERY_KEYS = {
FLEET: {
ROOT: ["fleet"] as const,
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
list: (resource: FleetResourceSlug | string) =>
["fleet", "list", resource] as const,
},
VEHICLES: {
ROOT: ["vehicles"] as const,
list: (filter?: Record<string, unknown>) => ["vehicles", "list", filter ?? {}] as const,
list: (filter?: Record<string, unknown>) =>
["vehicles", "list", filter ?? {}] as const,
byId: (id: string) => ["vehicles", "detail", id] as const,
},
FIRST_MILE: {
ROOT: ["first-mile"] as const,
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
list: (filter?: Record<string, unknown>) =>
["first-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["first-mile", "detail", id] as const,
},
LAST_MILE: {
ROOT: ["last-mile"] as const,
list: (filter?: Record<string, unknown>) => ["last-mile", "list", filter ?? {}] as const,
list: (filter?: Record<string, unknown>) =>
["last-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["last-mile", "detail", id] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
["rule-engine", "list", resource, params ?? {}] as const,
list: (
resource: RuleEngineResourceSlug | string,
params?: RuleEngineListParams,
) => ["rule-engine", "list", resource, params ?? {}] as const,
detail: (resource: RuleEngineResourceSlug | string, id: string) =>
["rule-engine", "detail", resource, id] as const,
chain: ["rule-engine", "approval-rules", "chain"] as const,
@@ -136,27 +157,39 @@ export const QUERY_KEYS = {
OVERVIEW: {
ROOT: ["overview"] as const,
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const,
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
dashboard: (range?: string) =>
["overview", "dashboard", range ?? "30d"] as const,
bookingsTab: (range?: string) =>
["overview", "bookings", range ?? "30d"] as const,
contractsTab: (range?: string) =>
["overview", "contracts", range ?? "30d"] as const,
billingTab: (range?: string) =>
["overview", "billing", range ?? "30d"] as const,
operationsTab: () => ["overview", "operations"] as const,
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
customersTab: (range?: string) =>
["overview", "customers", range ?? "30d"] as const,
staffTab: (range?: string) =>
["overview", "staff", range ?? "30d"] as const,
},
FUEL: {
ROOT: ["fuel"] as const,
purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const,
purchases: (vehicleId?: string) =>
["fuel", "purchases", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) =>
["fuel", "stats", vehicleId ?? "all"] as const,
},
MAINTENANCE: {
ROOT: ["maintenance"] as const,
schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const,
upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const,
history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const,
schedules: (vehicleId?: string) =>
["maintenance", "schedules", vehicleId ?? "all"] as const,
upcoming: (vehicleId?: string) =>
["maintenance", "upcoming", vehicleId ?? "all"] as const,
history: (vehicleId?: string) =>
["maintenance", "history", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) =>
["maintenance", "stats", vehicleId ?? "all"] as const,
},
FINANCIAL_REPORTS: {

View File

@@ -13,7 +13,7 @@ export const URL_CONSTANTS = {
BASE: "/users",
BY_ID: (id: string | number) => `/users/${id}`,
SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me"
ME: "/api/auth/me",
},
ROLES: {
@@ -74,9 +74,18 @@ export const URL_CONSTANTS = {
STATS: "/companies/stats",
BY_ID: (id: string | number) => `/companies/${id}`,
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`,
BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`,
PROFILE_STATUS: (profileId: string) =>
`/companies/company-profiles/${profileId}/status`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
`/payments/by-company/${id}/customer-view`,
},
BILLING: {
INVOICES: "/billing/invoices",
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
},
CUSTOMERS_API: {
@@ -124,15 +133,21 @@ export const URL_CONSTANTS = {
CANCEL: (id: string) => `/bookings/${id}/cancel`,
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`,
CLEARANCE_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/declaration`,
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
CLEARANCE_FINALIZE_PRE: (id: string) =>
`/bookings/${id}/clearance/finalize-pre-clearance`,
CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`,
CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`,
CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`,
CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`,
CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`,
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
`/bookings/${id}/clearance/transit-permit`,
CLEARANCE_DELIVERY_ORDER: (id: string) =>
`/bookings/${id}/clearance/delivery-order`,
CLEARANCE_RELEASE_ORDER: (id: string) =>
`/bookings/${id}/clearance/release-order`,
CLEARANCE_RO_AMENDMENT: (id: string) =>
`/bookings/${id}/clearance/ro-amendment`,
CLEARANCE_EXPORT_RELEASE: (id: string) =>
`/bookings/${id}/clearance/export-release`,
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
@@ -157,7 +172,8 @@ export const URL_CONSTANTS = {
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
`/contracts/${id}/clearance/output-documents`,
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
CLEARANCE_DECLARATION: (id: string) =>
`/contracts/${id}/clearance/declaration`,
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
CLEARANCE_FINALIZE_PRE: (id: string) =>
@@ -247,7 +263,7 @@ export const URL_CONSTANTS = {
},
ROUTES: {
BASE: '/routes',
BASE: "/routes",
BY_ID: (id: string) => `/routes/${id}`,
},
@@ -261,12 +277,15 @@ export const URL_CONSTANTS = {
BATCH_BOARD_DETAIL: (scheduleId: string) =>
`/train-scheduling/batch-board/${scheduleId}`,
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
RUN_ALLOCATION: (id: string) =>
`/train-scheduling/schedules/${id}/run-allocation`,
DOC_REVIEW_COMPLETE: (id: string) =>
`/train-scheduling/schedules/${id}/doc-review-complete`,
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
BOOKING_WINDOW: (id: string) =>
`/train-scheduling/schedules/${id}/booking-window`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,
EXPIRE_BOOKING: (bookingId: string) =>
@@ -275,12 +294,14 @@ export const URL_CONSTANTS = {
`/train-scheduling/bookings/${bookingId}/move-schedule`,
GLOBAL_RULES: "/train-scheduling/global-rules",
PREVIEW: "/train-scheduling/preview",
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
ASSIGN_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/assign-bookings`,
CONTAINER: {
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
PREVIEW: "/train-scheduling/container/preview",
SCHEDULES: "/train-scheduling/container/schedules",
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
SCHEDULE_BY_ID: (id: string) =>
`/train-scheduling/container/schedules/${id}`,
ASSIGN_BOOKINGS: (id: string) =>
`/train-scheduling/container/schedules/${id}/assign-bookings`,
CANCEL_SCHEDULE: (id: string) =>
@@ -319,15 +340,18 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/export/load-list/document`,
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
CHECKPOINTS: (id: string) =>
`/train-scheduling/schedules/${id}/checkpoints`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`,
RESCHEDULE_EXECUTE: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/execute`,
MAINTENANCE: (id: string) => `/train-scheduling/schedules/${id}/maintenance`,
MAINTENANCE: (id: string) =>
`/train-scheduling/schedules/${id}/maintenance`,
SCHEDULES: "/train-scheduling/container/schedules",
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
SCHEDULE_BY_ID: (id: string) =>
`/train-scheduling/container/schedules/${id}`,
CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/container/schedules/${id}/cancel`,
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
@@ -375,175 +399,193 @@ export const URL_CONSTANTS = {
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
},
RATE_MATRIX: {
BASE: '/api/rate-matrices',
DRAFT: '/api/rate-matrices/draft',
RATE_MATRIX: {
BASE: "/api/rate-matrices",
DRAFT: "/api/rate-matrices/draft",
SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
LIST: '/api/rate-matrices',
LIST: "/api/rate-matrices",
DETAIL: (id: string) => `/api/rate-matrices/${id}`,
},
REFERENCE: {
PORTS: '/api/reference/ports',
CITIES: '/api/reference/cities',
CONTAINER_TYPES: '/api/reference/container-types',
CURRENCIES: '/api/reference/currencies',
PORTS: "/api/reference/ports",
CITIES: "/api/reference/cities",
CONTAINER_TYPES: "/api/reference/container-types",
CURRENCIES: "/api/reference/currencies",
},
FACILITIES: {
BASE: '/facilities',
BASE: "/facilities",
BY_ID: (id: string) => `/facilities/${id}`,
},
WAREHOUSES: {
BASE: '/warehouses',
DASHBOARD: '/warehouses/dashboard',
BASE: "/warehouses",
DASHBOARD: "/warehouses/dashboard",
BY_ID: (id: string) => `/warehouses/${id}`,
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
},
WAREHOUSE_YARDS: {
BASE: '/warehouse-yards',
BASE: "/warehouse-yards",
BY_ID: (id: string) => `/warehouse-yards/${id}`,
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
},
WAREHOUSE_ZONES: {
BASE: '/warehouse-zones',
BASE: "/warehouse-zones",
BY_ID: (id: string) => `/warehouse-zones/${id}`,
},
WAREHOUSE_INVENTORY: {
BASE: '/warehouse-inventory',
RECEIVE: '/warehouse-inventory/receive',
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
BASE: "/warehouse-inventory",
RECEIVE: "/warehouse-inventory/receive",
DASHBOARD_SUMMARY: "/warehouse-inventory/dashboard/summary",
READY_FOR_LOADING: "/warehouse-inventory/ready-for-loading",
INQUIRY: "/warehouse-inventory/inquiry",
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
RESERVE: '/warehouse-inventory/reserve',
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
UNLOAD_BOOKING: (bookingId: string) =>
`/warehouse-inventory/bookings/${bookingId}/unload`,
INSPECTION_REPORTS: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/inspection-reports`,
LOADABLE_WAGONS: "/warehouse-inventory/loadable-wagons",
BOOKING_SCHEDULE: (bookingId: string) =>
`/warehouse-inventory/booking/${bookingId}/schedule`,
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
// Import branch
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
MARK_READY_PICKUP: (id: string) =>
`/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
RELEASE_DOCUMENT: (id: string) =>
`/warehouse-inventory/${id}/release-document`,
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
HANDOVER_DOCUMENT: (id: string) =>
`/warehouse-inventory/${id}/handover-document`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk)
ELIGIBLE_BOOKINGS: (direction?: string) =>
direction
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
: `/warehouse-inventory/eligible-bookings`,
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
READY_TO_LOAD_EXPORT: "/warehouse-inventory/ready-to-load-export",
LOADED_EXPORT: "/warehouse-inventory/loaded-export",
BULK_DISPATCH_EXPORT: "/warehouse-inventory/bulk-dispatch-export",
IMPORT_ARRIVE_QUEUE: "/warehouse-inventory/import/arrive-queue",
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/import/trains/${scheduleId}/items`,
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-queue',
IMPORT_AUTO_UNLOAD_ARRIVED:
"/warehouse-inventory/import/auto-unload-arrived-bookings",
IMPORT_UNLOADED_QUEUE: "/warehouse-inventory/import/unloaded-queue",
IMPORT_PICKUP_READY_QUEUE: "/warehouse-inventory/import/pickup-ready-queue",
EXPORT_DJIBOUTI_ARRIVAL_QUEUE:
"/warehouse-inventory/export/djibouti-arrival-queue",
EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`,
EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: '/warehouse-inventory/export/auto-unload-at-djibouti',
EXPORT_AUTO_UNLOAD_AT_DJIBOUTI:
"/warehouse-inventory/export/auto-unload-at-djibouti",
},
WAREHOUSE_LOADINGS: {
BASE: '/warehouse-loadings',
BASE: "/warehouse-loadings",
},
WAREHOUSE_INSPECTION: {
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
ATTACHMENTS: (id: string) =>
`/warehouse-inspection-reports/${id}/attachments`,
},
WAREHOUSE_RULES: {
ALLOCATION: '/warehouse-allocation-rules',
ALLOCATION: "/warehouse-allocation-rules",
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
FEES: '/warehouse-fee-rules',
ALLOCATION_PREVIEW: "/warehouse-allocation/preview",
FEES: "/warehouse-fee-rules",
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
FEE_PREVIEW: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-preview`,
},
WAREHOUSE_INVOICES: {
BASE: '/warehouse-fee-invoices',
BASE: "/warehouse-fee-invoices",
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
GENERATE: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
FOR_INVENTORY: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-invoices`,
FOR_BOOKING: (bookingId: string) =>
`/bookings/${bookingId}/warehouse-fee-invoices`,
GATE_CLEARANCE: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/gate-clearance`,
},
INTERCHANGE_DOCUMENTS: {
BASE: '/interchange-documents',
BASE: "/interchange-documents",
BY_ID: (id: string) => `/interchange-documents/${id}`,
GENERATE_FROM_SCHEDULE: '/interchange-documents/generate-from-schedule',
GENERATE_FROM_SCHEDULE: "/interchange-documents/generate-from-schedule",
ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
},
IMPORT_OPERATIONS: {
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
CUSTOMS_DOCUMENTS: (bookingId: string) =>
`/import-operations/customs/${bookingId}/documents`,
CUSTOMS_DECLARATION: (bookingId: string) =>
`/import-operations/customs/${bookingId}/declaration`,
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
CUSTOMS_RISK: (bookingId: string) =>
`/import-operations/customs/${bookingId}/risk`,
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
`/import-operations/customs/${bookingId}/release-permitted`,
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`,
},
VEHICLES: {
BASE: '/vehicles',
BASE: "/vehicles",
BY_ID: (id: string) => `/vehicles/${id}`,
},
FIRST_MILE: {
BASE: '/first-mile',
BASE: "/first-mile",
BY_ID: (id: string) => `/first-mile/${id}`,
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
},
LAST_MILE: {
BASE: '/last-mile',
BASE: "/last-mile",
BY_ID: (id: string) => `/last-mile/${id}`,
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
},
DRIVERS: {
BASE: '/drivers',
BASE: "/drivers",
BY_ID: (id: string) => `/drivers/${id}`,
},
};

View File

@@ -1,24 +1,3 @@
import { useCallback, useState } from "react";
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
/**
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
* from any file row to open the document inline (pdf / image / video / office /
* text); render `viewer` once near the page root.
*
* const { view, viewer } = useFileViewer();
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
* {viewer}
*/
export function useFileViewer() {
const [file, setFile] = useState<ViewableFile | null>(null);
const view = useCallback((f: ViewableFile) => setFile(f), []);
const close = useCallback(() => setFile(null), []);
const viewer = (
<FileViewerModal open={file !== null} file={file} onClose={close} />
);
return { view, close, viewer };
}
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
// `@/hooks/useFileViewer` imports keep working.
export { useFileViewer } from "@edr/ui-common";

View File

@@ -1,5 +1,6 @@
import {
ActionIcon,
Anchor,
Box,
Button,
Card,
@@ -17,10 +18,13 @@ import {
ArrowRight,
Banknote,
Download,
Eye,
FileText,
IdCard,
LayoutGrid,
Package,
Paperclip,
Receipt,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
@@ -30,6 +34,7 @@ import {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
@@ -50,7 +55,13 @@ import type {
CustomerDocument,
CustomerPayment,
} from "@/types/customer";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
useFileViewer,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
@@ -78,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { view, viewer } = useFileViewer();
const { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({
@@ -104,9 +116,34 @@ export default function CustomerDetailPage() {
}),
);
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
usePagination({
pageSize: 10,
});
const invoiceFilter = useMemo(
() => ({
companyId: id ?? "",
page: invoicePagination.pageIndex + 1,
pageSize: invoicePagination.pageSize,
}),
[id, invoicePagination.pageIndex, invoicePagination.pageSize],
);
const invoicesQuery = useQuery(
api.invoices.list.queryOptions({
input: { filter: invoiceFilter },
enabled: Boolean(id),
}),
);
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
const invoices = invoicesQuery.data?.items ?? [];
const invoiceTotal = invoicesQuery.data?.total ?? 0;
const invoicePageCount = Math.max(
1,
Math.ceil(invoiceTotal / invoicePagination.pageSize),
);
const totalPaid = useMemo(
() =>
@@ -285,20 +322,37 @@ export default function CustomerDetailPage() {
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<ActionIcon
component="a"
href={fileViewUrl(row.original.id, true)}
variant="subtle"
color="gray"
aria-label="Download"
data-stop-row-click
>
<Download size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
aria-label="View"
data-stop-row-click
onClick={() =>
view({
name: row.original.name,
url: fileViewUrl(row.original.id),
mimeType: row.original.mimeType,
})
}
>
<Eye size={16} />
</ActionIcon>
<ActionIcon
component="a"
href={fileViewUrl(row.original.id, true)}
variant="subtle"
color="gray"
aria-label="Download"
data-stop-row-click
>
<Download size={16} />
</ActionIcon>
</Group>
),
},
],
[],
[view],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
@@ -358,6 +412,59 @@ export default function CustomerDetailPage() {
[],
);
const invoiceColumns: ColumnDef<Invoice>[] = useMemo(
() => [
{
id: "invoiceNumber",
header: "Invoice",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.invoiceNumber}
</Text>
),
},
{
id: "source",
header: "Source",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(row.original.totalAmount, row.original.currency)}
</Text>
),
},
{
id: "dueAt",
header: "Due",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.dueAt)}
</Text>
),
},
],
[],
);
const licenseProfiles = (company?.companyProfiles ?? []).filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
if (isLoading) {
return (
<Center mih="60vh">
@@ -392,8 +499,9 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
}`}
subtitle={`TIN ${company.tin}${
company.country ? ` · ${company.country}` : ""
}`}
meta={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
@@ -416,6 +524,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
Payments
</Tabs.Tab>
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
</Tabs.List>
{/* OVERVIEW */}
@@ -528,9 +639,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
: undefined
}
/>
@@ -539,23 +650,63 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<TableCard minWidth={760}>
<DataTable
columns={documentColumns}
data={documents}
status={tableStatus(documentsQuery)}
emptyMessage="No documents uploaded."
containerClassName="border-0 shadow-none bg-transparent"
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
<Stack gap="lg">
<TableCard minWidth={760}>
<DataTable
columns={documentColumns}
data={documents}
status={tableStatus(documentsQuery)}
emptyMessage="No documents uploaded."
containerClassName="border-0 shadow-none bg-transparent"
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
{licenseProfiles.length > 0 && (
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Business licenses
</Text>
<Stack gap="md">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Text size="sm" fw={600} c="edr-text">
{humanize(p.type)} · {p.reference}
</Text>
{(p.licenseFiles ?? []).map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
component="button"
type="button"
onClick={() =>
view({
name: f.name,
url: f.url,
mimeType: f.mimeType,
})
}
size="xs"
>
{f.name}
</Anchor>
</Group>
))}
</Stack>
))}
</Stack>
</Stack>
</Card>
)}
</Stack>
</Tabs.Panel>
{/* PAYMENTS */}
@@ -570,15 +721,53 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
</Tabs.Panel>
{/* INVOICES */}
<Tabs.Panel value="invoices" pt="lg">
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={860}>
<DataTable
columns={invoiceColumns}
data={invoices}
status={tableStatus(invoicesQuery)}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage="No invoices for this customer."
containerClassName="border-0 shadow-none bg-transparent"
error={
invoicesQuery.isError
? {
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
: undefined
}
pagination={{
pageIndex: invoicePagination.pageIndex,
pageSize: invoicePagination.pageSize,
pageCount: invoicePageCount,
totalCount: invoiceTotal,
}}
tableOptions={{
state: { pagination: invoicePagination },
onPaginationChange: setInvoicePagination,
manualPagination: true,
pageCount: invoicePageCount,
}}
/>
</Box>
</Box>
</Tabs.Panel>
</Tabs>
{viewer}
</PageContainer>
);
}

View File

@@ -0,0 +1,254 @@
import {
ActionIcon,
Button,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
InvoiceStatusBadge,
formatDate,
formatMoney,
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
function openPdfBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const opened = window.open(url, "_blank");
if (!opened) {
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
<Stack gap={2}>
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
{label}
</Text>
<Text size="sm" c="edr-text">
{value && value.trim() ? value : "—"}
</Text>
</Stack>
);
}
export default function InvoiceDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [downloading, setDownloading] = useState(false);
const { data: invoice, isLoading } = useQuery(
api.invoices.getById.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const downloadDocument = async () => {
if (!id) return;
setDownloading(true);
try {
const { data } = await invoicesService.downloadDocument(id);
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
} finally {
setDownloading(false);
}
};
if (isLoading) {
return (
<Center mih="60vh">
<Loader />
</Center>
);
}
if (!invoice) {
return (
<Container size="sm" py="xl">
<Stack align="center" gap="md">
<Text fw={700}>Invoice not found</Text>
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/invoices")}
>
Back to invoices
</Button>
</Stack>
</Container>
);
}
return (
<PageContainer>
<PageHeader
breadcrumbs={[
{ label: "Invoices", href: "/dashboard/invoices" },
{ label: invoice.invoiceNumber },
]}
backTo="/dashboard/invoices"
title={invoice.invoiceNumber}
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
meta={<InvoiceStatusBadge status={invoice.status} />}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Download invoice"
loading={downloading}
onClick={() => void downloadDocument()}
>
<Download size={16} />
</ActionIcon>
}
/>
<Stack gap="lg">
<Card>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Summary
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<InfoField label="Billed to" value={invoice.company?.name} />
<InfoField
label="Profile"
value={invoice.companyProfile?.reference}
/>
<InfoField label="Type" value={humanize(invoice.type)} />
<InfoField label="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
<InfoField
label="Total"
value={formatMoney(invoice.totalAmount, invoice.currency)}
/>
<InfoField
label="Balance"
value={formatMoney(invoice.balanceAmount, invoice.currency)}
/>
</SimpleGrid>
</Stack>
</Card>
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Line items
</Text>
<Table striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th ta="right">Quantity</Table.Th>
<Table.Th ta="right">Unit rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(invoice.lines ?? []).map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{humanize(line.chargeType)}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{formatMoney(line.unitRate, line.currency)}
</Table.Td>
<Table.Td ta="right">
{formatMoney(line.amount, line.currency)}
</Table.Td>
</Table.Tr>
))}
{(invoice.lines ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="md">
No line items.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Group
justify="flex-end"
gap="xl"
pt="sm"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Subtotal
</Text>
<Text size="sm">
{formatMoney(invoice.subtotalAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Tax
</Text>
<Text size="sm">
{formatMoney(invoice.taxAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Paid
</Text>
<Text size="sm">
{formatMoney(invoice.paidAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(invoice.totalAmount, invoice.currency)}
</Text>
</Stack>
</Group>
</Stack>
</Card>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,237 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
InvoiceStatusBadge,
formatDate,
formatMoney,
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
export default function InvoicesPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.list.queryOptions({ input: { filter } }),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Invoice>[] = useMemo(
() => [
{
id: "invoiceNumber",
header: "Invoice",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.invoiceNumber}
</Text>
),
},
{
id: "billedTo",
header: "Billed to",
cell: ({ row }) => (
<Text size="sm" c="edr-text">
{row.original.company?.name ?? "—"}
</Text>
),
},
{
id: "source",
header: "Source",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(row.original.totalAmount, row.original.currency)}
</Text>
),
},
{
id: "balance",
header: "Balance",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatMoney(row.original.balanceAmount, row.original.currency)}
</Text>
),
},
{
id: "dueAt",
header: "Due",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.dueAt)}
</Text>
),
},
],
[],
);
return (
<PageContainer>
<PageHeader
title="Invoices"
subtitle="Every invoice issued across bookings, warehouse fees and clearance charges."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(
v === "all" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No invoices match your search."
: "No invoices yet."
}
error={
isError
? {
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -28,6 +28,11 @@ import type {
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
import type {
Invoice,
InvoiceListFilter,
PaginatedInvoices,
} from "@/types/invoice";
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
import {
RuleEngineListResult,
@@ -128,6 +133,7 @@ import {
import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service";
import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import {
@@ -197,7 +203,10 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
export const api = {
trainScheduling: {
// ── Queries ────────────────────────────────────────────────────────────
scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>(
scheduleList: endpoint<
{ freightType?: FreightType },
TrainScheduleListItem[]
>(
"train-scheduling",
"schedules",
({ freightType }) => trainSchedulingService.listSchedules(freightType),
@@ -211,11 +220,16 @@ export const api = {
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
),
batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>(
batchBoardDetail: endpoint<
{ scheduleId: string },
BatchBoardScheduleDetail
>(
"train-scheduling",
"batch-board-detail",
({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId),
({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
({ scheduleId }) =>
trainSchedulingService.getBatchBoardDetail(scheduleId),
({ scheduleId }) =>
QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
),
scheduleDetail: endpoint<
@@ -344,7 +358,10 @@ export const api = {
),
// ── Mutations ──────────────────────────────────────────────────────────
runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>(
runAllocation: endpoint<
{ scheduleId: string },
WagonAllocationAttemptResult
>(
"train-scheduling",
"run-allocation",
({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId),
@@ -462,7 +479,10 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>(
pinWagons: endpoint<
{ id: string; payload: PinWagonsPayload },
TrainScheduleDetail
>(
"train-scheduling",
"pin-wagons",
({ id, payload }) => trainSchedulingService.pinWagons(id, payload),
@@ -558,8 +578,10 @@ export const api = {
({ id }) => warehouseService.getById(id).then((r) => r.data),
),
dashboard: endpoint<void, WarehouseDashboard>("warehouses", "dashboard", () =>
warehouseService.dashboard().then((r) => r.data),
dashboard: endpoint<void, WarehouseDashboard>(
"warehouses",
"dashboard",
() => warehouseService.dashboard().then((r) => r.data),
),
create: endpoint<SaveWarehousePayload, Warehouse>(
@@ -650,10 +672,8 @@ export const api = {
listInventory: endpoint<
{ filter?: InventoryFilter },
WarehouseInventoryItem[]
>(
"warehouse-inventory",
"list",
({ filter }) => warehouseService.listInventory(filter).then((r) => r.data),
>("warehouse-inventory", "list", ({ filter }) =>
warehouseService.listInventory(filter).then((r) => r.data),
),
inquiry: endpoint<
@@ -666,11 +686,19 @@ export const api = {
({ filter }) => ["warehouse-inventory", "inquiry", filter],
),
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
eligibleBookings: endpoint<
{ direction?: "IMPORT" | "EXPORT" } | void,
EligibleBooking[]
>(
"warehouse-inventory",
"eligible-bookings",
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
(input) =>
warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
(input) => [
"warehouse-inventory",
"eligible-bookings",
input?.direction ?? "ALL",
],
),
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
@@ -706,7 +734,11 @@ export const api = {
"import-train-items",
({ scheduleId }) =>
warehouseService.importTrainItems(scheduleId).then((r) => r.data),
({ scheduleId }) => ["warehouse-inventory", "import-train-items", scheduleId],
({ scheduleId }) => [
"warehouse-inventory",
"import-train-items",
scheduleId,
],
),
importUnloadedQueue: endpoint<void, ImportUnloadedItem[]>(
@@ -774,8 +806,11 @@ export const api = {
"inspection-reports",
({ inventoryId }) =>
warehouseService.listInspectionReports(inventoryId).then((r) => r.data),
({ inventoryId }) =>
["warehouse-inventory", inventoryId, "inspection-reports"],
({ inventoryId }) => [
"warehouse-inventory",
inventoryId,
"inspection-reports",
],
),
allocationRules: endpoint<void, AllocationRule[]>(
@@ -792,15 +827,28 @@ export const api = {
() => ["warehouse-fee-rules"],
),
feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>(
feePreview: endpoint<
{ inventoryId: string; billingCurrency?: "ETB" | "USD" },
FeePreview[]
>(
"warehouse-inventory",
"fee-preview",
({ inventoryId, billingCurrency }) =>
warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data),
({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'],
warehouseService
.feePreview(inventoryId, billingCurrency)
.then((r) => r.data),
({ inventoryId, billingCurrency }) => [
"warehouse-inventory",
inventoryId,
"fee-preview",
billingCurrency ?? "USD",
],
),
invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>(
invoices: endpoint<
{ filter?: WarehouseInvoiceFilter },
WarehouseFeeInvoice[]
>(
"warehouse-fee-invoices",
"list",
({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data),
@@ -829,7 +877,8 @@ export const api = {
receiveInventory: endpoint<ReceiveInventoryPayload, WarehouseInventoryItem>(
"warehouse-inventory",
"receive",
(payload) => warehouseService.receiveInventory(payload).then((r) => r.data),
(payload) =>
warehouseService.receiveInventory(payload).then((r) => r.data),
undefined,
() => [["warehouse-inventory"], ["warehouses"]],
),
@@ -864,7 +913,8 @@ export const api = {
>(
"warehouse-inventory",
"load",
({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data),
({ id, payload }) =>
warehouseService.load(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
@@ -883,7 +933,8 @@ export const api = {
>(
"warehouse-inventory",
"move",
({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data),
({ id, payload }) =>
warehouseService.move(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
@@ -939,7 +990,8 @@ export const api = {
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
"warehouse-inventory",
"bulk-mark-inspected",
(payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data),
(payload) =>
warehouseService.bulkMarkInspected(payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
@@ -957,7 +1009,12 @@ export const api = {
{
scheduleId: string;
warehouseId?: string;
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
assignments?: {
bookingId: string;
warehouseId: string;
yardId: string;
zoneId: string;
}[];
},
AutoUnloadArrivedResult
>(
@@ -1029,11 +1086,11 @@ export const api = {
),
// ── Allocation + fee rules ─────────────────────────────────────────────
previewAllocation: endpoint<AllocationCriteria, AllocationPreviewResult | null>(
"warehouse-allocation-rules",
"preview",
(criteria) =>
warehouseService.previewAllocation(criteria).then((r) => r.data),
previewAllocation: endpoint<
AllocationCriteria,
AllocationPreviewResult | null
>("warehouse-allocation-rules", "preview", (criteria) =>
warehouseService.previewAllocation(criteria).then((r) => r.data),
),
createAllocationRule: endpoint<SaveAllocationRulePayload, AllocationRule>(
@@ -1095,7 +1152,11 @@ export const api = {
// ── Invoices ───────────────────────────────────────────────────────────
generateInvoice: endpoint<
{ inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' },
{
inventoryId: string;
confirmZero?: boolean;
billingCurrency?: "ETB" | "USD";
},
WarehouseFeeInvoice
>(
"warehouse-fee-invoices",
@@ -1151,7 +1212,10 @@ export const api = {
},
routes: {
list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>(
list: endpoint<
{ status?: import("./routes.service").RouteStatus } | void,
RouteRecord[]
>(
"routes",
"list",
(input) =>
@@ -1176,7 +1240,10 @@ export const api = {
() => [["routes"]],
),
update: endpoint<{ id: string; data: Partial<SaveRoutePayload> }, RouteRecord>(
update: endpoint<
{ id: string; data: Partial<SaveRoutePayload> },
RouteRecord
>(
"routes",
"update",
({ id, data }) => routesService.update(id, data).then((r) => r.data),
@@ -1289,10 +1356,8 @@ export const api = {
({ trainId }) => ["wagons", "train", trainId],
),
getById: endpoint<{ id: string }, Wagon>(
"wagons",
"getById",
({ id }) => wagonService.getById(id).then((r) => r.data),
getById: endpoint<{ id: string }, Wagon>("wagons", "getById", ({ id }) =>
wagonService.getById(id).then((r) => r.data),
),
assignToTrain: endpoint<
@@ -1671,7 +1736,8 @@ export const api = {
>(
"file-upload-settings",
"addField",
({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto),
({ settingId, dto }) =>
fileUploadSettingsService.addField(settingId, dto),
undefined,
() => [["file-upload-settings"]],
),
@@ -1770,7 +1836,8 @@ export const api = {
>(
"dropdown-settings",
"updateOption",
({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto),
({ optionId, dto }) =>
dropdownSettingsService.updateOption(optionId, dto),
undefined,
() => [["dropdown-settings"]],
),
@@ -1823,11 +1890,10 @@ export const api = {
ruleEngineService.update(resource, id, payload),
),
remove: endpoint<
{ resource: RuleEngineResourceSlug; id: string },
void
>("rule-engine", "remove", ({ resource, id }) =>
ruleEngineService.remove(resource, id),
remove: endpoint<{ resource: RuleEngineResourceSlug; id: string }, void>(
"rule-engine",
"remove",
({ resource, id }) => ruleEngineService.remove(resource, id),
),
submitRate: endpoint<{ id: string }, RuleEngineRecord>(
@@ -1850,14 +1916,21 @@ export const api = {
),
reorder: endpoint<
{ resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } },
{
resource: RuleEngineResourceSlug;
payload: { ids: string[]; requiresDirectorApproval?: boolean };
},
void
>("rule-engine", "reorder", ({ resource, payload }) =>
ruleEngineService.reorder(resource, payload),
),
moveOrder: endpoint<
{ resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" },
{
resource: RuleEngineResourceSlug;
id: string;
direction: "up" | "down";
},
void
>("rule-engine", "moveOrder", ({ resource, id, direction }) =>
ruleEngineService.moveOrder(resource, id, direction),
@@ -1879,10 +1952,8 @@ export const api = {
({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
),
remove: endpoint<{ id: string }, void>(
"bookings",
"remove",
({ id }) => bookingsService.remove(id),
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
bookingsService.remove(id),
),
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
@@ -1932,10 +2003,11 @@ export const api = {
({ id }) => bookingsService.generateContract(id),
),
getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>(
"bookings",
"getContractView",
({ id }) => bookingsService.getContractView(id),
getContractView: endpoint<
{ id: string },
import("./bookings.service").ContractView
>("bookings", "getContractView", ({ id }) =>
bookingsService.getContractView(id),
),
signContract: endpoint<
@@ -2019,7 +2091,8 @@ export const api = {
>(
"customers",
"setProfileStatus",
({ profileId, status }) => customersService.setProfileStatus(profileId, status),
({ profileId, status }) =>
customersService.setProfileStatus(profileId, status),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
@@ -2040,6 +2113,22 @@ export const api = {
),
},
invoices: {
list: endpoint<{ filter: InvoiceListFilter }, PaginatedInvoices>(
"invoices",
"list",
({ filter }) => invoicesService.list(filter),
({ filter }) => QUERY_KEYS.INVOICES.list(filter),
),
getById: endpoint<{ id: string }, Invoice>(
"invoices",
"getById",
({ id }) => invoicesService.getById(id),
({ id }) => QUERY_KEYS.INVOICES.byId(id),
),
},
overview: {
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
"overview",

View File

@@ -0,0 +1,36 @@
import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
Invoice,
InvoiceListFilter,
PaginatedInvoices,
} from "@/types/invoice";
const cleanParams = (params: object) =>
Object.fromEntries(
Object.entries(params).filter(
([, value]) => value !== undefined && value !== "" && value !== null,
),
);
export const invoicesService = {
list(filter: InvoiceListFilter): Promise<PaginatedInvoices> {
return apiClient
.get<PaginatedInvoices>(URL_CONSTANTS.BILLING.INVOICES, {
params: cleanParams(filter),
})
.then((r) => r.data);
},
getById(id: string): Promise<Invoice> {
return apiClient
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
.then((r) => r.data);
},
downloadDocument(id: string) {
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
responseType: "blob",
});
},
};

View File

@@ -32,6 +32,14 @@ export type ProfileType =
/** Mirrors backend `ProfileStatus`. */
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
/** A business-license document uploaded for a company profile. */
export interface LicenseFile {
name: string;
url: string;
size: number;
mimeType?: string;
}
/** A single role a company is registered for, with its reference code. */
export interface CompanyProfile {
id: string;
@@ -39,7 +47,10 @@ export interface CompanyProfile {
type: ProfileType;
reference: string;
status: ProfileStatus;
/** @deprecated Superseded by licenseFiles (file model). */
businessLicense?: string | null;
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
attributes?: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;

View File

@@ -0,0 +1,22 @@
import type { Freight } from "@edr/types";
/** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */
export interface Invoice extends Freight.IInvoice {
subtotalAmount: number;
taxAmount: number;
}
/** Query parameters for the invoice list. */
export interface InvoiceListFilter {
page: number;
pageSize: number;
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
}
/** Standard paginated list envelope (matches the customers/bookings service shape). */
export interface PaginatedInvoices {
items: Invoice[];
total: number;
}

View File

@@ -1,24 +1,3 @@
import { useCallback, useState } from "react";
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
/**
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
* from any file row to open the document inline (pdf / image / video / office /
* text); render `viewer` once near the page root.
*
* const { view, viewer } = useFileViewer();
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
* {viewer}
*/
export function useFileViewer() {
const [file, setFile] = useState<ViewableFile | null>(null);
const view = useCallback((f: ViewableFile) => setFile(f), []);
const close = useCallback(() => setFile(null), []);
const viewer = (
<FileViewerModal open={file !== null} file={file} onClose={close} />
);
return { view, close, viewer };
}
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
// `@/hooks/useFileViewer` imports keep working.
export { useFileViewer } from "@edr/ui-common";

View File

@@ -1,13 +1,16 @@
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common";
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
import {
Anchor,
Button,
Card,
Center,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
@@ -17,10 +20,19 @@ import {
CheckCircle2,
FileCheck,
Loader2,
Paperclip,
UploadCloud,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
const ROLE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
interface TabDocumentsProps {
profile: ProfileResponse;
@@ -28,21 +40,63 @@ interface TabDocumentsProps {
onContinue?: () => void;
}
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
export default function TabDocuments({
profile,
mode = "edit",
onContinue,
}: TabDocumentsProps) {
const queryClient = useQueryClient();
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
const { view, viewer } = useFileViewer();
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: "customer_file_documents" },
input: { code: documentSettingCode(profile.nationality) },
}),
);
const docsQuery = useQuery(
api.companies.documents.queryOptions({
input: { companyId: profile.companyId },
}),
);
const uploadedKeys = useMemo(
() => (docsQuery.data ?? []).map((d) => d.code),
[docsQuery.data],
);
const existingFilesByKey = useMemo(() => {
const map: Record<
string,
{ name: string; url: string; size?: number; mimeType?: string | null }[]
> = {};
for (const doc of docsQuery.data ?? []) {
(map[doc.code] ??= []).push({
name: doc.name,
url: fileViewUrl(doc.id),
size: doc.size,
mimeType: doc.mimeType,
});
}
return map;
}, [docsQuery.data]);
const docUploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>
companiesService.uploadDocuments(profile.companyId, files),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
},
});
@@ -73,6 +127,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
for (const field of docSettingQuery.data?.fields ?? []) {
const min = getMinFiles(field);
if (min <= 0) continue;
if (uploadedKeys.includes(field.fileKey)) continue;
const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) {
@@ -82,94 +137,139 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
return errs;
};
const licenseProfiles = profile.companyProfiles.filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<FileCheck size={20} />
<Title order={3}>Documents</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Upload and manage required business documents
</Text>
{docSettingQuery.isLoading ? (
<Center py="xl">
<Loader2 size={24} className="animate-spin" />
</Center>
) : !docSettingQuery.data ? (
<Text c="edr-muted" size="sm" ta="center" py="md">
No document requirements configured for your account.
<>
<Card padding="lg">
<Group gap="sm" mb="xs">
<FileCheck size={20} />
<Title order={3}>Documents</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Upload and manage required business documents
</Text>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
/>
)}
{docSettingQuery.data && (
<Group
justify="space-between"
mt="lg"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{docUploadMutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
</Text>
</Group>
)}
{docUploadMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>Upload failed</Text>
</Group>
{docSettingQuery.isLoading ? (
<Center py="xl">
<Loader2 size={24} className="animate-spin" />
</Center>
) : !docSettingQuery.data ? (
<Text c="edr-muted" size="sm" ta="center" py="md">
No document requirements configured for your account.
</Text>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
uploadedKeys={uploadedKeys}
existingFiles={existingFilesByKey}
onViewFile={view}
/>
)}
{docSettingQuery.data && (
<Group
justify="space-between"
mt="lg"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{docUploadMutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
{mode === "onboarding"
? "Saved successfully"
: "Documents uploaded successfully"}
</Text>
</Group>
)}
{docUploadMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Upload failed
</Text>
</Group>
)}
</Group>
{mode === "onboarding" ? (
<Button
type="button"
leftSection={<ArrowRight size={16} />}
loading={docUploadMutation.isPending}
onClick={() => {
const validationErrors = validateRequired();
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
return;
}
if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
onSuccess: () => onContinue?.(),
});
} else {
onContinue?.();
}
}}
>
Continue
</Button>
) : (
<Button
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
disabled={!hasFiles}
onClick={() => {
if (!hasFiles) return;
docUploadMutation.mutate(documentFiles);
}}
>
Upload Documents
</Button>
)}
</Group>
{mode === "onboarding" ? (
<Button
type="button"
leftSection={<ArrowRight size={16} />}
loading={docUploadMutation.isPending}
onClick={() => {
const validationErrors = validateRequired();
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
return;
}
if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
onSuccess: () => onContinue?.(),
});
} else {
onContinue?.();
}
}}
>
Continue
</Button>
) : (
<Button
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
disabled={!hasFiles}
onClick={() => {
if (!hasFiles) return;
docUploadMutation.mutate(documentFiles);
}}
>
Upload Documents
</Button>
)}
</Group>
)}
</Card>
{licenseProfiles.length > 0 && (
<Card padding="lg" mt="lg">
<Group gap="sm" mb="xs">
<Paperclip size={20} />
<Title order={3}>Business licenses</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
License documents uploaded per operational profile
</Text>
<Stack gap="md">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Text size="sm" fw={600} c="edr-text">
{ROLE_LABELS[p.type] ?? p.type} · {p.reference}
</Text>
{p.licenseFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Text component="button" type="button" size="xs">
{f.name}
</Text>
</Group>
))}
</Stack>
))}
</Stack>
</Card>
)}
</Card>
{viewer}
</>
);
}

View File

@@ -53,6 +53,7 @@ import {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import type {
CompanyDocument,
CompanyInfoResponse,
CompanyNationality,
CompanyProfileResponse,
@@ -158,7 +159,11 @@ export const api = {
createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string },
CompanyProfileResponse
>("companies", "createCompanyProfile", companiesService.createCompanyProfile),
>(
"companies",
"createCompanyProfile",
companiesService.createCompanyProfile,
),
startOnboarding: endpoint<
{
@@ -192,6 +197,12 @@ export const api = {
"onboardingRequirements",
companiesService.getOnboardingRequirements,
),
documents: endpoint<{ companyId: string }, CompanyDocument[]>(
"companies",
"documents",
({ companyId }) => companiesService.getDocuments(companyId),
),
},
bookings: {
@@ -228,7 +239,8 @@ export const api = {
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
"bookings",
"downloadHandoverDocument",
({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId),
({ inventoryId }) =>
bookingsService.downloadHandoverDocument(inventoryId),
),
create: endpoint<
@@ -312,11 +324,8 @@ export const api = {
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
Freight.IBooking
>(
"bookings",
"proceedToOperation",
({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
),
checkPayment: endpoint<{ orderId: string }, { status: string }>(
@@ -354,10 +363,11 @@ export const api = {
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>(
"train-scheduling",
"availableDaysForCargo",
(input) => bookingsService.getAvailableDaysForCargo(input),
getAvailableDaysForCargo: endpoint<
Freight.AvailableDaysForCargoQuery,
string[]
>("train-scheduling", "availableDaysForCargo", (input) =>
bookingsService.getAvailableDaysForCargo(input),
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(

View File

@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
}
/** A single company-level document uploaded against a `file_upload_settings` field. */
export interface CompanyDocument {
id: string;
name: string;
/** The `fileKey` of the setting field it was uploaded against. */
code: string;
mimeType: string;
size: number;
uploadedAt: string;
url: string;
}
/** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField {
fileKey: string;
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
}
export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
type:
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
businessLicense?: string;
}
@@ -180,7 +197,9 @@ export const companiesService = {
}
},
create: async (payload: CreateCompanyPayload): Promise<CompanyInfoResponse> => {
create: async (
payload: CreateCompanyPayload,
): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.CREATE,
payload,
@@ -195,7 +214,9 @@ export const companiesService = {
return unwrap(response.data);
},
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => {
updateProfile: async (
payload: UpdateProfilePayload,
): Promise<ProfileResponse> => {
const response = await client.patch<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
payload,
@@ -293,7 +314,18 @@ export const companiesService = {
formData.append(fieldName, fileOrFiles);
}
}
await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
await client.post(
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
formData,
);
},
/** List documents already uploaded for a company (settings-driven, by fileKey). */
getDocuments: async (companyId: string): Promise<CompanyDocument[]> => {
const response = await client.get<ApiResponse<CompanyDocument[]>>(
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
);
return unwrap(response.data);
},
/** Upload business-license document(s) for a company profile (multi-file). */

View File

@@ -48,6 +48,7 @@
"class-validator": "^0.14.0",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"helmet": "^8.0.0",
"jose": "^5.10.0",
"pg": "^8.21.0",
"qrcode": "^1.5.3",

View File

@@ -0,0 +1,25 @@
-- AlterTable: change distanceKm from Decimal to Double Precision on RouteStop
ALTER TABLE "RouteStop" ALTER COLUMN "distanceKm" TYPE DOUBLE PRECISION;
-- CreateTable
CREATE TABLE "RouteCoachTemplate" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"coachId" TEXT NOT NULL,
"positionNumber" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RouteCoachTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RouteCoachTemplate_routeId_idx" ON "RouteCoachTemplate"("routeId");
-- CreateIndex
CREATE UNIQUE INDEX "RouteCoachTemplate_routeId_positionNumber_key" ON "RouteCoachTemplate"("routeId", "positionNumber");
-- AddForeignKey
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "SeatClass" ADD COLUMN "bedPosition" TEXT,
ADD COLUMN "nationalityType" TEXT;
-- CreateIndex
CREATE INDEX "SeatClass_coachTypeId_nationalityType_bedPosition_idx" ON "SeatClass"("coachTypeId", "nationalityType", "bedPosition");

View File

@@ -88,7 +88,9 @@ model SeatClass {
coachTypeId String
name String
description String?
baseFareMinor Int @default(0) // per-km rate
nationalityType String? // 'LOCAL' | 'INTERNATIONAL'
bedPosition String? // 'UPPER' | 'MIDDLE' | 'LOWER' | null for regular seat
baseFareMinor Int @default(0) // per-km rate (tariff decimal × 100000)
premiumMinor Int @default(0) // flat fee per passenger
insuranceFeeMinor Int @default(0) // flat fee per passenger
isActive Boolean @default(true)
@@ -100,6 +102,7 @@ model SeatClass {
segmentFares SegmentFareRule[]
@@unique([coachTypeId, name])
@@index([coachTypeId])
@@index([coachTypeId, nationalityType, bedPosition])
@@schema("passenger")
}
@@ -420,9 +423,10 @@ model Coach {
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[]
assignments CoachAssignment[]
coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[]
assignments CoachAssignment[]
routeTemplates RouteCoachTemplate[]
@@index([coachTypeId])
@@index([sequence])
@@schema("passenger")
@@ -998,6 +1002,7 @@ model Route {
fareRules RouteFareRule[]
segmentFares SegmentFareRule[]
schedules TrainSchedule[]
coachTemplates RouteCoachTemplate[]
@@schema("passenger")
}
@@ -1015,6 +1020,20 @@ model RouteStop {
@@schema("passenger")
}
model RouteCoachTemplate {
id String @id @default(uuid())
routeId String
coachId String
positionNumber Int
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
coach Coach @relation(fields: [coachId], references: [id])
@@unique([routeId, positionNumber])
@@index([routeId])
@@schema("passenger")
}
model RouteFareRule {
id String @id @default(uuid())
routeId String

View File

@@ -166,21 +166,41 @@ async function seedCoachTypesAndClasses() {
});
}
// Tariff rates: baseFareMinor = tariff_decimal × 100000
// Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate
// LOCAL = Ethiopian or Djiboutian nationals
// INTERNATIONAL = all other nationalities
const seatClasses = [
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
// LOCAL rates
{ name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
// INTERNATIONAL rates
{ name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 },
{ name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 },
];
for (const sc of seatClasses) {
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
await prisma.seatClass.upsert({
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
update: {},
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor },
create: {
coachTypeId: ct!.id,
name: sc.name,
nationalityType: sc.nationalityType,
bedPosition: sc.bedPosition,
baseFareMinor: sc.baseFareMinor,
premiumMinor: sc.premiumMinor,
insuranceFeeMinor: sc.insuranceFeeMinor,
},
});
}
console.log(`${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
@@ -238,6 +258,58 @@ async function seedRoute() {
});
}
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
// Full cross-border route: Sebeta → Nagad (all 15 stations)
const fullRoute = await prisma.route.upsert({
where: { code: 'Route-201' },
update: {},
create: {
code: 'Route-201',
name: 'Sebeta - Nagad (Full Cross-Border)',
description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad',
effectiveFrom: new Date('2026-01-01'),
effectiveUntil: new Date('2034-12-31'),
active: true,
},
});
// Cumulative distances from Sebeta (km) for all 15 stations
const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG'];
const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0];
for (let i = 0; i < fullStationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } });
await prisma.routeStop.upsert({
where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } },
update: { distanceKm: fullDistancesKm[i] },
create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] },
});
}
// Full cross-border return route: Nagad → Sebeta
const fullReturnRoute = await prisma.route.upsert({
where: { code: 'Route-202' },
update: {},
create: {
code: 'Route-202',
name: 'Nagad - Sebeta (Full Cross-Border Return)',
description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta',
effectiveFrom: new Date('2026-01-01'),
effectiveUntil: new Date('2034-12-31'),
active: true,
},
});
const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0];
for (let i = 0; i < fullReturnStationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } });
await prisma.routeStop.upsert({
where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } },
update: { distanceKm: fullReturnDistancesKm[i] },
create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] },
});
}
console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`);
}
async function seedCoaches() {
@@ -431,34 +503,61 @@ async function seedTrips() {
async function seedFareRules() {
console.log('\n💰 Seeding fare rules...');
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
const seatClasses = await prisma.seatClass.findMany();
const validFrom = new Date('2024-01-01');
const fareRules = [];
for (const sc of seatClasses) {
fareRules.push({
routeId: route!.id,
seatClassId: sc.id,
passengerCategory: 'ADULT' as const,
baseFareMinor: sc.baseFareMinor,
currency: 'ETB',
validFrom,
});
fareRules.push({
routeId: route!.id,
seatClassId: sc.id,
passengerCategory: 'CHILD' as const,
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
discountPercent: 10,
currency: 'ETB',
validFrom,
});
// Delete existing FareRule rows so re-seed is idempotent
await prisma.fareRule.deleteMany({});
const fareRules: any[] = [];
for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) {
for (const sc of seatClasses) {
fareRules.push({
route: route.code,
seatClassId: sc.id,
baseFareMinor: sc.baseFareMinor,
currency: 'ETB',
validFrom,
});
}
}
await Promise.all(
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
fareRules.map(fr => prisma.fareRule.create({ data: fr }))
);
console.log(`${fareRules.length} fare rules for ADULT/CHILD categories created`);
console.log(`${fareRules.length} fare rules created in FareRule table`);
const allRoutes = await prisma.route.findMany({
where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } },
});
const routeFareRules: any[] = [];
for (const r of allRoutes) {
for (const sc of seatClasses) {
routeFareRules.push({
routeId: r.id,
seatClassId: sc.id,
passengerCategory: 'ADULT' as const,
baseFareMinor: sc.baseFareMinor,
currency: 'ETB',
validFrom,
});
// CHILD: same per-km rate as ADULT — age-based free/paid logic is handled
// at booking time (first child free, subsequent children full fare).
routeFareRules.push({
routeId: r.id,
seatClassId: sc.id,
passengerCategory: 'CHILD' as const,
baseFareMinor: sc.baseFareMinor,
currency: 'ETB',
validFrom,
});
}
}
await Promise.all(
routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {}))
);
console.log(`${routeFareRules.length} route fare rules for ADULT/CHILD categories created`);
}
async function seedCurrency() {
@@ -758,7 +857,23 @@ async function runStep(name: string, step: () => Promise<unknown>): Promise<bool
async function main() {
console.log('🌱 Comprehensive EDR Seed Starting...\n');
const steps: Array<[string, () => Promise<unknown>]> = [
const steps: Array<[string, () => Promise<unknown>]> = [
['System Users', seedSystemUsers],
['Stations', seedStations],
['Coach Types & Classes', seedCoachTypesAndClasses],
['Route', seedRoute],
['Coaches', seedCoaches],
['Trips', seedTrips],
['Fare Rules', seedFareRules],
['Currency', seedCurrency],
['Payment Methods', seedPaymentMethods],
['Segment Fares', seedSegmentFares],
['Notification Templates', seedNotificationTemplates],
['Menu & Food', seedMenuAndFood],
['Promotions', seedPromotions],
['FAQ', seedFAQ],
['Fraud Rules', seedFraudRules],
['Kulubbi Package', seedKulubbiPackage],
];
let failed = 0;

View File

@@ -1,9 +1,4 @@
import {
MiddlewareConsumer,
Module,
NestModule,
OnApplicationBootstrap,
} from '@nestjs/common';
import {Logger, Module, OnApplicationBootstrap} from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
@@ -66,7 +61,6 @@ import { PackagesModule } from './modules/packages/packages.module';
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
import { HealthModule } from './modules/health/health.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
@Module({
imports: [
@@ -136,7 +130,6 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable
ExcessBaggageModule,
HealthModule,
TasksModule,
ConfigurableFareModule,
],
providers: [
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
@@ -147,6 +140,7 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable
],
})
export class AppModule implements OnApplicationBootstrap {
private readonly logger = new Logger(AppModule.name);
constructor(
private readonly seeder: DataSeeder,
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
@@ -157,17 +151,17 @@ export class AppModule implements OnApplicationBootstrap {
try {
await this.seeder.run();
} catch (err) {
console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
this.logger.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
}
try {
await this.edrPassengerOrgSeeder.run();
} catch (err) {
console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message);
this.logger.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message);
}
try {
await this.passengerStaffUsersSeeder.run();
} catch (err) {
console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
}
}
}

View File

@@ -3,6 +3,17 @@ import { Reflector } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler';
import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service';
// Route-prefix → throttler tier mapping.
// Evaluated in order; first match wins.
const ROUTE_TIERS: Array<{ prefix: string; tier: 'auth' | 'strict' | 'default' }> = [
{ prefix: '/auth', tier: 'auth' },
{ prefix: '/fayda/verification',tier: 'auth' },
{ prefix: '/bookings', tier: 'strict' },
{ prefix: '/passengers', tier: 'strict' },
{ prefix: '/payments', tier: 'strict' },
{ prefix: '/wallet', tier: 'strict' },
];
@Injectable()
export class DynamicThrottlerGuard extends ThrottlerGuard {
constructor(
@@ -29,9 +40,17 @@ export class DynamicThrottlerGuard extends ThrottlerGuard {
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS),
]);
this.throttlers = [
{ name: 'default', ttl: defaultTtl, limit: defaultLimit },
];
const url: string = context.switchToHttp().getRequest<{ url: string }>().url ?? '';
const matched = ROUTE_TIERS.find(({ prefix }) => url.startsWith(prefix));
const tier = matched?.tier ?? 'default';
if (tier === 'auth') {
this.throttlers = [{ name: 'auth', ttl: authTtl, limit: authLimit }];
} else if (tier === 'strict') {
this.throttlers = [{ name: 'strict', ttl: strictTtl, limit: strictLimit }];
} else {
this.throttlers = [{ name: 'default', ttl: defaultTtl, limit: defaultLimit }];
}
return super.canActivate(context);
}

View File

@@ -1,8 +1,29 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
// In production set connection_limit and pool_timeout in DATABASE_URL:
// ?connection_limit=10&pool_timeout=20&sslmode=require
log:
process.env.NODE_ENV === 'development'
? [{ emit: 'event', level: 'query' }, { emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }]
: [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }],
});
if (process.env.NODE_ENV === 'development') {
(this as any).$on('query', (e: { query: string; duration: number }) => {
if (e.duration > 500) {
this.logger.warn(`Slow query (${e.duration}ms): ${e.query}`);
}
});
}
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}

View File

@@ -1,9 +1,23 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '4000', 10),
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000',
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001',
}));
export default registerAs('app', () => {
const isProd = process.env.NODE_ENV === 'production';
if (isProd && !process.env.JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required in production');
}
if (isProd && !process.env.JWT_ACCESS_TOKEN_SECRET) {
throw new Error('JWT_ACCESS_TOKEN_SECRET environment variable is required in production');
}
if (isProd && !process.env.JWT_REFRESH_TOKEN_SECRET) {
throw new Error('JWT_REFRESH_TOKEN_SECRET environment variable is required in production');
}
return {
port: parseInt(process.env.PORT ?? '4000', 10),
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000',
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001',
};
});

View File

@@ -1,11 +1,12 @@
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT),
// which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
import "dotenv/config";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe, VersioningType } from "@nestjs/common";
import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import helmet from "helmet";
import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
@@ -14,21 +15,32 @@ import { SessionActivityInterceptor } from "./common/interceptors/session-activi
// Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations
process.env.TZ = 'Africa/Addis_Ababa';
// Safety guard: prevent insecure TLS from being enabled in production
if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS === 'true') {
throw new Error('WAAFI_INSECURE_TLS=true is not allowed in production');
}
async function bootstrap() {
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
const app = await NestFactory.create(AppModule, { rawBody: true });
// Security headers
app.use(helmet());
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
// version-neutral at their existing paths (e.g. /search, /bookings) unchanged for the frontend.
app.enableVersioning({ type: VersioningType.URI });
app.enableCors({
origin: [
process.env.PORTAL_URL ?? "http://localhost:5174",
process.env.BACK_OFFICE_URL ?? "http://localhost:5184",
process.env.BACK_OFFICE_URL ?? "http://localhost:5184",
],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept-Language', 'X-Request-ID'],
credentials: true,
});
app.useGlobalFilters(new HttpExceptionFilter());
@@ -38,6 +50,7 @@ async function bootstrap() {
);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false }));
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle("EDR Passenger API")
.setDescription(
@@ -130,7 +143,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- Complete audit trail per leg for compliance and reporting
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
- **Boarding pass delivered via email + SMS on every successful gate validation** includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
### Booking Type Matrix
@@ -240,28 +253,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret
### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:**
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
\`POST /passengers/verify-fayda\` Automatic Fayda verification for adults (5+ years)
**For International Passengers:**
\`POST /passengers/register-international\` — Passport information collection
\`POST /passengers/register-international\` Passport information collection
### Step 4: View Seat Map
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
\`GET /seats/seatmap/{scheduleId}\` Show available coaches and seats.
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
### Step 5: Hold Seats
\`POST /seats/hold\` to reserve seats for 15 minutes.
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
- ROUND_TRIP return: second hold call → \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
- ONE_WAY / TRANSIT outbound leg: one hold call \`holdId\`
- TRANSIT leg-2: second hold call \`leg2HoldId\`
- ROUND_TRIP return: second hold call \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking
Choose the right endpoint and bookingType:
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
- **ONE_WAY** \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
- **ROUND_TRIP** same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
- **TRANSIT** same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
- **ROUND_TRIP_TRANSIT** same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -406,10 +419,12 @@ Payment providers send notifications to:
operationsSorter: "alpha",
},
});
} // end if (NODE_ENV !== 'production')
const port = process.env.PORT ?? 4000;
await app.listen(port);
console.log(`🚀 EDR Passenger API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
const logger = new Logger('Bootstrap');
logger.log(`EDR Passenger API running on port ${port}`);
logger.log(`Swagger: http://localhost:${port}/api-docs`);
}
bootstrap();

View File

@@ -180,6 +180,9 @@ export class PassengerAuthService {
return {
iamUserId,
// Top-level passengerId keeps the profile shape consistent with the login
// response so the web User object always carries it (the JWT does not).
passengerId: passenger.id,
email: iam?.email ?? null,
phone: iam?.phone_number ?? null,
fullName: iam?.name?.en ?? iam?.name?.am ?? null,

View File

@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { CurrenciesController } from './currencies.controller';
import { CurrenciesService } from './currencies.service';
import { CurrencyModule } from '../currency/currency.module';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [HttpModule],
imports: [HttpModule, PrismaModule, CurrencyModule],
controllers: [CurrenciesController],
providers: [CurrenciesService],
exports: [CurrenciesService],

View File

@@ -1,10 +1,14 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
@Injectable()
export class CurrenciesService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
) {}
async getAllCurrencies() {
const rates = await this.prisma.currencyExchangeRate.findMany({
@@ -108,7 +112,12 @@ export class CurrenciesService {
}
async syncExchangeRates() {
return { message: 'Exchange rates synced successfully', synced: 0 };
await this.currencyService.syncExchangeRates();
const rates = await this.prisma.currencyExchangeRate.findMany({
orderBy: { effectiveDate: 'desc' },
take: 10,
});
return { message: 'Exchange rates synced successfully', synced: rates.length };
}
private getCurrencyName(code: string): string {

View File

@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { CurrencyService } from './currency.service';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, HttpModule],
providers: [CurrencyService],
exports: [CurrencyService],
})

View File

@@ -4,6 +4,9 @@ import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { PrismaService } from '../../common/prisma.service';
import { Currency } from '@prisma/client';
@@ -21,7 +24,11 @@ const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
export class CurrencyService {
private readonly logger = new Logger(CurrencyService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
async convertEtbMinorToChargeMajor(
amountMinorEtb: number,
@@ -81,14 +88,11 @@ export class CurrencyService {
fromCurrency: Currency,
toCurrency: Currency,
): Promise<number> {
if (fromCurrency === toCurrency) return 1;
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: {
fromCurrency,
toCurrency,
},
orderBy: {
effectiveDate: 'desc',
},
where: { fromCurrency, toCurrency },
orderBy: { effectiveDate: 'desc' },
});
if (!exchangeRate) {
@@ -98,26 +102,61 @@ export class CurrencyService {
return 1.0;
}
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
if (ageMs > 2 * 24 * 60 * 60 * 1000) {
this.logger.warn(
`Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`,
);
}
return Number(exchangeRate.rate);
}
async syncExchangeRates(): Promise<void> {
this.logger.log('Syncing exchange rates from external provider');
this.logger.log('Syncing exchange rates from central bank API');
const today = this.todayUtc();
const rates = [
{ from: 'ETB', to: 'ETB', rate: 1.0 },
{ from: 'ETB', to: 'DJF', rate: 3.25 },
{ from: 'ETB', to: 'USD', rate: 0.018 },
{ from: 'DJF', to: 'ETB', rate: 0.3077 },
{ from: 'USD', to: 'ETB', rate: 55.56 },
// Fallback rates used when the API is unreachable
const fallbackRates = [
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: Currency.ETB, to: Currency.DJF, rate: 3.25 },
{ from: Currency.ETB, to: Currency.USD, rate: 0.018 },
{ from: Currency.DJF, to: Currency.ETB, rate: 0.3077 },
{ from: Currency.USD, to: Currency.ETB, rate: 55.56 },
];
for (const { from, to, rate } of rates) {
await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API');
const apiUrl = this.configService.get<string>('EXCHANGE_RATE_API_URL');
if (apiUrl) {
try {
const response = await firstValueFrom(
this.httpService.get<Record<string, number>>(apiUrl, { timeout: 5000 }),
);
// Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... }
const data = response.data;
const apiRates = [
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate },
{ from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate },
{ from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate },
{ from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate },
];
for (const { from, to, rate } of apiRates) {
await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API');
}
this.logger.log('Exchange rates synced from central bank API');
return;
} catch (err) {
this.logger.warn(
`Central bank API unreachable (${(err as Error).message}), falling back to configured rates`,
);
}
}
this.logger.log('Exchange rates synced successfully');
// Fallback: persist the static rates so the DB always has a current row
for (const { from, to, rate } of fallbackRates) {
await this.upsertRate(from, to, rate, today, 'FALLBACK');
}
this.logger.log('Exchange rates synced using fallback values');
}
async listRates() {

View File

@@ -4,8 +4,6 @@ import { CurrencyService } from '../currency/currency.service';
import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto';
import { Currency } from '@prisma/client';
const TAX_RATE = 0.05;
@Injectable()
export class FareEngineService {
constructor(
@@ -32,6 +30,22 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
// Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL
const nationalityUpper = (dto.nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
// Find the nationality-specific seat class for the same coach type and bed position.
// Falls back to the requested seatClass if no nationality-specific one exists.
const nationalitySeatClass = await this.prisma.seatClass.findFirst({
where: {
coachTypeId: seatClass.coachTypeId,
nationalityType,
bedPosition: seatClass.bedPosition ?? null,
isActive: true,
},
}) ?? seatClass;
// Calculate distance: distanceKm represents cumulative distance from route origin
// For a segment, distance = destination.distanceKm - origin.distanceKm
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
@@ -68,16 +82,45 @@ export class FareEngineService {
let ratePerKmMinor: number;
let fareSource: string;
if (fareRule) {
// Flat fare from FareRule — distance is informational only
// 1. Segment override: exact origin→destination stop pair on this route
const segmentOverride = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: route.id,
seatClassId: dto.seatClassId,
originStopSequence: originStop.sequence,
destinationStopSequence: destStop.sequence,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
nationality: dto.nationality ?? null,
},
}) ?? await this.prisma.segmentFareRule.findFirst({
where: {
routeId: route.id,
seatClassId: dto.seatClassId,
originStopSequence: originStop.sequence,
destinationStopSequence: destStop.sequence,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
nationality: null,
},
});
if (segmentOverride) {
// Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SEGMENT_FARE_RULE';
} else if (fareRule?.tripId) {
// Schedule-scoped flat override
baseFarePerPassengerMinor = fareRule.baseFareMinor;
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
fareSource = 'SCHEDULE_FARE_RULE';
} else {
// Distance × rate fallback
ratePerKmMinor = seatClass.baseFareMinor;
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
fareSource = 'DISTANCE_RATE';
// Default: distance-based using tariff formula: km × rate × 1.02
// baseFareMinor stores the per-km rate (tariff decimal × 100000)
ratePerKmMinor = nationalitySeatClass.baseFareMinor;
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02);
fareSource = 'SEAT_CLASS_BASE_FARE';
}
// Premium and insurance fees applied per passenger
@@ -110,8 +153,7 @@ export class FareEngineService {
}
const afterDiscountMinor = subtotalMinor - discountMinor;
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE);
const totalEtbMinor = afterDiscountMinor + taxMinor;
const totalEtbMinor = afterDiscountMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
@@ -119,8 +161,9 @@ export class FareEngineService {
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
`Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalitySeatClass.name}`,
`Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`,
`Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
@@ -132,10 +175,8 @@ export class FareEngineService {
``,
`Subtotal: ${subtotalMinor} ETB minor`,
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
`Tax (5%): +${taxMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`,
``,
`Nationality: ${dto.nationality ?? 'unspecified'}${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
`Fare source: ${fareSource}`,
@@ -146,7 +187,8 @@ export class FareEngineService {
routeCode: route.code,
originName: originStation?.name ?? dto.originStationId,
destinationName: destStation?.name ?? dto.destinationStationId,
seatClassName: seatClass.name,
seatClassId: nationalitySeatClass.id,
seatClassName: nationalitySeatClass.name,
totalDistanceKm,
ratePerKmMinor,
baseFarePerPassengerMinor,
@@ -159,7 +201,6 @@ export class FareEngineService {
paidChildrenCount,
subtotalMinor,
discountMinor,
taxMinor,
totalMinor: totalEtbMinor,
billingCurrency,
totalInBillingCurrency,
@@ -284,16 +325,13 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => {
const seatClassId = rule.seatClassId;
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
const totalMinor = rule.baseFareMinor + taxMinor;
return {
seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
taxMinor,
totalMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};

View File

@@ -79,11 +79,10 @@ export class UpdateClassDto {
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ example: 50 }) @IsOptional() @IsInt() baseFareMinor?: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
}
export class GenerateSeatMapDto {

View File

@@ -258,6 +258,8 @@ export class FleetService {
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
premiumMinor: dto.premiumMinor,
insuranceFeeMinor: dto.insuranceFeeMinor,
};
if (dto.isActive !== undefined) {

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Routes')
@@ -93,4 +93,35 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' })
@ApiResponse({ status: 404, description: 'Route not found' })
getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); }
// ── Route Coach Template ───────────────────────────────────────────────────
@Get(':id/coaches')
@ApiOperation({ summary: 'Get the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Ordered coach template with coach and coach type details' })
@ApiResponse({ status: 404, description: 'Route not found' })
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Set the default coach lineup for this route',
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
})
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Updated coach template' })
@ApiResponse({ status: 400, description: 'Duplicate positions or inactive coach' })
@ApiResponse({ status: 404, description: 'Route or coach not found' })
setCoachTemplate(@Param('id') id: string, @Body() dto: SetRouteCoachTemplateDto) {
return this.service.setRouteCoachTemplate(id, dto);
}
@Delete(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Template cleared' })
@ApiResponse({ status: 404, description: 'Route not found' })
clearCoachTemplate(@Param('id') id: string) { return this.service.removeRouteCoachTemplate(id); }
}

View File

@@ -44,3 +44,14 @@ export class UpdateRouteDto {
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}
export class RouteCoachTemplateItemDto {
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() @Min(1) positionNumber: number;
}
export class SetRouteCoachTemplateDto {
@ApiProperty({ type: [RouteCoachTemplateItemDto], description: 'Ordered list of coaches for this route. Replaces the existing template.' })
@IsArray() @ValidateNested({ each: true }) @Type(() => RouteCoachTemplateItemDto)
coaches: RouteCoachTemplateItemDto[];
}

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@Injectable()
@@ -202,6 +202,44 @@ export class RoutesService {
});
}
async getRouteCoachTemplate(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
return this.prisma.routeCoachTemplate.findMany({
where: { routeId },
include: { coach: { include: { coachType: true } } },
orderBy: { positionNumber: 'asc' },
});
}
async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
const coachIds = dto.coaches.map(c => c.coachId);
const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
const inactive = coaches.find(c => c.status !== 'ACTIVE');
if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`);
const positions = dto.coaches.map(c => c.positionNumber);
if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values');
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
await this.prisma.routeCoachTemplate.createMany({
data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })),
});
return this.getRouteCoachTemplate(routeId);
}
async removeRouteCoachTemplate(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
return { deleted: true, routeId };
}
// ── Used by SchedulesService ───────────────────────────────────────────────
/**

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service';
@@ -132,6 +132,23 @@ export class SchedulesController {
@Body() dto: UpdateStopTimeDto,
) { return this.service.updateStop(id, sequence, dto); }
@Put(':scheduleId/fares/:seatClassId')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Override fare for a specific seat class on a schedule',
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
})
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'seatClassId', description: 'SeatClass UUID' })
@ApiResponse({ status: 200, description: 'Fare rule upserted' })
upsertScheduleFare(
@Param('scheduleId') scheduleId: string,
@Param('seatClassId') seatClassId: string,
@Body() dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
) {
return this.service.upsertScheduleFare(scheduleId, seatClassId, dto);
}
@Get(':scheduleId/fares/stored')
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })

View File

@@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto {
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule. Overrides the route coach template if provided.' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
}

View File

@@ -46,6 +46,8 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
// createSchedule already auto-applies the route coach template;
// only override if explicit coachIds are provided
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
@@ -177,6 +179,18 @@ export class SchedulesService {
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
// Auto-apply route coach template if one is defined
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
where: { routeId: dto.routeId },
orderBy: { positionNumber: 'asc' },
});
if (coachTemplates.length > 0) {
await this.assignCoaches(
schedule.id,
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
);
}
return this.getSchedule(schedule.id);
}
@@ -402,6 +416,34 @@ export class SchedulesService {
});
}
async upsertScheduleFare(
scheduleId: string,
seatClassId: string,
dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
) {
const [schedule, seatClass] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }),
this.prisma.seatClass.findUnique({ where: { id: seatClassId } }),
]);
if (!schedule) throw new NotFoundException('Schedule not found');
if (!seatClass) throw new NotFoundException('Seat class not found');
const now = new Date();
const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now;
const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null;
return this.prisma.$transaction(async (tx) => {
await tx.fareRule.updateMany({
where: { tripId: scheduleId, seatClassId, validUntil: null },
data: { validUntil: now },
});
return tx.fareRule.create({
data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil },
include: { seatClass: true },
});
});
}
createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
return this.prisma.fareRule.create({
@@ -555,10 +597,10 @@ export class SchedulesService {
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
const data = coaches.map((c, idx) => ({
const data = coaches.map((c) => ({
scheduleId,
coachId: c.coachId,
positionNumber: idx + 1,
positionNumber: c.positionNumber,
isOperational: true,
}));

View File

@@ -418,6 +418,7 @@ export class SearchService {
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
@@ -426,56 +427,25 @@ export class SearchService {
}
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date();
const nationality = dto.nationality;
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: seatClass.id,
nationality: dto.nationality,
scheduleId: dto.scheduleId,
adultCount: dto.adultCount,
childCount: dto.childCount ?? 0,
promoCode: dto.promoCode,
});
const bestMatch = this.selectBestFareRule(
candidates,
dto.scheduleId,
segmentRoute,
fullRoute,
nationality,
);
const baseFareMinor = bestMatch?.baseFareMinor
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0;
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > now) {
discountMinor = promo.percentOff
? Math.round(totalBaseFareMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency);
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
@@ -487,13 +457,23 @@ export class SearchService {
segmentRoute,
seatClassName: dto.seatClassName,
nationality: dto.nationality,
adultCount, childCount,
baseFareMinor, adultFareMinor, childFareMinor,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount, totalBaseFareMinor,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
adultCount: fare.adultCount,
childCount: fare.childCount,
baseFareMinor: fare.baseFarePerPassengerMinor,
adultFareMinor: fare.adultCount * fare.farePerPassengerMinor,
childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor,
freeChildrenCount: fare.freeChildrenCount,
paidChildrenCount: fare.paidChildrenCount,
premiumMinor: fare.premiumPerPassenger,
insuranceFeeMinor: fare.insurancePerPassenger,
totalBaseFareMinor: fare.subtotalMinor,
discountMinor: fare.discountMinor,
taxesFeesMinor: 0,
loyaltyRedemptionMinor: loyaltyMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
};
}
@@ -505,15 +485,19 @@ export class SearchService {
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
const seatClassMap = new Map<string, any>();
// Collect seat class IDs from the schedule include for the ID set,
// but fetch fresh records from DB so updated baseFareMinor is always current
const seatClassIdSet = new Set<string>();
for (const a of schedule.coachAssignments) {
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
if (sc.isActive) seatClassIdSet.add(sc.id);
}
}
const seatClasses = Array.from(seatClassMap.values())
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
const freshSeatClasses = await this.prisma.seatClass.findMany({
where: { id: { in: Array.from(seatClassIdSet) }, isActive: true },
});
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
if (seatClasses.length === 0) return [];
@@ -531,9 +515,9 @@ export class SearchService {
});
return {
seatClassName: fare.seatClassName,
baseFareMinor: fare.baseFarePerPassengerMinor,
baseFareMinor: fare.totalMinor,
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
displayAmountMinor: fare.totalInBillingCurrency,
};
} catch {
return null;
@@ -568,12 +552,16 @@ export class SearchService {
if (fareRules.length > 0) {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
return fareRules.map(rule => ({
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
baseFareMinor: rule.baseFareMinor,
displayCurrency,
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
}));
const TAX_RATE = 0.05;
return fareRules.map(rule => {
const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE);
return {
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
baseFareMinor: totalMinor,
displayCurrency,
displayAmountMinor: Math.round(totalMinor * exchangeRate),
};
});
}
}
@@ -644,54 +632,4 @@ export class SearchService {
});
}
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
});
return fare.baseFarePerPassengerMinor;
}
private selectBestFareRule(
candidates: any[],
scheduleId: string,
segmentRoute: string,
fullRoute: string,
nationality?: string,
): any | null {
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
);
if (match) return match;
}
return null;
}
}

View File

@@ -19,21 +19,31 @@ export class SeatClassesService {
return sc;
}
async updateSeatClass(id: string, dto: any) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found');
const { basePrice, ...rest } = dto;
const data = {
...rest,
...(basePrice !== undefined && { baseFareMinor: basePrice }),
};
return this.prisma.seatClass.update({ where: { id }, data });
}
async createSeatClass(dto: any) {
try {
return await this.prisma.seatClass.create({ data: dto });
const { basePrice, ...rest } = dto;
const data = {
...rest,
...(basePrice !== undefined && { baseFareMinor: basePrice }),
};
return await this.prisma.seatClass.create({ data });
} catch (e: any) {
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
throw e;
}
}
async updateSeatClass(id: string, dto: any) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found');
return this.prisma.seatClass.update({ where: { id }, data: dto });
}
async deleteSeatClass(id: string) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found');

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule],
imports: [PrismaModule, NotificationsModule, CurrencyModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -2,12 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
/** Maximum time (hours) a passenger has to pay after booking. */
const MAX_PAYMENT_HOURS = 2;
/** Minutes before departure: cutoff for new bookings and payment deadline. */
const CUTOFF_MINUTES = 30;
// Retention windows
const OTP_RETENTION_HOURS = 1;
const FAYDA_SESSION_RETENTION_HOURS = 1;
const AUDIT_LOG_RETENTION_DAYS = 365;
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
const GATE_LOG_RETENTION_DAYS = 180;
/**
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
*/
@@ -32,6 +40,7 @@ export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsClientService,
private readonly currencyService: CurrencyService,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -243,4 +252,47 @@ export class TasksService {
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 01:00 EAT: fetch mid-market rates from central bank API.
// ─────────────────────────────────────────────────────────────────────────
@Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' })
async syncExchangeRates() {
try {
await this.currencyService.syncExchangeRates();
} catch (err) {
this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────
@Cron('0 2 * * *')
async purgeExpiredData() {
const now = new Date();
const otpCutoff = new Date(now.getTime() - OTP_RETENTION_HOURS * 60 * 60 * 1000);
const faydaCutoff = new Date(now.getTime() - FAYDA_SESSION_RETENTION_HOURS * 60 * 60 * 1000);
const auditCutoff = new Date(now.getTime() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const webhookCutoff = new Date(now.getTime() - WEBHOOK_EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const gateCutoff = new Date(now.getTime() - GATE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const [otps, faydaSessions, auditLogs, webhookEvents, gateLogs] = await Promise.all([
this.prisma.otpCode.deleteMany({
where: { OR: [{ expiresAt: { lte: otpCutoff } }, { verified: true, createdAt: { lte: otpCutoff } }] },
}),
this.prisma.faydaVerificationSession.deleteMany({
where: { OR: [{ expiresAt: { lte: faydaCutoff } }, { status: { in: ['COMPLETED', 'FAILED'] }, createdAt: { lte: faydaCutoff } }] },
}),
this.prisma.auditLog.deleteMany({ where: { createdAt: { lte: auditCutoff } } }),
this.prisma.paymentWebhookEvent.deleteMany({ where: { receivedAt: { lte: webhookCutoff } } }),
this.prisma.gateValidationLog.deleteMany({ where: { validatedAt: { lte: gateCutoff } } }),
]);
this.logger.log(
`Data retention purge: ${otps.count} OTPs, ${faydaSessions.count} Fayda sessions, ` +
`${auditLogs.count} audit logs, ${webhookEvents.count} webhook events, ${gateLogs.count} gate logs deleted`,
);
}
}

View File

@@ -159,6 +159,8 @@ export class TicketsService {
} : null,
status: t.status,
validatedAt: t.validatedAt,
boardedAt: t.validatedAt,
qrCode: t.qrPayload ?? null,
createdAt: t.issuedAt,
};
}),
@@ -247,9 +249,12 @@ export class TicketsService {
// Use first seat for primary data
const primarySeat = passengerSeats[0];
// Build passenger QR data with all legs included
const qrData = JSON.stringify({
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
// Re-encode QR with ticketNumber included
const qrDataWithTicket = JSON.stringify({
ref: booking.bookingRef,
ticketNumber: barcodePayload,
type: booking.bookingType,
passenger: passengerName,
seats: passengerSeats.map(ps => ({
@@ -259,8 +264,7 @@ export class TicketsService {
scheduleId: ps.scheduleId || booking.scheduleId,
})),
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
const qrPayloadFinal = await QRCode.toDataURL(qrDataWithTicket);
const ticket = await this.prisma.ticket.create({
data: {
@@ -270,7 +274,7 @@ export class TicketsService {
seatId: primarySeat.seatId,
leg: primarySeat.leg || 1,
scheduleId: primarySeat.scheduleId || booking.scheduleId,
qrPayload,
qrPayload: qrPayloadFinal,
barcodePayload,
} as any,
});
@@ -371,15 +375,13 @@ export class TicketsService {
let bookingRef = qrCodeOrRef;
try {
const qrData = JSON.parse(qrCodeOrRef);
if (qrData.ref) {
bookingRef = qrData.ref;
}
if (qrData.ref) bookingRef = qrData.ref;
} catch {
// Not JSON, treat as booking reference
// Not JSON, treat as booking reference or ticket number
}
// Get booking and ticket info
const booking = await this.prisma.booking.findUnique({
let booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
@@ -389,6 +391,23 @@ export class TicketsService {
},
});
if (!booking) {
// Input may be a ticket number (barcodePayload) — look it up
const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: bookingRef } });
if (ticket) {
booking = await this.prisma.booking.findUnique({
where: { bookingRef: ticket.bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
tickets: true,
seats: { include: { seat: { include: { coach: true } } } },
},
});
if (booking) bookingRef = (booking as any).bookingRef;
}
}
if (!booking) {
throw new NotFoundException('Ticket not found');
}
@@ -443,6 +462,7 @@ export class TicketsService {
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
boarding: {
ticketId: ticket.id,
ticketNumber: ticket.barcodePayload,
bookingRef: booking.bookingRef,
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
route: `${(booking as any).schedule?.originStation?.name || 'N/A'}${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,

View File

@@ -1,12 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
transpilePackages: ['@edr/types', '@edr/ui-common'],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
},
images: {
unoptimized: true,
unoptimized: false,
},
};

View File

@@ -15,7 +15,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = useState(false);
const [isInitializing, setIsInitializing] = useState(false);
const [stream, setStream] = useState<MediaStream | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const scanIntervalRef = useRef<number | null>(null);
@@ -34,9 +34,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
}
// First, stop any existing stream
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Request camera access with simpler fallback
@@ -120,7 +120,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
}
// Set state to show video
setStream(mediaStream);
streamRef.current = mediaStream;
setIsScanning(true);
setIsInitializing(false);
@@ -144,9 +144,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
onError(errorMsg);
// Clean up on error
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsScanning(false);
setIsInitializing(false);
@@ -158,16 +158,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setIsScanning(false);
setCameraError(null);
}, [stream]);
}, []);
// QR code scanning with jsqr
const scanFrame = useCallback(() => {
@@ -201,15 +201,19 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
useEffect(() => {
if (isScanning) {
scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms
}
return () => {
scanIntervalRef.current = window.setInterval(scanFrame, 100);
} else {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
stopCamera();
};
}, [isScanning, scanFrame, stopCamera]);
}
}, [isScanning, scanFrame]);
// Cleanup on unmount only
useEffect(() => {
return () => stopCamera();
}, [stopCamera]);
// Load jsqr from CDN
useEffect(() => {
@@ -294,9 +298,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
</div>
<button
onClick={() => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsInitializing(false);
setCameraError('Camera initialization cancelled by user');
@@ -348,7 +352,7 @@ export default function BoardingPage() {
if (result.success) {
setSuccess('Passenger boarded successfully!');
setLastScanned(result.boarding);
setQrInput('');
setQrInput(result.boarding?.ticketNumber || result.boarding?.bookingRef || '');
// Auto-focus for next scan
setTimeout(() => inputRef.current?.focus(), 1000);
} else {
@@ -442,7 +446,13 @@ export default function BoardingPage() {
{/* Camera Scanner */}
<QRScanner
onScan={(data) => {
setQrInput(data);
let displayValue = data;
try {
const parsed = JSON.parse(data);
if (parsed.ticketNumber) displayValue = parsed.ticketNumber;
else if (parsed.ref) displayValue = parsed.ref;
} catch { /* not JSON, use raw */ }
setQrInput(displayValue);
handleScan(data);
}}
onError={setError}
@@ -537,7 +547,7 @@ export default function BoardingPage() {
)}
<div className="text-sm text-green-600 dark:text-green-400 font-mono mt-2">
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId}
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketNumber}
</div>
<div className="text-xs text-green-600 dark:text-green-400 mt-2">
@@ -565,7 +575,7 @@ export default function BoardingPage() {
<li> Tap "Scan QR Code" and point at ticket QR code</li>
<li> Allow camera access when your browser prompts you</li>
<li> Hold phone steady and position QR code within the frame</li>
<li> For manual option, type or paste booking reference</li>
<li> For manual option, type or paste ticket number</li>
<li> Tickets can only be boarded on their departure date</li>
<li> First scan boards outbound leg for round trips</li>
<li> Email & SMS sent automatically to passenger contacts</li>

View File

@@ -75,9 +75,9 @@ export default function ClassesPage() {
coachTypeId: selectedCoachTypeId,
name: formData.get('name') as string,
description: formData.get('description') as string,
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,
baseFareMinor: Math.round(Number((parseFloat(formData.get('baseFareMinor') as string) * 100).toFixed(10))) || 0,
premiumMinor: Math.round(Number((parseFloat(formData.get('premiumMinor') as string) * 100).toFixed(10))) || 0,
insuranceFeeMinor: Math.round(Number((parseFloat(formData.get('insuranceFeeMinor') as string) * 100).toFixed(10))) || 0,
isActive: formData.get('isActive') === 'true',
};
@@ -129,13 +129,6 @@ export default function ClassesPage() {
label: 'Class Name',
render: (cls: any) => <span className="font-medium">{cls.name}</span>,
},
{
key: 'description',
label: 'Description',
render: (cls: any) => (
<span className="text-sm text-muted-foreground">{cls.description || '-'}</span>
),
},
{
key: 'baseFareMinor',
label: 'Base Fare',
@@ -251,7 +244,7 @@ export default function ClassesPage() {
title={`${editingClass ? 'Edit' : 'Add'} Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Coach Type *</label>
@@ -265,7 +258,7 @@ export default function ClassesPage() {
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
{ct.code} - {ct.name} - {ct.type}
</option>
))}
</select>
@@ -283,17 +276,6 @@ export default function ClassesPage() {
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingClass?.description || ''}
placeholder="Describe this class..."
/>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
@@ -345,8 +327,8 @@ export default function ClassesPage() {
<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>
<p className="mt-2 text-xs"> Premium applies per passenger</p>
<p className="text-xs"> Insurance applies per passenger</p>
</div>
</div>

View File

@@ -731,7 +731,7 @@ export default function CoachesPage() {
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
{ct.code} - {ct.name} - {ct.type}
</option>
))}
</select>

View File

@@ -34,7 +34,7 @@ interface SeatClass {
}
export default function PricingPage() {
const [tab, setTab] = useState<'schedule' | 'segment' | 'baggage'>('schedule');
const [tab, setTab] = useState<'segment' | 'schedule' | 'baggage'>('segment');
const [showModal, setShowModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<string>('');
const [selectedRoute, setSelectedRoute] = useState<string>('');
@@ -103,7 +103,7 @@ export default function PricingPage() {
queryFn: async () => {
if (!selectedSchedule) return [];
try {
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/all`);
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/stored`);
return Array.isArray(response) ? response : (response as any)?.data || [];
} catch (err: any) {
const errMsg = err.response?.data?.message || err.message || 'Failed to load fares';
@@ -175,7 +175,7 @@ export default function PricingPage() {
});
const updateFareMutation = useMutation({
mutationFn: (data: any) => apiClient.patch(`/schedules/fares/${data.id}`, data),
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/schedules/fares/${id}`, data),
onSuccess: () => {
refetchFares();
setEditingFare(null);
@@ -264,10 +264,12 @@ export default function PricingPage() {
};
const handleEditFare = (fare: any) => {
setEditingFare(fare);
// Engine-calculated fares have no `id` — open as new rule pre-filled with engine values
setEditingFare(fare.id ? fare : null);
const minor = fare.totalMinor ?? fare.baseFareMinor ?? fare.baseFare ?? 0;
setFareForm({
seatClassId: fare.seatClassId || '',
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
baseFare: (minor / 100).toFixed(2),
nationality: fare.nationality || '',
passengerCategory: fare.passengerCategory || '',
route: fare.route || '',
@@ -283,12 +285,12 @@ export default function PricingPage() {
const routeStops = currentRoute?.stops || [];
const originStop = routeStops.find((s: any) => s.sequence === fare.originStopSequence);
const destStop = routeStops.find((s: any) => s.sequence === fare.destinationStopSequence);
setSegmentForm({
seatClassId: fare.seatClassId || '',
originStationId: originStop?.stationId || '',
destinationStationId: destStop?.stationId || '',
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
baseFare: ((fare.baseFare || fare.baseFareMinor || 0) / 100).toFixed(2),
nationality: fare.nationality || '',
passengerCategory: fare.passengerCategory || '',
validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
@@ -305,7 +307,7 @@ export default function PricingPage() {
return;
}
const baseFareMinor = parseInt(fareForm.baseFare, 10);
const baseFareMinor = Math.round(parseFloat(fareForm.baseFare) * 100);
if (editingFare) {
await updateFareMutation.mutateAsync({
@@ -353,7 +355,7 @@ export default function PricingPage() {
return;
}
const baseFareMinor = parseInt(segmentForm.baseFare, 10);
const baseFareMinor = Math.round(parseFloat(segmentForm.baseFare) * 100);
if (editingFare) {
await updateSegmentFareMutation.mutateAsync({
@@ -422,9 +424,9 @@ export default function PricingPage() {
key: 'baseFare',
label: 'Fare (ETB)',
render: (fare: any) => {
const fareValue = fare.baseFare || fare.baseFareMinor;
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
return <span className="font-mono font-medium">{fareValue} ETB</span>;
const minor = fare.baseFareMinor ?? fare.baseFare;
if (minor == null) return <span className="text-muted-foreground"></span>;
return <span className="font-mono font-medium">{(minor / 100).toFixed(2)} </span>;
},
},
{
@@ -438,7 +440,7 @@ export default function PricingPage() {
key: 'route',
label: 'Route',
render: (fare: any) => (
<span className="text-sm font-mono">{fare.route || '-'}</span>
<span className="text-sm font-mono">{fare.routeCode || fare.route || '-'}</span>
),
},
{
@@ -467,9 +469,11 @@ export default function PricingPage() {
const stops = currentRoute?.stops || [];
const originStop = stops.find((s: any) => s.sequence === fare.originStopSequence);
const destStop = stops.find((s: any) => s.sequence === fare.destinationStopSequence);
const originCode = stationsArray.find((s: any) => s.id === originStop?.stationId)?.code ?? `Stop ${fare.originStopSequence}`;
const destCode = stationsArray.find((s: any) => s.id === destStop?.stationId)?.code ?? `Stop ${fare.destinationStopSequence}`;
return (
<span className="text-sm font-medium">
Stop {fare.originStopSequence} {fare.destinationStopSequence}
<span className="text-sm font-medium font-mono">
{originCode} {destCode}
</span>
);
},
@@ -479,7 +483,7 @@ export default function PricingPage() {
label: 'Seat Class',
render: (fare: any) => {
const className = fare.seatClass?.name || 'N/A';
return <span className="font-medium">{className}</span>;
return <span>{className}</span>;
},
},
{
@@ -493,9 +497,9 @@ export default function PricingPage() {
key: 'baseFare',
label: 'Fare (ETB)',
render: (fare: any) => {
const fareValue = fare.baseFare || fare.baseFareMinor;
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
return <span className="font-mono font-medium">{fareValue} ETB</span>;
const fareValue = fare.baseFare ?? fare.baseFareMinor;
if (fareValue == null) return <span className="text-muted-foreground"></span>;
return <span className="font-mono font-medium">{(fareValue / 100).toFixed(2)} </span>;
},
},
{
@@ -529,14 +533,12 @@ export default function PricingPage() {
onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare,
variant: 'secondary' as const,
icon: Edit,
disabled: tab === 'schedule', // Schedule fares are computed, not stored
},
{
label: 'Delete',
onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }),
variant: 'danger' as const,
icon: Trash2,
disabled: tab === 'schedule', // Schedule fares are computed, not stored
},
];
@@ -588,89 +590,30 @@ export default function PricingPage() {
<div className="card">
<div className="flex gap-4 border-b mb-6">
<button
onClick={() => {
setTab('schedule');
setError(null);
}}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'schedule' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Schedule Fares
</button>
<button
onClick={() => { setTab('segment'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Segment Fares
</button>
<button
onClick={() => { setTab('schedule'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'schedule' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Schedule Fares
</button>
<button
onClick={() => { setTab('baggage'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Excess Baggage Rates
</button>
</div>
<div className="space-y-6">
{tab === 'schedule' && (
<>
<div>
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => {
setSelectedSchedule(e.target.value);
setError(null);
}}
className="input w-full max-w-md"
>
<option value="">Choose a schedule...</option>
{schedulesArray.map((schedule: Schedule) => (
<option key={schedule.id} value={schedule.id}>
{schedule.train?.name} ({schedule.train?.number}) - {schedule.originStation?.name} to {schedule.destinationStation?.name} ({new Date(schedule.departureAt).toLocaleDateString()})
</option>
))}
</select>
</div>
{selectedSchedule && (
<div>
<h3 className="text-lg font-semibold mb-4">Calculated Fares</h3>
<div className="mb-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">
These are <strong>dynamically calculated</strong> fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above.
</div>
{faresLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : faresArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No fares available for this schedule.
</div>
) : (
<>
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
{faresArray.length} seat class(es) available
</div>
<DataTable
data={faresArray}
columns={fareColumns}
actions={fareActions}
loading={false}
emptyMessage="No fares available."
/>
</>
)}
</div>
)}
</>
)}
{tab === 'segment' && (
<>
@@ -722,6 +665,61 @@ export default function PricingPage() {
)}
</>
)}
{tab === 'schedule' && (
<>
<div>
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => {
setSelectedSchedule(e.target.value);
setError(null);
}}
className="input w-full max-w-md"
>
<option value="">Choose a schedule...</option>
{schedulesArray.map((schedule: Schedule) => (
<option key={schedule.id} value={schedule.id}>
{schedule.train?.name} ({schedule.train?.number}) - {schedule.originStation?.name} to {schedule.destinationStation?.name} ({new Date(schedule.departureAt).toLocaleDateString()})
</option>
))}
</select>
</div>
{selectedSchedule && (
<div>
<h3 className="text-lg font-semibold mb-4">Stored Fare Rules</h3>
{faresLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : faresArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No fare rules defined for this schedule. Click "Add Fare Rule" to create one.
</div>
) : (
<>
<div className="mb-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">
These are <strong>stored fare override rules</strong> for this schedule. Click "Add Fare Rule" to create one. If no rules exist, the fare engine calculates fares automatically.
</div>
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
{faresArray.length} fare rule(s) defined
</div>
<DataTable
data={faresArray}
columns={fareColumns}
actions={fareActions}
loading={false}
emptyMessage="No fares available."
/>
</>
)}
</div>
)}
</>
)}
{tab === 'baggage' && (
<>
{allowancesLoading ? (
@@ -736,7 +734,7 @@ export default function PricingPage() {
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} </span> },
]}
actions={[
{
@@ -771,10 +769,10 @@ export default function PricingPage() {
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Pricing Structure</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
<strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis Dire Dawa)
</li>
<li>
<strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis Dire Dawa)
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
</li>
<li>
<strong>Passenger Type:</strong> ADULT (5+ years) or CHILD (&lt;5) first child travels free, subsequent children pay full fare
@@ -876,11 +874,11 @@ export default function PricingPage() {
<input
type="number"
min="0"
step="1"
step="0.01"
value={fareForm.baseFare}
onChange={(e) => setFareForm({ ...fareForm, baseFare: e.target.value })}
className="input w-full"
placeholder="e.g., 350"
placeholder="e.g., 350.00"
required
/>
</div>
@@ -1006,11 +1004,11 @@ export default function PricingPage() {
<input
type="number"
min="0"
step="1"
step="0.01"
value={segmentForm.baseFare}
onChange={(e) => setSegmentForm({ ...segmentForm, baseFare: e.target.value })}
className="input w-full"
placeholder="e.g., 150"
placeholder="e.g., 150.00"
required
/>
</div>
@@ -1092,8 +1090,8 @@ export default function PricingPage() {
</div>
<div className="flex gap-2 justify-end pt-4">
<ActionButton
variant="secondary"
<ActionButton
variant="secondary"
onClick={() => {
if (tab === 'schedule') resetForm();
else resetSegmentForm();
@@ -1102,8 +1100,8 @@ export default function PricingPage() {
>
Cancel
</ActionButton>
<ActionButton
onClick={tab === 'schedule' ? handleSaveFare : handleSaveSegmentFare}
<ActionButton
onClick={tab === 'schedule' ? handleSaveFare : handleSaveSegmentFare}
loading={
tab === 'schedule'
? createFareMutation.isPending || updateFareMutation.isPending

View File

@@ -1,15 +1,15 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, X, Search } from 'lucide-react';
import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { routesApi } from '@/lib/api/routes';
import { stationsApi } from '@/lib/api';
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
interface RouteStop {
stationId: string;
@@ -18,7 +18,153 @@ interface RouteStop {
distanceFromOrigin?: number;
}
type Tab = 'routes' | 'coaches';
function RouteCoachesTab({ routes }: { routes: any[] }) {
const queryClient = useQueryClient();
const [selectedRouteId, setSelectedRouteId] = useState('');
const [coachRows, setCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const { data: coaches } = useQuery({
queryKey: ['coaches-all'],
queryFn: () => fleetApi.getCoaches(),
});
const { data: template, isLoading: templateLoading } = useQuery({
queryKey: ['route-coaches', selectedRouteId],
queryFn: () => routeCoachTemplatesApi.get(selectedRouteId),
enabled: !!selectedRouteId,
});
const saveMutation = useMutation({
mutationFn: () => routeCoachTemplatesApi.set(selectedRouteId, coachRows),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] }),
});
const clearMutation = useMutation({
mutationFn: () => routeCoachTemplatesApi.clear(selectedRouteId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] });
setCoachRows([]);
},
});
// Sync coachRows when template loads
useEffect(() => {
const rows: any[] = Array.isArray(template) ? template : (template as any)?.coaches ?? [];
if (rows.length) setCoachRows(rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })));
}, [template]);
const allCoaches: any[] = (coaches as any)?.items ?? (Array.isArray(coaches) ? coaches : []);
const addRow = () => setCoachRows([...coachRows, { coachId: '', positionNumber: coachRows.length + 1 }]);
const removeRow = (i: number) => {
const updated = coachRows.filter((_, idx) => idx !== i);
setCoachRows(updated.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
};
const updateRow = (i: number, field: 'coachId', val: string) => {
const updated = [...coachRows];
updated[i] = { ...updated[i], [field]: val };
setCoachRows(updated);
};
const selectedCoachIds = new Set(coachRows.map((r) => r.coachId).filter(Boolean));
const handleCoachDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.setData('coach-index', index.toString());
};
const handleCoachDragOver = (e: React.DragEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).style.opacity = '0.5';
};
const handleCoachDragLeave = (e: React.DragEvent) => {
(e.currentTarget as HTMLElement).style.opacity = '1';
};
const handleCoachDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
(e.currentTarget as HTMLElement).style.opacity = '1';
const sourceIndex = parseInt(e.dataTransfer.getData('coach-index'));
if (sourceIndex === targetIndex) return;
const reordered = [...coachRows];
const [moved] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, moved);
setCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
};
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="label">Select Route</label>
<select className="input" value={selectedRouteId} onChange={(e) => { setSelectedRouteId(e.target.value); setCoachRows([]); }}>
<option value=""> choose a route </option>
{routes.map((r: any) => <option key={r.id} value={r.id}>{r.name} ({r.code})</option>)}
</select>
</div>
</div>
{selectedRouteId && (
<>
{templateLoading ? (
<p className="text-sm text-muted-foreground">Loading template</p>
) : (
<div className="space-y-2">
{coachRows.length > 0 && (
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder coaches</p>
)}
{coachRows.map((row, i) => (
<div
key={i}
draggable
onDragStart={(e) => handleCoachDragStart(e, i)}
onDragOver={handleCoachDragOver}
onDragLeave={handleCoachDragLeave}
onDrop={(e) => handleCoachDrop(e, i)}
className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
>
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="w-8 text-center text-sm font-medium text-muted-foreground flex-shrink-0">
{row.positionNumber}
</div>
<div className="flex-1">
<select className="input input-sm" value={row.coachId} onChange={(e) => updateRow(i, 'coachId', e.target.value)}>
<option value="">Select Coach</option>
{allCoaches
.filter((c: any) => !selectedCoachIds.has(c.id) || c.id === row.coachId)
.map((c: any) => (
<option key={c.id} value={c.id}>{c.number} {c.coachType?.name ?? c.type}</option>
))}
</select>
</div>
<button type="button" onClick={() => removeRow(i)} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
<X className="h-4 w-4" />
</button>
</div>
))}
<div className="flex justify-between pt-2">
<ActionButton type="button" variant="secondary" size="sm" icon={Plus} onClick={addRow}>Add Coach</ActionButton>
<div className="flex gap-2">
{coachRows.length > 0 && (
<ActionButton type="button" variant="danger" size="sm" onClick={() => clearMutation.mutate()} loading={clearMutation.isPending}>
Clear
</ActionButton>
)}
<ActionButton type="button" size="sm" icon={Save} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Template
</ActionButton>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}
export default function RoutesPage() {
const [activeTab, setActiveTab] = useState<Tab>('routes');
const [showModal, setShowModal] = useState(false);
const [editingRoute, setEditingRoute] = useState<any>(null);
const [stops, setStops] = useState<RouteStop[]>([]);
@@ -84,21 +230,18 @@ export default function RoutesPage() {
// Keep current stop order (already rearranged by user)
const sortedMiddleStops = stops;
// Calculate distanceKm (distance from previous stop)
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
const stopsArray = [
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
...sortedMiddleStops.map((stop, idx) => {
const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0);
return {
stationId: stop.stationId,
sequence: idx + 2,
distanceKm: (stop.distanceFromOrigin || 0) - prevDistance,
};
}),
...sortedMiddleStops.map((stop, idx) => ({
stationId: stop.stationId,
sequence: idx + 2,
distanceKm: stop.distanceFromOrigin || 0,
})),
{
stationId: destinationStationId,
sequence: sortedMiddleStops.length + 2,
distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0),
distanceKm: destinationDistance || 0,
},
];
@@ -218,19 +361,14 @@ export default function RoutesPage() {
setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
// Last stop's distanceKm is segment distance from previous stop, so accumulate
let cumulative = 0;
const allStops = routeStops.map((stop: any) => {
cumulative += stop.distanceKm || 0;
return { ...stop, _cumulative: cumulative };
});
setDestinationDistance(allStops[allStops.length - 1]._cumulative);
// distanceKm is cumulative from origin — read directly
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: allStops[idx + 1]._cumulative,
distanceFromOrigin: stop.distanceKm || 0,
}));
setStops(middleStops);
}
@@ -252,42 +390,69 @@ export default function RoutesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Routes</h1>
<p className="text-muted-foreground">Manage railway routes</p>
<p className="text-muted-foreground">Manage railway routes and coach templates</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingRoute(null);
setOriginStationId('');
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
setShowModal(true);
}}
>
Add Route
</ActionButton>
{activeTab === 'routes' && (
<ActionButton
icon={Plus}
onClick={() => {
setEditingRoute(null);
setOriginStationId('');
setDestinationStationId('');
setDestinationDistance(undefined);
setStops([]);
setSearch('');
setShowModal(true);
}}
>
Add Route
</ActionButton>
)}
</div>
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or description..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
{/* Tabs */}
<div className="flex gap-1 border-b">
{(['routes', 'coaches'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
activeTab === tab
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab === 'coaches' ? 'Coach Templates' : 'Routes'}
</button>
))}
</div>
<DataTable
data={displayedRoutes}
columns={routeColumns}
actions={routeActions}
loading={routesLoading}
emptyMessage={search ? "No routes match your search" : "No routes found"}
/>
{activeTab === 'routes' && (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or description..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
data={displayedRoutes}
columns={routeColumns}
actions={routeActions}
loading={routesLoading}
emptyMessage={search ? 'No routes match your search' : 'No routes found'}
/>
</>
)}
{activeTab === 'coaches' && (
<RouteCoachesTab routes={filteredRoutes} />
)}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}

View File

@@ -1,13 +1,14 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Loader2, Zap, Trash2, Edit, Search, X } from 'lucide-react';
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
import { routeCoachTemplatesApi } from '@/lib/api';
interface Schedule {
id: string;
@@ -46,6 +47,7 @@ interface Coach {
export default function SchedulesPage() {
const [showModal, setShowModal] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
@@ -59,12 +61,45 @@ export default function SchedulesPage() {
trainId: '',
routeId: '',
startDateTime: '',
durationHours: '12',
repeatEveryDays: '1',
forNextDays: '30',
coachIds: [] as string[],
durationHours: '10',
repeatEveryDays: '2',
forNextDays: '15',
});
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
queryKey: ['route-coaches', addForm.routeId],
queryFn: () => routeCoachTemplatesApi.get(addForm.routeId),
enabled: !!addForm.routeId,
});
useEffect(() => {
if (!addForm.routeId) { setAddCoachRows([]); return; }
const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? [];
setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []);
}, [singleRouteTemplate, addForm.routeId]);
// Fetch route coach template when route changes
const { data: routeTemplate, isLoading: templateLoading } = useQuery({
queryKey: ['route-coaches', bulkForm.routeId],
queryFn: () => routeCoachTemplatesApi.get(bulkForm.routeId),
enabled: !!bulkForm.routeId,
});
useEffect(() => {
if (!bulkForm.routeId) { setBulkCoachRows([]); return; }
const rows: any[] = Array.isArray(routeTemplate) ? routeTemplate : (routeTemplate as any)?.coaches ?? [];
setBulkCoachRows(
rows.length
? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber }))
: []
);
}, [routeTemplate, bulkForm.routeId]);
const [editForm, setEditForm] = useState({
departureAt: '',
arrivalAt: '',
@@ -121,8 +156,8 @@ export default function SchedulesPage() {
durationHours: '12',
repeatEveryDays: '1',
forNextDays: '30',
coachIds: [],
});
setBulkCoachRows([]);
setError(null);
},
onError: (err: any) => {
@@ -130,6 +165,20 @@ export default function SchedulesPage() {
},
});
const createScheduleMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/schedules', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
setShowAddModal(false);
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
setAddCoachRows([]);
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to create schedule');
},
});
const updateScheduleMutation = useMutation({
mutationFn: (data: { id: string; payload: any }) =>
apiClient.patch(`/schedules/${data.id}`, data.payload),
@@ -187,13 +236,30 @@ export default function SchedulesPage() {
forNextDays: parseInt(bulkForm.forNextDays),
};
if (bulkForm.coachIds.length > 0) {
payload.coachIds = bulkForm.coachIds;
const validCoaches = bulkCoachRows.filter((r) => r.coachId);
if (validCoaches.length > 0) {
payload.coachIds = validCoaches.map((r) => r.coachId);
}
await bulkGenerateMutation.mutateAsync(payload);
};
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
const dep = new Date(addForm.departureAt);
const arr = new Date(addForm.arrivalAt);
if (arr <= dep) { setError('Arrival must be after departure'); return; }
const validCoaches = addCoachRows.filter((r) => r.coachId);
await createScheduleMutation.mutateAsync({
trainId: addForm.trainId,
routeId: addForm.routeId,
departureAt: dep.toISOString(),
arrivalAt: arr.toISOString(),
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
});
};
const handleEditSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
@@ -396,6 +462,13 @@ export default function SchedulesPage() {
},
] as any;
const cancelScheduleMutation = useMutation({
mutationFn: (id: string) => apiClient.patch(`/schedules/${id}/status`, { status: 'CANCELLED' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }),
});
const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
const scheduleActions = [
{
label: 'Edit',
@@ -403,6 +476,13 @@ export default function SchedulesPage() {
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Cancel',
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
variant: 'danger' as const,
icon: X,
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
},
{
label: 'Delete',
onClick: handleDelete,
@@ -428,6 +508,13 @@ export default function SchedulesPage() {
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
</ActionButton>
)}
<ActionButton
icon={Plus}
variant="secondary"
onClick={() => { setError(null); setShowAddModal(true); }}
>
Add Schedule
</ActionButton>
<ActionButton
icon={Zap}
onClick={() => {
@@ -530,6 +617,22 @@ export default function SchedulesPage() {
</div>
</div>
<ConfirmDialog
isOpen={cancelConfirm.isOpen}
onClose={() => setCancelConfirm({ isOpen: false, item: null })}
onConfirm={async () => {
if (cancelConfirm.item) {
await cancelScheduleMutation.mutateAsync(cancelConfirm.item.id);
setCancelConfirm({ isOpen: false, item: null });
}
}}
title="Cancel Schedule"
message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`}
confirmText="Cancel Schedule"
isDanger={true}
isLoading={cancelScheduleMutation.isPending}
/>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
@@ -549,6 +652,107 @@ export default function SchedulesPage() {
warning="Schedules with existing bookings cannot be deleted."
/>
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
title="Add Schedule"
size="lg"
>
<form onSubmit={handleAddSubmit} className="space-y-4">
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Train *</label>
<select className="input" value={addForm.trainId} onChange={(e) => setAddForm({ ...addForm, trainId: e.target.value })} required>
<option value="">Select Train</option>
{trains.map((t: Train) => <option key={t.id} value={t.id}>{t.number} ({t.name})</option>)}
</select>
</div>
<div>
<label className="label">Route *</label>
<select className="input" value={addForm.routeId} onChange={(e) => setAddForm({ ...addForm, routeId: e.target.value })} required>
<option value="">Select Route</option>
{routes.map((r: Route) => <option key={r.id} value={r.id}>{r.code} ({r.name})</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Departure *</label>
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
</div>
<div>
<label className="label">Arrival *</label>
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
</div>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Coaches</label>
<div className="flex items-center gap-3">
{singleTemplateLoading && addForm.routeId && (
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template</span>
)}
{!addForm.routeId && <span className="text-xs text-muted-foreground">Select a route to load its coach template</span>}
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
disabled={addCoachRows.filter(r => r.coachId).length >= coaches.length}
onClick={() => setAddCoachRows([...addCoachRows, { coachId: '', positionNumber: addCoachRows.length + 1 }])}>
Add Coach
</ActionButton>
</div>
</div>
{addCoachRows.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">No coaches assigned.</p>
) : (
<div className="space-y-2">
{addCoachRows.length > 1 && <p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>}
{addCoachRows.map((row, i) => {
const selectedIds = new Set(addCoachRows.map((r) => r.coachId).filter(Boolean));
return (
<div key={i} draggable
onDragStart={(e) => e.dataTransfer.setData('add-coach-idx', i.toString())}
onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
onDrop={(e) => {
e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '1';
const src = parseInt(e.dataTransfer.getData('add-coach-idx'));
if (src === i) return;
const reordered = [...addCoachRows];
const [moved] = reordered.splice(src, 1);
reordered.splice(i, 0, moved);
setAddCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
}}
className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
>
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
<select className="input input-sm flex-1" value={row.coachId}
onChange={(e) => { const u = [...addCoachRows]; u[i] = { ...u[i], coachId: e.target.value }; setAddCoachRows(u); }}>
<option value="">Select Coach</option>
{coaches.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId).map((c: Coach) => (
<option key={c.id} value={c.id}>{c.number || c.coachNumber} {c.coachType?.name} (Cap: {c.capacity})</option>
))}
</select>
<button type="button" onClick={() => setAddCoachRows(addCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
<X className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
</div>
</form>
</Modal>
<Modal
isOpen={showModal}
onClose={() => {
@@ -561,8 +765,8 @@ export default function SchedulesPage() {
durationHours: '12',
repeatEveryDays: '1',
forNextDays: '30',
coachIds: [],
});
setBulkCoachRows([]);
}}
title="Bulk Generate Schedules"
size="lg"
@@ -611,7 +815,7 @@ export default function SchedulesPage() {
</div>
<div>
<label className="label">Start Date & Time *</label>
<label className="label">Departure Date & Time *</label>
<input
type="datetime-local"
value={bulkForm.startDateTime}
@@ -627,7 +831,7 @@ export default function SchedulesPage() {
<input
type="number"
min="1"
value={bulkForm.durationHours}
placeholder={bulkForm.durationHours}
onChange={(e) => setBulkForm({ ...bulkForm, durationHours: e.target.value })}
className="input"
/>
@@ -638,7 +842,7 @@ export default function SchedulesPage() {
<input
type="number"
min="1"
value={bulkForm.repeatEveryDays}
placeholder={bulkForm.repeatEveryDays}
onChange={(e) => setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })}
className="input"
/>
@@ -649,61 +853,91 @@ export default function SchedulesPage() {
<input
type="number"
min="1"
value={bulkForm.forNextDays}
placeholder={bulkForm.forNextDays}
onChange={(e) => setBulkForm({ ...bulkForm, forNextDays: e.target.value })}
className="input"
/>
</div>
</div>
<div>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<label className="label">Coaches (Optional)</label>
<button
type="button"
onClick={() => {
if (bulkForm.coachIds.length === coaches.length) {
setBulkForm({ ...bulkForm, coachIds: [] });
} else {
setBulkForm({ ...bulkForm, coachIds: coaches.map((c: Coach) => c.id) });
}
}}
className="text-xs text-primary hover:underline"
>
{bulkForm.coachIds.length === coaches.length ? 'Deselect All' : 'Select All'}
</button>
<label className="label mb-0">Coaches</label>
<div className="flex items-center gap-3">
{templateLoading && bulkForm.routeId && (
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template</span>
)}
{!bulkForm.routeId && (
<span className="text-xs text-muted-foreground">Select a route to load its coach template</span>
)}
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
disabled={bulkCoachRows.filter(r => r.coachId).length >= coaches.length}
onClick={() => setBulkCoachRows([...bulkCoachRows, { coachId: '', positionNumber: bulkCoachRows.length + 1 }])}>
Add Coach
</ActionButton>
</div>
</div>
<div className="border border-border rounded-lg p-3 max-h-64 overflow-y-auto space-y-2">
{coaches.length === 0 ? (
<p className="text-sm text-muted-foreground">No coaches available</p>
) : (
coaches.map((coach: Coach) => (
<label key={coach.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={bulkForm.coachIds.includes(coach.id)}
onChange={(e) => {
if (e.target.checked) {
setBulkForm({
...bulkForm,
coachIds: [...bulkForm.coachIds, coach.id],
});
} else {
setBulkForm({
...bulkForm,
coachIds: bulkForm.coachIds.filter((id) => id !== coach.id),
});
}
{bulkCoachRows.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">No coaches assigned schedules will be created without coach assignments.</p>
) : (
<div className="space-y-2">
{bulkCoachRows.length > 1 && (
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>
)}
{bulkCoachRows.map((row, i) => {
const selectedIds = new Set(bulkCoachRows.map((r) => r.coachId).filter(Boolean));
return (
<div
key={i}
draggable
onDragStart={(e) => e.dataTransfer.setData('bulk-coach-idx', i.toString())}
onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
onDrop={(e) => {
e.preventDefault();
(e.currentTarget as HTMLElement).style.opacity = '1';
const src = parseInt(e.dataTransfer.getData('bulk-coach-idx'));
if (src === i) return;
const reordered = [...bulkCoachRows];
const [moved] = reordered.splice(src, 1);
reordered.splice(i, 0, moved);
setBulkCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
}}
className="rounded"
/>
<span className="text-sm">
{coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity})
</span>
</label>
))
)}
</div>
className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
>
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
<select
className="input input-sm flex-1"
value={row.coachId}
onChange={(e) => {
const updated = [...bulkCoachRows];
updated[i] = { ...updated[i], coachId: e.target.value };
setBulkCoachRows(updated);
}}
>
<option value="">Select Coach</option>
{coaches
.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId)
.map((c: Coach) => (
<option key={c.id} value={c.id}>
{c.number || c.coachNumber} {c.coachType?.name} (Cap: {c.capacity})
</option>
))}
</select>
<button
type="button"
onClick={() => setBulkCoachRows(bulkCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))}
className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0"
>
<X className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
</div>
<div className="bg-blue-50 p-4 rounded-lg">
@@ -713,10 +947,10 @@ export default function SchedulesPage() {
schedules, starting from the specified date, repeating every{' '}
<strong>{bulkForm.repeatEveryDays}</strong> days for the next{' '}
<strong>{bulkForm.forNextDays}</strong> days.
{bulkForm.coachIds.length > 0 && (
{bulkCoachRows.filter(r => r.coachId).length > 0 && (
<>
{' '}
Each schedule will have <strong>{bulkForm.coachIds.length}</strong> coach(es) assigned.
Each schedule will have <strong>{bulkCoachRows.filter(r => r.coachId).length}</strong> coach(es) assigned.
</>
)}
</p>
@@ -736,8 +970,8 @@ export default function SchedulesPage() {
durationHours: '12',
repeatEveryDays: '1',
forNextDays: '30',
coachIds: [],
});
setBulkCoachRows([]);
}}
>
Cancel
@@ -756,7 +990,7 @@ export default function SchedulesPage() {
setEditingSchedule(null);
setError(null);
}}
title={`Edit Schedule - ${editingSchedule?.train?.name} (${editingSchedule?.train?.number})`}
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} ${editingSchedule?.destinationStation?.name ?? ''}`}
size="lg"
>
{editingSchedule && (

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function TariffRatesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,428 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const;
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
const COACH_TYPE_LABELS: Record<string, string> = {
HSC: 'Regular Seat (Hard Seat)',
HBC: 'Economy Bed (Hard Berth)',
SBC: 'VIP Bed (Soft Berth)',
};
// Tariff reference rates per the official policy document
const TARIFF_REFERENCE: Record<string, Record<string, number>> = {
LOCAL: {
'HSC-null': 0.03,
'HBC-UPPER': 0.04,
'HBC-MIDDLE': 0.055,
'HBC-LOWER': 0.06,
'SBC-UPPER': 0.075,
'SBC-LOWER': 0.08,
},
INTERNATIONAL: {
'HSC-null': 0.06,
'HBC-UPPER': 0.08,
'HBC-MIDDLE': 0.11,
'HBC-LOWER': 0.12,
'SBC-UPPER': 0.15,
'SBC-LOWER': 0.16,
},
};
function getTariffRef(nationalityType: string, coachCode: string, bedPosition: string | null) {
const key = `${coachCode}-${bedPosition ?? 'null'}`;
return TARIFF_REFERENCE[nationalityType]?.[key];
}
export default function TariffRatesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const [formError, setFormError] = useState<string | null>(null);
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
const [selectedBedPosition, setSelectedBedPosition] = useState<string>('');
const [selectedNationalityType, setSelectedNationalityType] = useState<string>('LOCAL');
const queryClient = useQueryClient();
const { data: classesData, isLoading } = useQuery({
queryKey: ['seat-classes'],
queryFn: () => apiClient.get<any>('/seat-classes'),
});
const { data: coachTypesData } = useQuery({
queryKey: ['coach-types'],
queryFn: () => apiClient.get<any>('/fleet/coach-types'),
});
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/seat-classes', data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); },
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/seat-classes/${id}`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); },
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/seat-classes/${id}`),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); setDeleteConfirm({ isOpen: false, item: null }); },
onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Delete failed' })),
});
const closeModal = () => {
setShowModal(false);
setEditingClass(null);
setFormError(null);
setSelectedCoachTypeId('');
setSelectedBedPosition('');
setSelectedNationalityType('LOCAL');
};
const openEdit = (cls: any) => {
setEditingClass(cls);
setSelectedCoachTypeId(cls.coachTypeId || '');
setSelectedBedPosition(cls.bedPosition || '');
setSelectedNationalityType(cls.nationalityType || 'LOCAL');
setFormError(null);
setShowModal(true);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setFormError(null);
const fd = new FormData(e.currentTarget);
const payload: any = {
coachTypeId: selectedCoachTypeId,
name: fd.get('name') as string,
nationalityType: selectedNationalityType,
bedPosition: selectedBedPosition || null,
baseFareMinor: parseInt(fd.get('baseFareMinor') as string),
isActive: fd.get('isActive') === 'true',
};
if (editingClass) {
await updateMutation.mutateAsync({ id: editingClass.id, data: payload });
} else {
await createMutation.mutateAsync(payload);
}
};
const coachTypesArray: any[] = Array.isArray(coachTypesData)
? coachTypesData
: (coachTypesData as any)?.data || (coachTypesData as any)?.items || [];
const allClasses: any[] = Array.isArray(classesData)
? classesData
: (classesData as any)?.items || (classesData as any)?.data || [];
// Only show classes that have nationalityType set (tariff-managed rows)
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
const displayed = tariffClasses.filter((c: any) => {
if (!search) return true;
const s = search.toLowerCase();
return (
c.name?.toLowerCase().includes(s) ||
c.nationalityType?.toLowerCase().includes(s) ||
c.bedPosition?.toLowerCase().includes(s) ||
c.coachType?.name?.toLowerCase().includes(s)
);
});
// Auto-suggest name from selections
const suggestName = () => {
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
if (!ct) return '';
const label = COACH_TYPE_LABELS[ct.code] || ct.name;
const pos = selectedBedPosition ? ` ${selectedBedPosition.charAt(0) + selectedBedPosition.slice(1).toLowerCase()}` : '';
const nat = selectedNationalityType === 'LOCAL' ? 'Local' : 'Intl';
return `${label}${pos} (${nat})`;
};
// Auto-suggest baseFareMinor from tariff reference
const suggestRate = () => {
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
if (!ct) return '';
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
// baseFareMinor = tariff_decimal × 100000
return ref ? Math.round(ref * 100000).toString() : '';
};
const columns = [
{
key: 'nationalityType', label: 'Passenger Type',
render: (c: any) => (
<Badge variant="status" status={c.nationalityType === 'LOCAL' ? 'CONFIRMED' : 'INFO'}>
{c.nationalityType === 'LOCAL' ? 'Local' : 'International'}
</Badge>
),
},
{
key: 'coachType', label: 'Coach Type',
render: (c: any) => <span className="text-sm">{c.coachType?.name || c.coachTypeId}</span>,
},
{
key: 'bedPosition', label: 'Berth Position',
render: (c: any) => c.bedPosition
? <span className="font-mono text-sm">{c.bedPosition}</span>
: <span className="text-muted-foreground text-xs">Standard</span>,
},
{
key: 'name', label: 'Class Name',
render: (c: any) => <span className="font-medium">{c.name}</span>,
},
{
key: 'baseFareMinor', label: 'Rate per km (minor)',
render: (c: any) => {
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
const tariffMinor = ref ? Math.round(ref * 100000) : undefined;
const matches = tariffMinor === c.baseFareMinor;
return (
<div className="flex items-center gap-2">
<span className="font-mono font-medium">{c.baseFareMinor}</span>
{tariffMinor !== undefined && (
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
{matches ? '✓ tariff' : `tariff: ${tariffMinor}`}
</span>
)}
</div>
);
},
},
{
key: 'isActive', label: 'Status',
render: (c: any) => (
<Badge variant="status" status={c.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{c.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const actions = [
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: openEdit },
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (c: any) => setDeleteConfirm({ isOpen: true, item: c }),
},
];
const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC';
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
<p className="text-muted-foreground">
Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy
</p>
</div>
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
Add Rate
</ActionButton>
</div>
{/* Tariff reference card */}
<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-2">Official Tariff Formula</h3>
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono">
Fare = KM × rate × 1.02 × ExchangeRate
</p>
<p className="text-xs text-blue-700 dark:text-blue-400 mt-1">
Rate is stored as <strong>baseFareMinor = tariff_decimal × 100,000</strong> (e.g. 0.03 3000). The ×1.02 insurance coefficient is applied automatically by the fare engine.
</p>
</div>
<div className="card">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, nationality, berth position..."
className="input pl-10 w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<DataTable
data={displayed}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
/>
</div>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
onConfirm={() => deleteMutation.mutate(deleteConfirm.item?.id)}
title="Delete Tariff Rate"
message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
error={deleteConfirm.error}
warning="Bookings in progress may be affected. Ensure a replacement rate exists."
/>
<Modal
isOpen={showModal}
onClose={closeModal}
title={`${editingClass ? 'Edit' : 'Add'} Tariff Rate`}
size="lg"
>
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">
{formError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Passenger Nationality *</label>
<select
className="input"
value={selectedNationalityType}
onChange={(e) => setSelectedNationalityType(e.target.value)}
required
>
<option value="LOCAL">Local (Ethiopian / Djiboutian)</option>
<option value="INTERNATIONAL">International (Foreign nationals)</option>
</select>
</div>
<div>
<label className="label">Coach Type *</label>
<select
className="input"
value={selectedCoachTypeId}
onChange={(e) => { setSelectedCoachTypeId(e.target.value); setSelectedBedPosition(''); }}
required
>
<option value="">Select coach type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} {ct.name}
</option>
))}
</select>
</div>
{isBedCoach && (
<div>
<label className="label">Berth Position *</label>
<select
className="input"
value={selectedBedPosition}
onChange={(e) => setSelectedBedPosition(e.target.value)}
required={isBedCoach}
>
<option value="">Select berth position</option>
{(selectedCoachType?.code === 'HBC'
? BED_POSITIONS
: (['UPPER', 'LOWER'] as const)
).map((pos) => (
<option key={pos} value={pos}>{pos}</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'}
</p>
</div>
)}
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingClass?.name || ''}
key={editingClass?.id ?? `new-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
placeholder={suggestName() || 'e.g. Economy Bed Upper (Local)'}
required
/>
{!editingClass && suggestName() && (
<p className="text-xs text-muted-foreground mt-1">
Suggested:{' '}
<button
type="button"
className="text-primary underline"
onClick={(e) => {
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=name]') as HTMLInputElement);
if (inp) inp.value = suggestName();
}}
>
{suggestName()}
</button>
</p>
)}
</div>
<div>
<label className="label">Base Fare Minor (per km) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor ?? ''}
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
placeholder={suggestRate() || 'e.g. 3000'}
min="0"
required
/>
{suggestRate() && (
<p className="text-xs text-muted-foreground mt-1">
Official tariff rate:{' '}
<button
type="button"
className="text-primary underline"
onClick={(e) => {
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=baseFareMinor]') as HTMLInputElement);
if (inp) inp.value = suggestRate();
}}
>
{suggestRate()}
</button>
{' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km)
</p>
)}
</div>
<div>
<label className="label">Status</label>
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={closeModal}>Cancel</ActionButton>
<ActionButton type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{editingClass ? 'Update' : 'Create'} Rate
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -761,9 +761,12 @@ export default function TicketsPage() {
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
</div>
<div className="text-right shrink-0">
<div className="text-right shrink-0 flex flex-col items-end gap-1">
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${t.qrCode ? 'bg-emerald-200 text-emerald-900' : 'bg-white/20 text-white/60'}`}>
{t.qrCode ? '✓ QR Available' : 'No QR'}
</span>
{t.validatedAt && <p className="text-emerald-200 text-xs">Validated {formatDateTime(t.validatedAt)}</p>}
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
@@ -835,13 +838,29 @@ export default function TicketsPage() {
</section>
)}
{/* QR Code */}
{t.qrCode && (
<section>
<SectionHeader title="QR Code" />
<div className="flex justify-center">
<div className="bg-white p-4 rounded-xl border border-muted inline-block">
<img
src={t.qrCode.startsWith('data:') ? t.qrCode : `data:image/png;base64,${t.qrCode}`}
alt={`QR Code for ${t.ticketNumber}`}
className="w-48 h-48 object-contain"
/>
<p className="text-center text-xs text-muted-foreground mt-2 font-mono">{t.ticketNumber}</p>
</div>
</div>
</section>
)}
{/* Validation */}
<section>
<SectionHeader title="Validation & Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
<Field label="Created" value={formatDateTime(t.createdAt)} />
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
<Field label="Ticket ID" value={t.id} mono truncate />

View File

@@ -89,7 +89,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Financial',
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
// { name: 'Configurable Fares', href: '/fare-management', icon: Settings, permission: PERMS.admin },
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin },
{ name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },

View File

@@ -149,7 +149,7 @@ export default function DataTable<T extends Record<string, any>>({
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{sortedData.map((item, index) => (
<tr
key={item.id || index}
key={item.id ?? index}
onClick={() => onRowClick?.(item)}
className={cn(
'transition-colors',
@@ -186,15 +186,16 @@ export default function DataTable<T extends Record<string, any>>({
return (
<button
ref={(el) => { buttonRefs.current[item.id] = el; }}
ref={(el) => { buttonRefs.current[item.id ?? index] = el; }}
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right + window.scrollX - 192, // 192px = w-48
left: rect.right + window.scrollX - 192,
});
setExpandedActions(expandedActions === item.id ? null : item.id);
const key = item.id ?? String(index);
setExpandedActions(expandedActions === key ? null : key);
}}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
@@ -229,9 +230,9 @@ export default function DataTable<T extends Record<string, any>>({
>
<div className="py-1">
{actions
?.filter(action => !action.show || action.show(sortedData.find(item => item.id === expandedActions)!))
?.filter(action => !action.show || action.show(sortedData.find((item, i) => (item.id ?? String(i)) === expandedActions)!))
.map((action, actionIndex) => {
const item = sortedData.find(item => item.id === expandedActions);
const item = sortedData.find((item, i) => (item.id ?? String(i)) === expandedActions);
if (!item) return null;
return (
<button

View File

@@ -442,6 +442,14 @@ export const excessBaggageApi = {
delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`),
};
// Route Coach Templates API
export const routeCoachTemplatesApi = {
get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`),
set: (routeId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
apiClient.put<any>(`/routes/${routeId}/coaches`, { coaches }),
clear: (routeId: string) => apiClient.delete(`/routes/${routeId}/coaches`),
};
// System Config API
export const systemConfigApi = {
getAll: () => apiClient.get<Record<string, string>>('/config'),

View File

@@ -1,9 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
transpilePackages: ['@edr/types', '@edr/ui-common'],
images: {
unoptimized: true,
unoptimized: false,
},
};

View File

@@ -260,11 +260,23 @@ export default function ReviewPage() {
if (localStoragePassengerId) passengerId = localStoragePassengerId;
}
// Fallback 3: Use passengerId from user object
// Fallback 3: Use passengerId from user object. The profile response nests
// it under `passenger.id`, while the login response exposes it top-level as
// `passengerId` — accept either shape so a cached profile user still resolves.
if (!passengerId && user) {
passengerId = (user as any).passengerId;
passengerId = (user as any).passengerId || (user as any).passenger?.id;
}
// Fallback 4: last resort — fetch the passenger profile directly.
if (!passengerId) {
try {
const me: any = await apiClient.get('/passengers/me');
passengerId = me?.id || me?.passengerId || '';
} catch (err) {
console.error('Failed to resolve passengerId from /passengers/me:', err);
}
}
if (!passengerId) {
throw new Error('Passenger ID not found in authentication token. Please log in again.');
}

View File

@@ -30,6 +30,18 @@ services:
- apps/edr-passenger-api/.env
extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/health/ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
freight-portal:
build:
context: .
@@ -72,6 +84,18 @@ services:
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
env_file:
- apps/edr-passenger-web/portal/.env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_PORTAL_PORT:-5174}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
passenger-backoffice:
build:
context: .
@@ -86,6 +110,18 @@ services:
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
env_file:
- apps/edr-passenger-web/backoffice/.env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_BACKOFFICE_PORT:-5184}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
payment-api:
build:

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
#
# Next.js Dockerfile for passenger web (portal + backoffice).
# Builds with turbo, runs with Node.js (not nginx).
# Builds with turbo, runs with Node.js standalone output.
#
# Build example (portal):
# DOCKER_BUILDKIT=1 docker build \
@@ -16,8 +16,6 @@ ARG PORT=5174
ARG NEXT_PUBLIC_API_URL
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -46,20 +44,30 @@ RUN if [ -z "$NEXT_PUBLIC_API_URL" ]; then \
COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter="${APP_PACKAGE}..."
FROM base AS deployer
ARG APP_PACKAGE
COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy
# Runner: copy the standalone output produced by Next.js.
# With output:'standalone' in a pnpm monorepo, Next.js emits:
# .next/standalone/ <- shared workspace node_modules
# .next/standalone/<APP_PATH>/ <- app server.js + app-level node_modules
# .next/static/ <- hashed client assets
# public/ <- static public files
# We copy the full standalone tree to /app, then WORKDIR into the app
# subdirectory so `node server.js` resolves correctly.
FROM node:24.15.0-alpine AS runner
ARG APP_PATH
ARG PORT=5174
ENV PORT=${PORT}
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deployer /deploy .
COPY --from=builder /app/${APP_PATH}/.next .next
COPY --from=builder /app/${APP_PATH}/public public
USER node
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# Copy the entire standalone tree (includes root node_modules symlinks)
COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/.next/standalone ./
# Overlay the hashed static assets and public files at the path Next.js expects
COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/.next/static ./${APP_PATH}/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/public ./${APP_PATH}/public
USER nextjs
# server.js lives at <APP_PATH>/server.js inside the standalone tree
WORKDIR /app/${APP_PATH}
EXPOSE ${PORT}
CMD ["npx", "next", "start"]
CMD ["node", "server.js"]

View File

@@ -4,10 +4,24 @@ server {
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/wasm;
gzip_min_length 1024;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../button";
import type { ViewableFile } from "../FileViewer";
export interface SmartFileInputProps {
/** The settings object containing features and their upload fields config. */
@@ -27,6 +28,26 @@ export interface SmartFileInputProps {
* no in-memory File is currently selected for them.
*/
uploadedKeys?: string[];
/**
* Metadata for already-uploaded files, keyed by fileKey. When a field has
* entries here, they're listed with view/download links (instead of the
* generic placeholder text) and the field counts as uploaded even if it's
* not also listed in `uploadedKeys`.
*/
existingFiles?: Record<
string,
{ name: string; url: string; size?: number; mimeType?: string | null }[]
>;
/**
* When provided, already-uploaded files render as buttons that call this with
* the file instead of opening a new browser tab. Wire it to `useFileViewer`'s
* `view` to preview documents inline:
*
* const { view, viewer } = useFileViewer();
* <SmartFileInput ... onViewFile={view} />
* {viewer}
*/
onViewFile?: (file: ViewableFile) => void;
/** Disabled state for the entire file input group. */
disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
@@ -70,12 +91,76 @@ function FileIcon({ name, className }: { name: string; className?: string }) {
return <File className={cn("text-slate-400", className)} />;
}
type ExistingFile = {
name: string;
url: string;
size?: number;
mimeType?: string | null;
};
/**
* A single already-uploaded file. Renders a click-to-view button when
* `onViewFile` is set (inline preview via the FileViewer), otherwise a plain
* new-tab anchor.
*/
function ExistingFileLink({
file: f,
onViewFile,
className,
showSize = false,
}: {
file: ExistingFile;
onViewFile?: (file: ViewableFile) => void;
className?: string;
showSize?: boolean;
}) {
const label =
showSize && typeof f.size === "number"
? `${f.name} (${formatBytes(f.size)})`
: f.name;
if (onViewFile) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewFile({ name: f.name, url: f.url, mimeType: f.mimeType });
}}
className={cn(
"relative z-10 text-left text-xs text-primary hover:underline truncate max-w-xs",
className,
)}
>
{label}
</button>
);
}
return (
<a
href={f.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={cn(
"relative z-10 text-xs text-primary hover:underline truncate max-w-xs",
className,
)}
>
{label}
</a>
);
}
export function SmartFileInput({
file,
value,
onChange,
errors,
uploadedKeys,
existingFiles,
onViewFile,
disabled = false,
variant = "default",
className,
@@ -270,9 +355,11 @@ export function SmartFileInput({
const fieldError =
errors?.[field.fileKey] || localErrors[field.fileKey];
const isDragOver = dragActive[field.fileKey];
const existingForField = existingFiles?.[field.fileKey] ?? [];
// Already uploaded server-side and nothing newly picked to replace it.
const isUploaded =
(uploadedKeys?.includes(field.fileKey) ?? false) &&
((uploadedKeys?.includes(field.fileKey) ?? false) ||
existingForField.length > 0) &&
currentFiles.length === 0;
// Format accepted files for the HTML input element
@@ -417,6 +504,17 @@ export function SmartFileInput({
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
</span>
{existingForField.length > 0 && (
<div className="flex flex-col gap-1 basis-full">
{existingForField.map((f, idx) => (
<ExistingFileLink
key={`${f.url}-${idx}`}
file={f}
onViewFile={onViewFile}
/>
))}
</div>
)}
</div>
) : isUploaded ? (
// Uploaded state: a solid success panel that still doubles as a
@@ -431,7 +529,7 @@ export function SmartFileInput({
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input
@@ -457,11 +555,24 @@ export function SmartFileInput({
<p className="text-sm font-semibold text-foreground">
{isDragOver ? "Drop to replace" : "Document uploaded"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{isDragOver
? "Release to replace the document on file."
: "Saved to your application. Drag a new file here or click to replace it."}
</p>
{existingForField.length > 0 ? (
<div className="mt-0.5 flex flex-col gap-0.5">
{existingForField.map((f, idx) => (
<ExistingFileLink
key={`${f.url}-${idx}`}
file={f}
onViewFile={onViewFile}
showSize
/>
))}
</div>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{isDragOver
? "Release to replace the document on file."
: "Saved to your application. Drag a new file here or click to replace it."}
</p>
)}
</div>
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
@@ -480,9 +591,9 @@ export function SmartFileInput({
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError &&
"border-destructive hover:border-destructive/80",
"border-destructive hover:border-destructive/80",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input
@@ -534,4 +645,4 @@ export function SmartFileInput({
);
}
export default SmartFileInput;
export default SmartFileInput;

View File

@@ -0,0 +1,27 @@
import { useCallback, useState } from "react";
import { FileViewerModal, type ViewableFile } from "../components/FileViewer";
/**
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
* from any file row to open the document inline (pdf / image / video / office /
* text); render `viewer` once near the page root.
*
* const { view, viewer } = useFileViewer();
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
* {viewer}
*
* Pass `view` straight into `SmartFileInput`'s `onViewFile` prop to make its
* already-uploaded files open in the viewer instead of a new tab.
*/
export function useFileViewer() {
const [file, setFile] = useState<ViewableFile | null>(null);
const view = useCallback((f: ViewableFile) => setFile(f), []);
const close = useCallback(() => setFile(null), []);
const viewer = (
<FileViewerModal open={file !== null} file={file} onClose={close} />
);
return { view, close, viewer };
}

View File

@@ -20,6 +20,8 @@ export type {
ViewableFile,
} from "./components/FileViewer";
export { useFileViewer } from "./hooks/useFileViewer";
export { OperationDatePicker } from "./components/OperationDatePicker";
export type { OperationDatePickerProps } from "./components/OperationDatePicker";

19
pnpm-lock.yaml generated
View File

@@ -525,6 +525,9 @@ importers:
express:
specifier: ^4.18.2
version: 4.22.2
helmet:
specifier: ^8.0.0
version: 8.2.0
jose:
specifier: ^5.10.0
version: 5.10.0
@@ -7107,6 +7110,10 @@ packages:
headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
helmet@8.2.0:
resolution: {integrity: sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==}
engines: {node: '>=18.0.0'}
helper-date@1.0.1:
resolution: {integrity: sha512-wU3VOwwTJvGr/w5rZr3cprPHO+hIhlblTJHD6aFBrKLuNbf4lAmkawd2iK3c6NbJEvY7HAmDpqjOFSI5/+Ey2w==}
engines: {node: '>=4.0'}
@@ -12625,10 +12632,10 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
'@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
'@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 9.3.0(react@19.2.6)
clsx: 2.1.1
dayjs: 1.11.21
react: 19.2.6
@@ -15532,7 +15539,7 @@ snapshots:
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -19080,6 +19087,8 @@ snapshots:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.0
helmet@8.2.0: {}
helper-date@1.0.1:
dependencies:
date.js: 0.3.3
@@ -20446,7 +20455,7 @@ snapshots:
mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tanstack/match-sorter-utils': 8.19.4