mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #178 from Tria-plc/freight/fix/fixes
Freight/fix/fixes
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/** Booking statuses that represent a delivered/finished shipment. */
|
||||
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
|
||||
|
||||
/**
|
||||
* Statuses that represent real, committed freight (excludes drafts and dead
|
||||
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
|
||||
*/
|
||||
const COMMITTED_STATUSES = [
|
||||
'APPROVED',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'COMPLETED',
|
||||
'DELIVERED',
|
||||
'CONSOLIDATED',
|
||||
] as const;
|
||||
|
||||
export interface CurrencyTotal {
|
||||
currency: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MonthlyTonnage {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
tonnes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only aggregation queries against the bookings table, scoped to a
|
||||
* company, that back the portal dashboard. Lives in the companies module so it
|
||||
* can be exposed via `companies.controller` without a circular dependency on
|
||||
* BookingsModule (which already imports CompaniesModule).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CompanyDashboardRepository {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookings: Repository<Booking>,
|
||||
) {}
|
||||
|
||||
/** Count of delivered/completed bookings for a company within [from, to). */
|
||||
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
||||
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
||||
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('b.payment_currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere("b.payment_status = 'PAID'")
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.groupBy('b.payment_currency')
|
||||
.getRawMany<{ currency: string; total: string }>();
|
||||
|
||||
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||
}
|
||||
|
||||
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
||||
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
const row = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getRawOne<{ total: string }>();
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Committed tonnage grouped by calendar month within [from, to). */
|
||||
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.groupBy('year')
|
||||
.addGroupBy('month')
|
||||
.getRawMany<{ year: string; month: string; total: string }>();
|
||||
|
||||
return rows.map((r) => ({
|
||||
year: Number(r.year),
|
||||
month: Number(r.month),
|
||||
tonnes: Number(r.total),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class FreightVolumePointDto {
|
||||
@ApiProperty({ example: 'May', description: 'Short month label' })
|
||||
month!: string;
|
||||
|
||||
@ApiProperty({ example: 940, description: 'Tonnage shipped in the month' })
|
||||
tonnes!: number;
|
||||
}
|
||||
|
||||
export class FreightVolumeDto {
|
||||
@ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' })
|
||||
totalTonnes!: number;
|
||||
|
||||
@ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' })
|
||||
totalValue!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' })
|
||||
ytdChangePct!: number;
|
||||
|
||||
@ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' })
|
||||
monthly!: FreightVolumePointDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* KPIs for the portal dashboard (MyPortalPage), aggregated from the current
|
||||
* user's company bookings. All figures are scoped to that company.
|
||||
*
|
||||
* Note: every metric here derives from the bookings table — there is no
|
||||
* separate "non-booking" data source for delivered/spend/volume. On-time
|
||||
* delivery rate is replaced by completion rate: no ETA / promised-delivery
|
||||
* column exists in the schema, so on-time cannot be computed, whereas
|
||||
* completion rate (delivered ÷ committed) can.
|
||||
*/
|
||||
export class DashboardSummaryResponseDto {
|
||||
@ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' })
|
||||
deliveredCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 92,
|
||||
description: 'Share of committed bookings that have been delivered/completed (YTD), in percent',
|
||||
})
|
||||
completionRate!: number;
|
||||
|
||||
@ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' })
|
||||
spendYtd!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
spendCurrency!: string;
|
||||
|
||||
@ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' })
|
||||
spendYtdChangePct!: number;
|
||||
|
||||
@ApiProperty({ type: FreightVolumeDto })
|
||||
freightVolume!: FreightVolumeDto;
|
||||
}
|
||||
@@ -173,7 +173,7 @@ const App = () => {
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<RequireCompany path={location.pathname} />}>
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
|
||||
@@ -147,7 +147,6 @@ export function AppLayout({
|
||||
flexShrink: 0,
|
||||
cursor: "pointer",
|
||||
};
|
||||
const toggleStyle: CSSProperties = { ...islandStyle, borderRadius: 10 };
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Button, FormField } from "@edr/ui-common";
|
||||
|
||||
import type { CreateBookingPayload } from "../../services/bookings.service";
|
||||
|
||||
export interface BookingFormProps {
|
||||
onSubmit: (payload: CreateBookingPayload) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const BookingForm = ({ onSubmit, isSubmitting }: BookingFormProps) => {
|
||||
const [reference, setReference] = useState("");
|
||||
const [customerId, setCustomerId] = useState("");
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [totalAmount, setTotalAmount] = useState("0");
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit({
|
||||
reference,
|
||||
customerId,
|
||||
scheduledDate,
|
||||
totalAmount: Number(totalAmount),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<FormField
|
||||
label="Reference"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Customer ID"
|
||||
value={customerId}
|
||||
onChange={(e) => setCustomerId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Scheduled date"
|
||||
type="date"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Total amount"
|
||||
type="number"
|
||||
value={totalAmount}
|
||||
onChange={(e) => setTotalAmount(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Create booking
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingForm;
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Table, type TableColumn } from "@edr/ui-common";
|
||||
|
||||
export interface BookingTableProps {
|
||||
bookings: Freight.IBooking[];
|
||||
}
|
||||
|
||||
const columns: TableColumn<Freight.IBooking>[] = [
|
||||
{ key: "reference", header: "Reference" },
|
||||
{ key: "customerId", header: "Customer" },
|
||||
{ key: "status", header: "Status" },
|
||||
{
|
||||
key: "scheduledDate",
|
||||
header: "Scheduled",
|
||||
render: (row) => new Date(row.scheduledDate).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
key: "totalAmount",
|
||||
header: "Total",
|
||||
render: (row) => row.totalAmount.toFixed(2),
|
||||
},
|
||||
];
|
||||
|
||||
const BookingTable = ({ bookings }: BookingTableProps) => (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={bookings}
|
||||
rowKey={(row) => row.id}
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
);
|
||||
|
||||
export default BookingTable;
|
||||
@@ -1,81 +0,0 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Button, FormField } from "@edr/ui-common";
|
||||
|
||||
export interface ConsignmentFormProps {
|
||||
onSubmit: (payload: {
|
||||
bookingId: string;
|
||||
trackingNumber: string;
|
||||
cargoType: string;
|
||||
weightKg: number;
|
||||
originStation: string;
|
||||
destinationStation: string;
|
||||
}) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const ConsignmentForm = ({ onSubmit, isSubmitting }: ConsignmentFormProps) => {
|
||||
const [bookingId, setBookingId] = useState("");
|
||||
const [trackingNumber, setTrackingNumber] = useState("");
|
||||
const [cargoType, setCargoType] = useState("GENERAL");
|
||||
const [weightKg, setWeightKg] = useState("0");
|
||||
const [originStation, setOriginStation] = useState("");
|
||||
const [destinationStation, setDestinationStation] = useState("");
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit({
|
||||
bookingId,
|
||||
trackingNumber,
|
||||
cargoType,
|
||||
weightKg: Number(weightKg),
|
||||
originStation,
|
||||
destinationStation,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<FormField
|
||||
label="Booking ID"
|
||||
value={bookingId}
|
||||
onChange={(e) => setBookingId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Tracking #"
|
||||
value={trackingNumber}
|
||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Cargo type"
|
||||
value={cargoType}
|
||||
onChange={(e) => setCargoType(e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
label="Weight (kg)"
|
||||
type="number"
|
||||
value={weightKg}
|
||||
onChange={(e) => setWeightKg(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
<FormField
|
||||
label="Origin station"
|
||||
value={originStation}
|
||||
onChange={(e) => setOriginStation(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Destination station"
|
||||
value={destinationStation}
|
||||
onChange={(e) => setDestinationStation(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Create consignment
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsignmentForm;
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Table, type TableColumn } from "@edr/ui-common";
|
||||
|
||||
export interface ConsignmentTableProps {
|
||||
consignments: Freight.IConsignment[];
|
||||
}
|
||||
|
||||
const columns: TableColumn<Freight.IConsignment>[] = [
|
||||
{ key: "trackingNumber", header: "Tracking #" },
|
||||
{ key: "cargoType", header: "Cargo" },
|
||||
{ key: "status", header: "Status" },
|
||||
{ key: "originStation", header: "Origin" },
|
||||
{ key: "destinationStation", header: "Destination" },
|
||||
{
|
||||
key: "weightKg",
|
||||
header: "Weight (kg)",
|
||||
render: (row) => row.weightKg.toFixed(2),
|
||||
},
|
||||
];
|
||||
|
||||
const ConsignmentTable = ({ consignments }: ConsignmentTableProps) => (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={consignments}
|
||||
rowKey={(row) => row.id}
|
||||
emptyMessage="No consignments yet"
|
||||
/>
|
||||
);
|
||||
|
||||
export default ConsignmentTable;
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { 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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { bookingsService } from "../services/bookings.service";
|
||||
|
||||
export const useBookings = () =>
|
||||
useQuery({
|
||||
queryKey: ["bookings"],
|
||||
queryFn: bookingsService.list,
|
||||
});
|
||||
|
||||
export const useBooking = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: ["bookings", id],
|
||||
queryFn: () => bookingsService.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { consignmentsService } from "../services/consignments.service";
|
||||
|
||||
export const useConsignments = () =>
|
||||
useQuery({
|
||||
queryKey: ["consignments"],
|
||||
queryFn: consignmentsService.list,
|
||||
});
|
||||
|
||||
export const useConsignment = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: ["consignments", id],
|
||||
queryFn: () => consignmentsService.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { customersService } from "@/services/customers.service";
|
||||
import type {
|
||||
CreateCustomerDto,
|
||||
UpdateCustomerDto,
|
||||
} from "@/types/customers";
|
||||
|
||||
const KEY = ["customers"] as const;
|
||||
|
||||
export const useCustomers = () =>
|
||||
useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: customersService.list,
|
||||
});
|
||||
|
||||
export const useCustomer = (id: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: [...KEY, "id", id],
|
||||
queryFn: () => customersService.getById(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
export const useCreateCustomer = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateCustomerDto) => customersService.create(dto),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCustomer = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) =>
|
||||
customersService.update(id, dto),
|
||||
onSuccess: (_data, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCustomer = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => customersService.remove(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
||||
import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
|
||||
import { shipments, type Shipment } from "@/pages/tracking/shipments.mock";
|
||||
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
||||
import { 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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Banknote,
|
||||
Building2,
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PaymentMethod } from "@/services/payments.service";
|
||||
|
||||
interface ProviderOption {
|
||||
method: PaymentMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "TELEBIRR",
|
||||
label: "telebirr",
|
||||
description: "Ethiopian mobile money",
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
method: "CBE_BIRR",
|
||||
label: "CBE Birr",
|
||||
description: "Commercial Bank of Ethiopia",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
method: "EBIRR",
|
||||
label: "E-Birr",
|
||||
description: "Electronic payment gateway",
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
method: "WAAFI",
|
||||
label: "WAAFI",
|
||||
description: "Djibouti mobile money",
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
method: "CARD",
|
||||
label: "Card",
|
||||
description: "Visa / Mastercard",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
method: "DMONEY",
|
||||
label: "D-Money",
|
||||
description: "Djibouti D-money",
|
||||
icon: Banknote,
|
||||
},
|
||||
{
|
||||
method: "CAC_BANK",
|
||||
label: "CAC Bank",
|
||||
description: "CAC Int Bank (OTP)",
|
||||
icon: Building2,
|
||||
},
|
||||
];
|
||||
|
||||
function ProviderRow({
|
||||
option,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
option: ProviderOption;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Group
|
||||
onClick={onSelect}
|
||||
gap={12}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 12,
|
||||
padding: "13px 14px",
|
||||
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
|
||||
backgroundColor: selected ? "#ECF6F1" : "#fff",
|
||||
transition: "border-color .12s, background-color .12s",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 10,
|
||||
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
|
||||
color: selected ? "#fff" : "#475569",
|
||||
}}
|
||||
>
|
||||
<Icon size={19} />
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text fz="14px" fw={700} c="#10202F">
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text fz="12.5px" c="#9AA8B5">
|
||||
{option.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
|
||||
backgroundColor: selected ? "#0A6F4D" : "transparent",
|
||||
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentMethodModal({
|
||||
opened,
|
||||
onClose,
|
||||
amountLabel,
|
||||
onConfirm,
|
||||
processing,
|
||||
error,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Human-readable total, e.g. "ETB 12,500". */
|
||||
amountLabel?: string;
|
||||
onConfirm: (method: PaymentMethod) => void;
|
||||
processing?: boolean;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const [method, setMethod] = useState<PaymentMethod | null>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
size={460}
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Text fw={800} fz="17px" c="#10202F">
|
||||
Choose a payment method
|
||||
</Text>
|
||||
{amountLabel && (
|
||||
<Text fz="12.5px" c="#9AA8B5">
|
||||
Amount due: {amountLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Stack gap={10}>
|
||||
{PROVIDERS.map((option) => (
|
||||
<ProviderRow
|
||||
key={option.method}
|
||||
option={option}
|
||||
selected={method === option.method}
|
||||
onSelect={() => setMethod(option.method)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<Text fz="12.5px" c="#C0392B" fw={600}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
mt={6}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
disabled={!method || processing}
|
||||
loading={processing}
|
||||
onClick={() => method && onConfirm(method)}
|
||||
styles={{
|
||||
root: { height: 46 },
|
||||
label: { fontSize: 14, fontWeight: 800 },
|
||||
}}
|
||||
>
|
||||
{processing ? "Redirecting…" : "Continue to payment"}
|
||||
</Button>
|
||||
<Text fz="11.5px" c="#9AA8B5" ta="center">
|
||||
You'll be redirected to your provider to complete payment securely.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import BookingForm from "../../components/bookings/BookingForm";
|
||||
import { bookingsService } from "../../services/bookings.service";
|
||||
|
||||
const CreateBookingPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bookingsService.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["bookings"] });
|
||||
navigate("/bookings");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<h1 className="mb-4 text-2xl font-semibold text-gray-900">New booking</h1>
|
||||
<BookingForm
|
||||
onSubmit={mutation.mutate}
|
||||
isSubmitting={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateBookingPage;
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
initialBookingFormValues,
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Field, FieldError, Input, Switch } from "@edr/ui-common";
|
||||
import { type BookingFormValues } from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step3FirstLastMile({ form }: { form: BookingForm }) {
|
||||
const firstMileEnabled = form.watch("firstMileEnabled");
|
||||
const lastMileEnabled = form.watch("lastMileEnabled");
|
||||
const equipmentReturn = form.watch("equipmentReturn");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="First & Last Mile"
|
||||
description="Configure trucking and container return options."
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-border rounded-xl border border-border">
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="firstMileEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">First Mile - Pick-up</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Truck pick-up from your premises (Door to Port) to the
|
||||
origin rail yard.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("pickUpAddress", "", {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{firstMileEnabled && (
|
||||
<Controller
|
||||
name="pickUpAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field className="mt-3" data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="Pick-up address *"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<Controller
|
||||
name="lastMileEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Last Mile - Delivery</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Truck delivery from the destination rail yard to the final
|
||||
address (Port to Door).
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (!value) {
|
||||
form.setValue("deliveryAddress", "", {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{lastMileEnabled && (
|
||||
<Controller
|
||||
name="deliveryAddress"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field className="mt-3" data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="Delivery address *"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lastMileEnabled && (
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<Controller
|
||||
name="equipmentReturn"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Equipment Return</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{field.value === "with_return"
|
||||
? "Container returned to EDR after unloading."
|
||||
: "Container retained by the customer after delivery."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value === "with_return"}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(
|
||||
value ? "with_return" : "without_return",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
} from "./schema";
|
||||
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",
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Field, FieldError, SmartFileInput } from "@edr/ui-common";
|
||||
import {
|
||||
BOOKING_DOCS_SETTING,
|
||||
REQUIRED_DOC_KEYS,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import { getUploadedRequiredCount, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
|
||||
export function Step7Documents({ form }: { form: BookingForm }) {
|
||||
const documents = form.watch("documents");
|
||||
const uploadedRequired = getUploadedRequiredCount(documents);
|
||||
const documentErrors = form.formState.errors.documents as
|
||||
| Record<string, { message?: string }>
|
||||
| undefined;
|
||||
const smartFileErrors = Object.fromEntries(
|
||||
Object.entries(documentErrors ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
value?.message ?? "",
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Compliance Documents"
|
||||
description="Upload your company's legal credentials for EDR contract eligibility verification (US-04)."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold ${uploadedRequired === REQUIRED_DOC_KEYS.length
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
{uploadedRequired}/{REQUIRED_DOC_KEYS.length}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{uploadedRequired < REQUIRED_DOC_KEYS.length
|
||||
? `${REQUIRED_DOC_KEYS.length - uploadedRequired} mandatory document(s) still needed.`
|
||||
: "All mandatory documents uploaded. Power of Attorney is optional."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="documents"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<SmartFileInput
|
||||
file={BOOKING_DOCS_SETTING}
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
errors={smartFileErrors}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { 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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Calendar, MapPin } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { consignments } from "../consignments/consignments.mock";
|
||||
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
|
||||
|
||||
export interface ShipmentFormData {
|
||||
consignmentId?: number;
|
||||
consignmentReference?: string;
|
||||
originStation?: string;
|
||||
destinationStation?: string;
|
||||
mode?: ShipmentMode;
|
||||
status?: ShipmentStatus;
|
||||
currentLocation?: string;
|
||||
eta?: string;
|
||||
}
|
||||
|
||||
export interface NewShipmentPageProps {
|
||||
mode?: "create" | "edit";
|
||||
shipment?: ShipmentFormData;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
||||
|
||||
export default function NewShipmentPage({
|
||||
mode = "create",
|
||||
shipment,
|
||||
children,
|
||||
}: NewShipmentPageProps = {}) {
|
||||
const isEdit = mode === "edit";
|
||||
const title = isEdit ? "Edit Shipment" : "New Shipment";
|
||||
const description = isEdit
|
||||
? "Update shipment tracking information."
|
||||
: "Create a new shipment for real-time tracking.";
|
||||
const submitLabel = isEdit ? "Save Changes" : "Create Shipment";
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? <Button>{isEdit ? "Edit" : "New Shipment"}</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
{/* Consignment */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Consignment *</Label>
|
||||
<select
|
||||
defaultValue={shipment?.consignmentId ?? ""}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select consignment
|
||||
</option>
|
||||
{consignments.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.trackingNumber} — {c.customer} ({c.originStation} →{" "}
|
||||
{c.destinationStation})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-500">
|
||||
Origin and destination auto-fill from the linked consignment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<select
|
||||
defaultValue={shipment?.status ?? "In Transit"}
|
||||
className={selectClass}
|
||||
>
|
||||
<option>In Transit</option>
|
||||
<option>Delivered</option>
|
||||
<option>Delayed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label>Transport Mode</Label>
|
||||
<select
|
||||
defaultValue={shipment?.mode ?? "rail"}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="rail">Rail</option>
|
||||
<option value="truck">Truck</option>
|
||||
<option value="multimodal">Multimodal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Origin */}
|
||||
<div className="space-y-2">
|
||||
<Label>Origin Station</Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
defaultValue={shipment?.originStation ?? ""}
|
||||
placeholder="Auto-filled from consignment"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="space-y-2">
|
||||
<Label>Destination Station</Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
defaultValue={shipment?.destinationStation ?? ""}
|
||||
placeholder="Auto-filled from consignment"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ETA */}
|
||||
<div className="space-y-2">
|
||||
<Label>Estimated Arrival</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
type="date"
|
||||
defaultValue={shipment?.eta ?? ""}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Location */}
|
||||
<div className="space-y-2">
|
||||
<Label>Current Location</Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
defaultValue={shipment?.currentLocation ?? ""}
|
||||
placeholder="e.g. Dire Dawa Yard"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
List,
|
||||
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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
86
apps/edr-freight-web/portal/src/services/payments.service.ts
Normal file
86
apps/edr-freight-web/portal/src/services/payments.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const P = URL_CONSTANTS.PAYMENTS;
|
||||
|
||||
/** Payment methods supported by the central payment microservice. */
|
||||
export type PaymentMethod =
|
||||
| "TELEBIRR"
|
||||
| "CBE_BIRR"
|
||||
| "EBIRR"
|
||||
| "WAAFI"
|
||||
| "CARD"
|
||||
| "DMONEY"
|
||||
| "CAC_BANK";
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile";
|
||||
|
||||
export interface InitiatePaymentPayload {
|
||||
bookingId: string;
|
||||
method: PaymentMethod;
|
||||
platform?: PaymentPlatform;
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export interface ClientAction {
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
url?: string;
|
||||
appId?: string;
|
||||
receiveCode?: string;
|
||||
shortCode?: string;
|
||||
providerOrderId?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface InitiateResponse {
|
||||
intentId: string;
|
||||
status: string;
|
||||
clientAction?: ClientAction;
|
||||
merchantOrderId?: string;
|
||||
}
|
||||
|
||||
export interface IntentStatus extends InitiateResponse {
|
||||
paidAt?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the absolute URL for the public browser-checkout page, which
|
||||
* (re)initiates the payment and auto-redirects to the provider's checkout.
|
||||
* Used as the "pay" step after a successful `initiate`.
|
||||
*/
|
||||
function buildCheckoutUrl(payload: {
|
||||
bookingId: string;
|
||||
method: PaymentMethod;
|
||||
platform?: PaymentPlatform;
|
||||
}): string {
|
||||
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||
const params = new URLSearchParams({
|
||||
bookingId: payload.bookingId,
|
||||
method: payload.method,
|
||||
platform: payload.platform ?? "web",
|
||||
});
|
||||
return `${base}${P.CHECKOUT}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export const paymentsService = {
|
||||
initiate: async (
|
||||
payload: InitiatePaymentPayload,
|
||||
): Promise<InitiateResponse> => {
|
||||
const { data } = await client.post(P.INITIATE, {
|
||||
platform: "web",
|
||||
...payload,
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
getIntent: async (bookingId: string): Promise<IntentStatus> => {
|
||||
const { data } = await client.get(P.INTENT(bookingId));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
checkoutUrl: buildCheckoutUrl,
|
||||
};
|
||||
Reference in New Issue
Block a user