Merge pull request #178 from Tria-plc/freight/fix/fixes

Freight/fix/fixes
This commit is contained in:
Nathnael Wondisha
2026-06-16 16:19:14 +03:00
committed by GitHub
41 changed files with 1178 additions and 1032 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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