mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +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 { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||||
|
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||||
|
|
||||||
interface CurrentIamUser {
|
interface CurrentIamUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -45,6 +46,12 @@ export class CompaniesController {
|
|||||||
return new ProfileResponseDto(profile, company);
|
return new ProfileResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
||||||
|
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
||||||
|
return this.companiesService.getDashboardSummary(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('profile')
|
@Patch('profile')
|
||||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import { CompaniesService } from './companies.service';
|
|||||||
import { CompaniesRepository } from './companies.repository';
|
import { CompaniesRepository } from './companies.repository';
|
||||||
import { ExternalProfileRepository } from './external-profile.repository';
|
import { ExternalProfileRepository } from './external-profile.repository';
|
||||||
import { FFClientRepository } from './ff-client.repository';
|
import { FFClientRepository } from './ff-client.repository';
|
||||||
|
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||||
import { Company } from './entities/company.entity';
|
import { Company } from './entities/company.entity';
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { ExternalProfile } from './entities/external-profile.entity';
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
})
|
})
|
||||||
export class CompaniesModule {}
|
export class CompaniesModule {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
|||||||
import { CompaniesRepository } from './companies.repository';
|
import { CompaniesRepository } from './companies.repository';
|
||||||
import { ExternalProfileRepository } from './external-profile.repository';
|
import { ExternalProfileRepository } from './external-profile.repository';
|
||||||
import { FFClientRepository } from './ff-client.repository';
|
import { FFClientRepository } from './ff-client.repository';
|
||||||
|
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||||
@@ -9,6 +10,7 @@ import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
|||||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||||
|
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||||
import { Company } from './entities/company.entity';
|
import { Company } from './entities/company.entity';
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { ExternalProfile } from './entities/external-profile.entity';
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
@@ -27,6 +29,7 @@ export class CompaniesService {
|
|||||||
private readonly companiesRepo: CompaniesRepository,
|
private readonly companiesRepo: CompaniesRepository,
|
||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly ffClientsRepo: FFClientRepository,
|
private readonly ffClientsRepo: FFClientRepository,
|
||||||
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
@@ -98,6 +101,122 @@ export class CompaniesService {
|
|||||||
return { profile, company };
|
return { profile, company };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
||||||
|
* current user's company bookings. All figures are scoped to that company.
|
||||||
|
*
|
||||||
|
* Note: delivered/spend/volume all derive from the bookings table — there is
|
||||||
|
* no separate data source for them. On-time delivery rate is replaced by
|
||||||
|
* completion rate (delivered ÷ committed): the schema has no ETA /
|
||||||
|
* promised-delivery date, so on-time cannot be computed.
|
||||||
|
*
|
||||||
|
* Period attribution uses booking.created_at: there is no delivery-date
|
||||||
|
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||||
|
* delivered/completed status.
|
||||||
|
*/
|
||||||
|
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
|
||||||
|
// A user without a company profile has no bookings — return an empty summary
|
||||||
|
// rather than 404, so the portal home still renders.
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||||
|
if (!companyId) return this.emptyDashboardSummary();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||||
|
// Same point in the previous year, so YoY compares like-for-like windows.
|
||||||
|
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
|
||||||
|
|
||||||
|
const [
|
||||||
|
deliveredThis,
|
||||||
|
committedThis,
|
||||||
|
spendThisByCcy,
|
||||||
|
spendPrevByCcy,
|
||||||
|
tonnageThis,
|
||||||
|
tonnagePrev,
|
||||||
|
monthlyRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||||
|
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||||
|
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||||
|
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
|
||||||
|
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||||
|
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
||||||
|
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||||
|
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
||||||
|
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deliveredCount: deliveredThis,
|
||||||
|
// Share of committed bookings that reached delivered/completed.
|
||||||
|
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
|
||||||
|
spendYtd: spend.total,
|
||||||
|
spendCurrency: spend.currency,
|
||||||
|
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||||
|
freightVolume: {
|
||||||
|
totalTonnes: Math.round(tonnageThis),
|
||||||
|
totalValue: spend.total,
|
||||||
|
currency: spend.currency,
|
||||||
|
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
|
||||||
|
monthly: this.buildMonthlySeries(now, monthlyRows),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyDashboardSummary(): DashboardSummaryResponseDto {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
deliveredCount: 0,
|
||||||
|
completionRate: 0,
|
||||||
|
spendYtd: 0,
|
||||||
|
spendCurrency: 'ETB',
|
||||||
|
spendYtdChangePct: 0,
|
||||||
|
freightVolume: {
|
||||||
|
totalTonnes: 0,
|
||||||
|
totalValue: 0,
|
||||||
|
currency: 'ETB',
|
||||||
|
ytdChangePct: 0,
|
||||||
|
monthly: this.buildMonthlySeries(now, []),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First day of the month `n` months before `from`. */
|
||||||
|
private monthsAgo(from: Date, n: number): Date {
|
||||||
|
return new Date(from.getFullYear(), from.getMonth() - n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||||
|
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
||||||
|
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
||||||
|
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
|
||||||
|
private changePct(current: number, previous: number): number {
|
||||||
|
if (previous <= 0) return 0;
|
||||||
|
return Math.round(((current - previous) / previous) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
|
||||||
|
private buildMonthlySeries(
|
||||||
|
now: Date,
|
||||||
|
rows: { year: number; month: number; tonnes: number }[],
|
||||||
|
): { month: string; tonnes: number }[] {
|
||||||
|
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||||
|
const series: { month: string; tonnes: number }[] = [];
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||||
|
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
|
||||||
|
}
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||||
await this.findCompanyById(id);
|
await this.findCompanyById(id);
|
||||||
const updated = await this.companiesRepo.update(id, dto);
|
const updated = await this.companiesRepo.update(id, dto);
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
|
/** Booking statuses that represent a delivered/finished shipment. */
|
||||||
|
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses that represent real, committed freight (excludes drafts and dead
|
||||||
|
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
|
||||||
|
*/
|
||||||
|
const COMMITTED_STATUSES = [
|
||||||
|
'APPROVED',
|
||||||
|
'CONTRACT_READY',
|
||||||
|
'SIGNED_CUSTOMER',
|
||||||
|
'FULLY_EXECUTED',
|
||||||
|
'SELECTED_FOR_BATCH',
|
||||||
|
'PNR_GENERATED',
|
||||||
|
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
|
'PAID',
|
||||||
|
'IN_TRANSIT',
|
||||||
|
'COMPLETED',
|
||||||
|
'DELIVERED',
|
||||||
|
'CONSOLIDATED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface CurrencyTotal {
|
||||||
|
currency: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyTonnage {
|
||||||
|
year: number;
|
||||||
|
month: number; // 1-12
|
||||||
|
tonnes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only aggregation queries against the bookings table, scoped to a
|
||||||
|
* company, that back the portal dashboard. Lives in the companies module so it
|
||||||
|
* can be exposed via `companies.controller` without a circular dependency on
|
||||||
|
* BookingsModule (which already imports CompaniesModule).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CompanyDashboardRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Booking)
|
||||||
|
private readonly bookings: Repository<Booking>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Count of delivered/completed bookings for a company within [from, to). */
|
||||||
|
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
||||||
|
return this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
||||||
|
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
||||||
|
return this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
||||||
|
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||||
|
const rows = await this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('b.payment_currency', 'currency')
|
||||||
|
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere("b.payment_status = 'PAID'")
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.groupBy('b.payment_currency')
|
||||||
|
.getRawMany<{ currency: string; total: string }>();
|
||||||
|
|
||||||
|
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
||||||
|
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
||||||
|
const row = await this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.getRawOne<{ total: string }>();
|
||||||
|
|
||||||
|
return Number(row?.total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Committed tonnage grouped by calendar month within [from, to). */
|
||||||
|
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||||
|
const rows = await this.bookings
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||||
|
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||||
|
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||||
|
.where('b.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||||
|
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||||
|
.groupBy('year')
|
||||||
|
.addGroupBy('month')
|
||||||
|
.getRawMany<{ year: string; month: string; total: string }>();
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
year: Number(r.year),
|
||||||
|
month: Number(r.month),
|
||||||
|
tonnes: Number(r.total),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class FreightVolumePointDto {
|
||||||
|
@ApiProperty({ example: 'May', description: 'Short month label' })
|
||||||
|
month!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 940, description: 'Tonnage shipped in the month' })
|
||||||
|
tonnes!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FreightVolumeDto {
|
||||||
|
@ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' })
|
||||||
|
totalTonnes!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' })
|
||||||
|
totalValue!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'ETB' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' })
|
||||||
|
ytdChangePct!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' })
|
||||||
|
monthly!: FreightVolumePointDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPIs for the portal dashboard (MyPortalPage), aggregated from the current
|
||||||
|
* user's company bookings. All figures are scoped to that company.
|
||||||
|
*
|
||||||
|
* Note: every metric here derives from the bookings table — there is no
|
||||||
|
* separate "non-booking" data source for delivered/spend/volume. On-time
|
||||||
|
* delivery rate is replaced by completion rate: no ETA / promised-delivery
|
||||||
|
* column exists in the schema, so on-time cannot be computed, whereas
|
||||||
|
* completion rate (delivered ÷ committed) can.
|
||||||
|
*/
|
||||||
|
export class DashboardSummaryResponseDto {
|
||||||
|
@ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' })
|
||||||
|
deliveredCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 92,
|
||||||
|
description: 'Share of committed bookings that have been delivered/completed (YTD), in percent',
|
||||||
|
})
|
||||||
|
completionRate!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' })
|
||||||
|
spendYtd!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'ETB' })
|
||||||
|
spendCurrency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' })
|
||||||
|
spendYtdChangePct!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: FreightVolumeDto })
|
||||||
|
freightVolume!: FreightVolumeDto;
|
||||||
|
}
|
||||||
@@ -173,7 +173,7 @@ const App = () => {
|
|||||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route element={<RequireCompany path={location.pathname} />}>
|
<Route element={<RequireCompany />}>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<AppLayout
|
<AppLayout
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ export function AppLayout({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
const toggleStyle: CSSProperties = { ...islandStyle, borderRadius: 10 };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ChevronRight, Home } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
export interface BreadcrumbItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -34,10 +34,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
|||||||
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
|
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
|
||||||
|
|
||||||
{item.href && !isLast ? (
|
{item.href && !isLast ? (
|
||||||
<Link
|
<Link to={item.href} className="transition hover:text-[#10B981]">
|
||||||
to={item.href}
|
|
||||||
className="transition hover:text-[#10B981]"
|
|
||||||
>
|
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { FormEvent, useState } from "react";
|
|
||||||
import { Button, FormField } from "@edr/ui-common";
|
|
||||||
|
|
||||||
import type { CreateBookingPayload } from "../../services/bookings.service";
|
|
||||||
|
|
||||||
export interface BookingFormProps {
|
|
||||||
onSubmit: (payload: CreateBookingPayload) => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BookingForm = ({ onSubmit, isSubmitting }: BookingFormProps) => {
|
|
||||||
const [reference, setReference] = useState("");
|
|
||||||
const [customerId, setCustomerId] = useState("");
|
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
|
||||||
const [totalAmount, setTotalAmount] = useState("0");
|
|
||||||
|
|
||||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSubmit({
|
|
||||||
reference,
|
|
||||||
customerId,
|
|
||||||
scheduledDate,
|
|
||||||
totalAmount: Number(totalAmount),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
||||||
<FormField
|
|
||||||
label="Reference"
|
|
||||||
value={reference}
|
|
||||||
onChange={(e) => setReference(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Customer ID"
|
|
||||||
value={customerId}
|
|
||||||
onChange={(e) => setCustomerId(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Scheduled date"
|
|
||||||
type="date"
|
|
||||||
value={scheduledDate}
|
|
||||||
onChange={(e) => setScheduledDate(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Total amount"
|
|
||||||
type="number"
|
|
||||||
value={totalAmount}
|
|
||||||
onChange={(e) => setTotalAmount(e.target.value)}
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<Button type="submit" isLoading={isSubmitting}>
|
|
||||||
Create booking
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BookingForm;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type { Freight } from "@edr/types";
|
|
||||||
import { Table, type TableColumn } from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface BookingTableProps {
|
|
||||||
bookings: Freight.IBooking[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: TableColumn<Freight.IBooking>[] = [
|
|
||||||
{ key: "reference", header: "Reference" },
|
|
||||||
{ key: "customerId", header: "Customer" },
|
|
||||||
{ key: "status", header: "Status" },
|
|
||||||
{
|
|
||||||
key: "scheduledDate",
|
|
||||||
header: "Scheduled",
|
|
||||||
render: (row) => new Date(row.scheduledDate).toLocaleDateString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "totalAmount",
|
|
||||||
header: "Total",
|
|
||||||
render: (row) => row.totalAmount.toFixed(2),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const BookingTable = ({ bookings }: BookingTableProps) => (
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={bookings}
|
|
||||||
rowKey={(row) => row.id}
|
|
||||||
emptyMessage="No bookings yet"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default BookingTable;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { FormEvent, useState } from "react";
|
|
||||||
import { Button, FormField } from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface ConsignmentFormProps {
|
|
||||||
onSubmit: (payload: {
|
|
||||||
bookingId: string;
|
|
||||||
trackingNumber: string;
|
|
||||||
cargoType: string;
|
|
||||||
weightKg: number;
|
|
||||||
originStation: string;
|
|
||||||
destinationStation: string;
|
|
||||||
}) => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConsignmentForm = ({ onSubmit, isSubmitting }: ConsignmentFormProps) => {
|
|
||||||
const [bookingId, setBookingId] = useState("");
|
|
||||||
const [trackingNumber, setTrackingNumber] = useState("");
|
|
||||||
const [cargoType, setCargoType] = useState("GENERAL");
|
|
||||||
const [weightKg, setWeightKg] = useState("0");
|
|
||||||
const [originStation, setOriginStation] = useState("");
|
|
||||||
const [destinationStation, setDestinationStation] = useState("");
|
|
||||||
|
|
||||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSubmit({
|
|
||||||
bookingId,
|
|
||||||
trackingNumber,
|
|
||||||
cargoType,
|
|
||||||
weightKg: Number(weightKg),
|
|
||||||
originStation,
|
|
||||||
destinationStation,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
||||||
<FormField
|
|
||||||
label="Booking ID"
|
|
||||||
value={bookingId}
|
|
||||||
onChange={(e) => setBookingId(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Tracking #"
|
|
||||||
value={trackingNumber}
|
|
||||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Cargo type"
|
|
||||||
value={cargoType}
|
|
||||||
onChange={(e) => setCargoType(e.target.value)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Weight (kg)"
|
|
||||||
type="number"
|
|
||||||
value={weightKg}
|
|
||||||
onChange={(e) => setWeightKg(e.target.value)}
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Origin station"
|
|
||||||
value={originStation}
|
|
||||||
onChange={(e) => setOriginStation(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label="Destination station"
|
|
||||||
value={destinationStation}
|
|
||||||
onChange={(e) => setDestinationStation(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button type="submit" isLoading={isSubmitting}>
|
|
||||||
Create consignment
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConsignmentForm;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { Freight } from "@edr/types";
|
|
||||||
import { Table, type TableColumn } from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface ConsignmentTableProps {
|
|
||||||
consignments: Freight.IConsignment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: TableColumn<Freight.IConsignment>[] = [
|
|
||||||
{ key: "trackingNumber", header: "Tracking #" },
|
|
||||||
{ key: "cargoType", header: "Cargo" },
|
|
||||||
{ key: "status", header: "Status" },
|
|
||||||
{ key: "originStation", header: "Origin" },
|
|
||||||
{ key: "destinationStation", header: "Destination" },
|
|
||||||
{
|
|
||||||
key: "weightKg",
|
|
||||||
header: "Weight (kg)",
|
|
||||||
render: (row) => row.weightKg.toFixed(2),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ConsignmentTable = ({ consignments }: ConsignmentTableProps) => (
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={consignments}
|
|
||||||
rowKey={(row) => row.id}
|
|
||||||
emptyMessage="No consignments yet"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ConsignmentTable;
|
|
||||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { Loader2, AlertCircle } from "lucide-react";
|
import { Loader2, AlertCircle } from "lucide-react";
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -64,11 +63,7 @@ export function DynamicSelect({
|
|||||||
const options = [...data.children].sort((a, b) => a.order - b.order);
|
const options = [...data.children].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||||
value={value}
|
|
||||||
onValueChange={onValueChange}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={cn("w-full", className)}>
|
<SelectTrigger className={cn("w-full", className)}>
|
||||||
<SelectValue placeholder={placeholder ?? `Select ${data.label}`} />
|
<SelectValue placeholder={placeholder ?? `Select ${data.label}`} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const TrackingTimeline = ({ events }: TrackingTimelineProps) => {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||||
<span>{event.location}</span>
|
<span>{event.location}</span>
|
||||||
<Badge tone="info">{event.status}</Badge>
|
<Badge>{event.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<time className="text-xs text-gray-500">
|
<time className="text-xs text-gray-500">
|
||||||
{new Date(event.occurredAt).toLocaleString()}
|
{new Date(event.occurredAt).toLocaleString()}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export * from './table';
|
export * from "./table";
|
||||||
export * from './badge';
|
export * from "./badge";
|
||||||
export * from './button';
|
export * from "./button";
|
||||||
export * from './dialog';
|
export * from "./dialog";
|
||||||
export * from './input';
|
export * from "./input";
|
||||||
export * from './label';
|
export * from "./label";
|
||||||
export * from './textarea';
|
export * from "./textarea";
|
||||||
export * from './Breadcrumbs';
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
|
|||||||
GET_INFO: "/api/companies/getInfo",
|
GET_INFO: "/api/companies/getInfo",
|
||||||
CREATE: "/api/companies/create",
|
CREATE: "/api/companies/create",
|
||||||
PROFILE: "/api/companies/profile",
|
PROFILE: "/api/companies/profile",
|
||||||
|
DASHBOARD: "/api/companies/dashboard",
|
||||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -100,4 +101,10 @@ export const URL_CONSTANTS = {
|
|||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
PAYMENTS: {
|
||||||
|
INITIATE: "/api/payments/initiate",
|
||||||
|
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
|
||||||
|
CHECKOUT: "/api/payments/checkout",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { bookingsService } from "../services/bookings.service";
|
|
||||||
|
|
||||||
export const useBookings = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["bookings"],
|
|
||||||
queryFn: bookingsService.list,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useBooking = (id: string) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["bookings", id],
|
|
||||||
queryFn: () => bookingsService.get(id),
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { consignmentsService } from "../services/consignments.service";
|
|
||||||
|
|
||||||
export const useConsignments = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["consignments"],
|
|
||||||
queryFn: consignmentsService.list,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useConsignment = (id: string) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["consignments", id],
|
|
||||||
queryFn: () => consignmentsService.get(id),
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { customersService } from "@/services/customers.service";
|
|
||||||
import type {
|
|
||||||
CreateCustomerDto,
|
|
||||||
UpdateCustomerDto,
|
|
||||||
} from "@/types/customers";
|
|
||||||
|
|
||||||
const KEY = ["customers"] as const;
|
|
||||||
|
|
||||||
export const useCustomers = () =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: KEY,
|
|
||||||
queryFn: customersService.list,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useCustomer = (id: string | undefined) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: [...KEY, "id", id],
|
|
||||||
queryFn: () => customersService.getById(id!),
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useCreateCustomer = () => {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (dto: CreateCustomerDto) => customersService.create(dto),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateCustomer = () => {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) =>
|
|
||||||
customersService.update(id, dto),
|
|
||||||
onSuccess: (_data, { id }) => {
|
|
||||||
qc.invalidateQueries({ queryKey: KEY });
|
|
||||||
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeleteCustomer = () => {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => customersService.remove(id),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
|
||||||
import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
|
|
||||||
import { shipments, type Shipment } from "@/pages/tracking/shipments.mock";
|
|
||||||
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
||||||
|
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock "logged-in customer". When auth integrates, replace this with the value
|
* Mock "logged-in customer". When auth integrates, replace this with the value
|
||||||
@@ -16,16 +14,6 @@ export function getCurrentCustomer(): Customer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMyBookings(): Booking[] {
|
|
||||||
const me = getCurrentCustomer();
|
|
||||||
return bookings.filter((b) => b.customerId === me.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMyShipments(): Shipment[] {
|
|
||||||
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
|
|
||||||
return shipments.filter((s) => myBookingIds.has(s.bookingId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMyInvoices(): Invoice[] {
|
export function getMyInvoices(): Invoice[] {
|
||||||
const me = getCurrentCustomer();
|
const me = getCurrentCustomer();
|
||||||
return invoices.filter((inv) => inv.customerId === me.id);
|
return invoices.filter((inv) => inv.customerId === me.id);
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import { useMemo } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { getMyInvoices, getMyShipments } from "@/lib/currentCustomer";
|
import { getMyInvoices } from "@/lib/currentCustomer";
|
||||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
@@ -38,6 +38,9 @@ const cv = (token: string) => {
|
|||||||
return `var(--mantine-color-${name}-${shade ?? "6"})`;
|
return `var(--mantine-color-${name}-${shade ?? "6"})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Format a signed percentage for KPI deltas, e.g. 16 → "+16%", -4 → "-4%". */
|
||||||
|
const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`;
|
||||||
|
|
||||||
const ACTIVE_STATUSES = [
|
const ACTIVE_STATUSES = [
|
||||||
"DRAFT",
|
"DRAFT",
|
||||||
"SUBMITTED",
|
"SUBMITTED",
|
||||||
@@ -356,12 +359,8 @@ const INVOICE_BADGE: Record<
|
|||||||
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
|
|
||||||
const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
|
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
export default function MyPortalPage() {
|
||||||
const { user, customer } = useAuth();
|
const { user, customer } = useAuth();
|
||||||
const myShipments = useMemo(() => getMyShipments(), []);
|
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -371,10 +370,17 @@ export default function MyPortalPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions());
|
||||||
|
const dashboard = dashboardQuery.data;
|
||||||
|
|
||||||
const allBookings = bookingsQuery.data?.items ?? [];
|
const allBookings = bookingsQuery.data?.items ?? [];
|
||||||
const activeBookings = allBookings.filter((b) =>
|
const activeBookings = allBookings.filter((b) =>
|
||||||
ACTIVE_STATUSES.includes(b.status),
|
ACTIVE_STATUSES.includes(b.status),
|
||||||
);
|
);
|
||||||
|
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const newActiveThisWeek = activeBookings.filter(
|
||||||
|
(b) => new Date(b.createdAt).getTime() >= weekAgo,
|
||||||
|
).length;
|
||||||
|
|
||||||
const visibleBookings = allBookings;
|
const visibleBookings = allBookings;
|
||||||
const outstandingInvoices = myInvoices.filter(
|
const outstandingInvoices = myInvoices.filter(
|
||||||
@@ -384,9 +390,6 @@ export default function MyPortalPage() {
|
|||||||
(sum, inv) => sum + inv.amount,
|
(sum, inv) => sum + inv.amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const deliveredCount =
|
|
||||||
myShipments.filter((s) => s.status === "Delivered").length || 12;
|
|
||||||
|
|
||||||
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
||||||
const companyName = (customer as any)?.companyName ?? displayName;
|
const companyName = (customer as any)?.companyName ?? displayName;
|
||||||
|
|
||||||
@@ -398,7 +401,9 @@ export default function MyPortalPage() {
|
|||||||
? "Good afternoon,"
|
? "Good afternoon,"
|
||||||
: "Good evening,";
|
: "Good evening,";
|
||||||
const recentInvoices = myInvoices.slice(0, 3);
|
const recentInvoices = myInvoices.slice(0, 3);
|
||||||
const maxVolume = Math.max(...VOLUME_DATA);
|
|
||||||
|
const volumePoints = dashboard?.freightVolume.monthly ?? [];
|
||||||
|
const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||||
@@ -471,8 +476,12 @@ export default function MyPortalPage() {
|
|||||||
<StatKpi
|
<StatKpi
|
||||||
icon={Truck}
|
icon={Truck}
|
||||||
label="Active Shipments"
|
label="Active Shipments"
|
||||||
value={activeBookings.length.toString()}
|
value={
|
||||||
delta="+2 this week"
|
bookingsQuery.isPending ? "—" : activeBookings.length.toString()
|
||||||
|
}
|
||||||
|
delta={
|
||||||
|
bookingsQuery.isPending ? "" : `+${newActiveThisWeek} this week`
|
||||||
|
}
|
||||||
deltaColor="edr-green.7"
|
deltaColor="edr-green.7"
|
||||||
/>
|
/>
|
||||||
<StatKpi
|
<StatKpi
|
||||||
@@ -485,17 +494,26 @@ export default function MyPortalPage() {
|
|||||||
/>
|
/>
|
||||||
<StatKpi
|
<StatKpi
|
||||||
icon={CheckCircle2}
|
icon={CheckCircle2}
|
||||||
label="Delivered (May)"
|
label="Delivered (YTD)"
|
||||||
value={deliveredCount.toString()}
|
value={dashboard ? dashboard.deliveredCount.toString() : "—"}
|
||||||
delta="96% on-time"
|
delta={dashboard ? `${dashboard.completionRate}% completed` : ""}
|
||||||
deltaColor="edr-muted"
|
deltaColor="edr-muted"
|
||||||
divider
|
divider
|
||||||
/>
|
/>
|
||||||
<StatKpi
|
<StatKpi
|
||||||
icon={Wallet}
|
icon={Wallet}
|
||||||
label="Spend YTD"
|
label="Spend YTD"
|
||||||
value="ETB 1.24M"
|
value={
|
||||||
delta="+16% YoY"
|
dashboard
|
||||||
|
? formatCurrency(
|
||||||
|
dashboard.spendYtd,
|
||||||
|
dashboard.spendCurrency as Currency,
|
||||||
|
)
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
delta={
|
||||||
|
dashboard ? `${formatPct(dashboard.spendYtdChangePct)} YoY` : ""
|
||||||
|
}
|
||||||
deltaColor="edr-green.7"
|
deltaColor="edr-green.7"
|
||||||
divider
|
divider
|
||||||
/>
|
/>
|
||||||
@@ -670,37 +688,57 @@ export default function MyPortalPage() {
|
|||||||
Freight Volume
|
Freight Volume
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={10} align="baseline" mt={4} mb={22}>
|
<Group gap={10} align="baseline" mt={4} mb={22}>
|
||||||
<Text fz={26} fw={800} c="edr-text">
|
{dashboardQuery.isPending ? (
|
||||||
4,180 t
|
<Skeleton height={32} width={180} radius="sm" />
|
||||||
</Text>
|
) : (
|
||||||
<Text fz={13} c="edr-muted">
|
<>
|
||||||
ETB 1.24M
|
<Text fz={26} fw={800} c="edr-text">
|
||||||
</Text>
|
{(dashboard?.freightVolume.totalTonnes ?? 0).toLocaleString()}{" "}
|
||||||
<Text fz={12} fw={700} c="edr-green.7">
|
t
|
||||||
+16% YTD
|
</Text>
|
||||||
</Text>
|
<Text fz={13} c="edr-muted">
|
||||||
|
{formatCurrency(
|
||||||
|
dashboard?.freightVolume.totalValue ?? 0,
|
||||||
|
(dashboard?.freightVolume.currency ?? "ETB") as Currency,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} fw={700} c="edr-green.7">
|
||||||
|
{formatPct(dashboard?.freightVolume.ytdChangePct ?? 0)} YTD
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group align="flex-end" gap={10} className="h-[110px]">
|
{dashboardQuery.isPending ? (
|
||||||
{VOLUME_DATA.map((val, i) => {
|
<Skeleton height={110} radius="md" />
|
||||||
const isLast = i === VOLUME_DATA.length - 1;
|
) : volumePoints.length === 0 ? (
|
||||||
return (
|
<Box className="flex h-[110px] items-center">
|
||||||
<Box
|
<Text fz={13} c="edr-muted">
|
||||||
key={i}
|
No freight volume yet.
|
||||||
className="flex flex-1 flex-col items-center gap-2"
|
</Text>
|
||||||
>
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Group align="flex-end" gap={10} className="h-[110px]">
|
||||||
|
{volumePoints.map((point, i) => {
|
||||||
|
const isLast = i === volumePoints.length - 1;
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
bg={isLast ? "edr-green" : "edr-soft"}
|
key={point.month}
|
||||||
bd={isLast ? undefined : "1px solid edr-border"}
|
className="flex flex-1 flex-col items-center gap-2"
|
||||||
h={Math.round((val / maxVolume) * 86)}
|
>
|
||||||
className="w-full rounded-t-md"
|
<Box
|
||||||
/>
|
bg={isLast ? "edr-green" : "edr-soft"}
|
||||||
<Text fz={11} c="edr-muted">
|
bd={isLast ? undefined : "1px solid edr-border"}
|
||||||
{MONTHS[i]}
|
h={Math.round((point.tonnes / maxVolume) * 86)}
|
||||||
</Text>
|
className="w-full rounded-t-md"
|
||||||
</Box>
|
/>
|
||||||
);
|
<Text fz={11} c="edr-muted">
|
||||||
})}
|
{point.month}
|
||||||
</Group>
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -8,7 +19,6 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -32,7 +42,10 @@ const onboardingSchema = z.object({
|
|||||||
companyLocation: z.string().min(1, "Location is required"),
|
companyLocation: z.string().min(1, "Location is required"),
|
||||||
companyAddress: z.string().min(1, "Address is required"),
|
companyAddress: z.string().min(1, "Address is required"),
|
||||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||||
vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"),
|
vatNumber: z
|
||||||
|
.string()
|
||||||
|
.min(1, "VAT number is required")
|
||||||
|
.length(10, "VAT number must be exactly 10 digits"),
|
||||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||||
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
||||||
@@ -52,8 +65,26 @@ const onboardingSchema = z.object({
|
|||||||
type FormData = z.infer<typeof onboardingSchema>;
|
type FormData = z.infer<typeof onboardingSchema>;
|
||||||
|
|
||||||
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||||
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
|
company: [
|
||||||
personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"],
|
"companyName",
|
||||||
|
"companyEmail",
|
||||||
|
"companyPhone",
|
||||||
|
"companyPhoneCountryCode",
|
||||||
|
"companyLocation",
|
||||||
|
"companyAddress",
|
||||||
|
"tinNumber",
|
||||||
|
"vatNumber",
|
||||||
|
"fanNumber",
|
||||||
|
],
|
||||||
|
personnel: [
|
||||||
|
"contactPersonName",
|
||||||
|
"contactPersonPhone",
|
||||||
|
"contactPersonPhoneCountryCode",
|
||||||
|
"generalManagerName",
|
||||||
|
"generalManagerEmail",
|
||||||
|
"generalManagerPhone",
|
||||||
|
"generalManagerPhoneCountryCode",
|
||||||
|
],
|
||||||
poa: [],
|
poa: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
confirm: [],
|
confirm: [],
|
||||||
@@ -76,7 +107,10 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
|||||||
generalManagerEmail: data.generalManagerEmail,
|
generalManagerEmail: data.generalManagerEmail,
|
||||||
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
||||||
poaName: data.poaName || undefined,
|
poaName: data.poaName || undefined,
|
||||||
poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined,
|
poaPhone:
|
||||||
|
data.poaPhone && data.poaPhoneCountryCode
|
||||||
|
? `${data.poaPhoneCountryCode}${data.poaPhone}`
|
||||||
|
: undefined,
|
||||||
poaAddress: data.poaAddress || undefined,
|
poaAddress: data.poaAddress || undefined,
|
||||||
poaEmail: data.poaEmail || undefined,
|
poaEmail: data.poaEmail || undefined,
|
||||||
poaLocation: data.poaLocation || undefined,
|
poaLocation: data.poaLocation || undefined,
|
||||||
@@ -102,22 +136,50 @@ export default function CompanyProfileForm({
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<CompanyStep>("company");
|
const [step, setStep] = useState<CompanyStep>("company");
|
||||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
const [internalFiles, setInternalFiles] = useState<
|
||||||
|
Record<string, File | File[] | null>
|
||||||
|
>({});
|
||||||
const documentFiles = controlledFiles ?? internalFiles;
|
const documentFiles = controlledFiles ?? internalFiles;
|
||||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||||
|
|
||||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
|
input: { code: documentSettingCode },
|
||||||
|
refetchOnMount: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
trigger,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(onboardingSchema),
|
resolver: zodResolver(onboardingSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251",
|
companyName: "",
|
||||||
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
|
companyEmail: "",
|
||||||
contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251",
|
companyPhone: "",
|
||||||
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251",
|
companyPhoneCountryCode: "+251",
|
||||||
poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "",
|
companyLocation: "",
|
||||||
|
companyAddress: "",
|
||||||
|
tinNumber: "",
|
||||||
|
vatNumber: "",
|
||||||
|
fanNumber: "",
|
||||||
|
contactPersonName: "",
|
||||||
|
contactPersonPhone: "",
|
||||||
|
contactPersonPhoneCountryCode: "+251",
|
||||||
|
generalManagerName: "",
|
||||||
|
generalManagerEmail: "",
|
||||||
|
generalManagerPhone: "",
|
||||||
|
generalManagerPhoneCountryCode: "+251",
|
||||||
|
poaName: "",
|
||||||
|
poaPhone: "",
|
||||||
|
poaPhoneCountryCode: "+251",
|
||||||
|
poaAddress: "",
|
||||||
|
poaEmail: "",
|
||||||
|
poaLocation: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +188,18 @@ export default function CompanyProfileForm({
|
|||||||
const totalSteps = 5;
|
const totalSteps = 5;
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (step === "poa") { setStep("documents"); return; }
|
if (step === "poa") {
|
||||||
if (step === "documents") { setStep("confirm"); return; }
|
setStep("documents");
|
||||||
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
|
return;
|
||||||
|
}
|
||||||
|
if (step === "documents") {
|
||||||
|
setStep("confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === "confirm") {
|
||||||
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isValid = await trigger(stepFields[step]);
|
const isValid = await trigger(stepFields[step]);
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
setStep(step === "company" ? "personnel" : "poa");
|
setStep(step === "company" ? "personnel" : "poa");
|
||||||
@@ -158,7 +229,13 @@ export default function CompanyProfileForm({
|
|||||||
confirm: `Step 5 of ${totalSteps} — Review & Confirm`,
|
confirm: `Step 5 of ${totalSteps} — Review & Confirm`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepOrder: CompanyStep[] = ["company", "personnel", "poa", "documents", "confirm"];
|
const stepOrder: CompanyStep[] = [
|
||||||
|
"company",
|
||||||
|
"personnel",
|
||||||
|
"poa",
|
||||||
|
"documents",
|
||||||
|
"confirm",
|
||||||
|
];
|
||||||
const currentIdx = stepOrder.indexOf(step);
|
const currentIdx = stepOrder.indexOf(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,13 +252,24 @@ export default function CompanyProfileForm({
|
|||||||
Change account type
|
Change account type
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
className="relative max-w-lg mx-auto px-2"
|
||||||
|
>
|
||||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
||||||
{STEPS.map(({ key, icon }, i) => {
|
{STEPS.map(({ key, icon }, i) => {
|
||||||
const done = i < currentIdx;
|
const done = i < currentIdx;
|
||||||
const active = i === currentIdx;
|
const active = i === currentIdx;
|
||||||
return done || active ? (
|
return done || active ? (
|
||||||
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
|
<ThemeIcon
|
||||||
|
key={key}
|
||||||
|
size={40}
|
||||||
|
radius="xl"
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
className="relative z-10"
|
||||||
|
>
|
||||||
{done ? <CheckCircle2 size={18} /> : icon}
|
{done ? <CheckCircle2 size={18} /> : icon}
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
) : (
|
) : (
|
||||||
@@ -223,7 +311,10 @@ export default function CompanyProfileForm({
|
|||||||
/>
|
/>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||||
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
|
phone={{
|
||||||
|
...register("companyPhone"),
|
||||||
|
placeholder: "912345678",
|
||||||
|
}}
|
||||||
countryCodeError={errors.companyPhoneCountryCode}
|
countryCodeError={errors.companyPhoneCountryCode}
|
||||||
phoneError={errors.companyPhone}
|
phoneError={errors.companyPhone}
|
||||||
label="Company Phone"
|
label="Company Phone"
|
||||||
@@ -271,7 +362,9 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "personnel" && (
|
{step === "personnel" && (
|
||||||
<>
|
<>
|
||||||
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
Contact Person
|
||||||
|
</Text>
|
||||||
<SimpleGrid cols={2} spacing="md">
|
<SimpleGrid cols={2} spacing="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
@@ -281,7 +374,10 @@ export default function CompanyProfileForm({
|
|||||||
/>
|
/>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
|
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
|
||||||
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
|
phone={{
|
||||||
|
...register("contactPersonPhone"),
|
||||||
|
placeholder: "912345678",
|
||||||
|
}}
|
||||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||||
phoneError={errors.contactPersonPhone}
|
phoneError={errors.contactPersonPhone}
|
||||||
label="Phone"
|
label="Phone"
|
||||||
@@ -290,7 +386,9 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
<Divider color="edr-border" />
|
<Divider color="edr-border" />
|
||||||
|
|
||||||
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
General Manager
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
placeholder="Abebe Bikila"
|
placeholder="Abebe Bikila"
|
||||||
@@ -306,8 +404,13 @@ export default function CompanyProfileForm({
|
|||||||
{...register("generalManagerEmail")}
|
{...register("generalManagerEmail")}
|
||||||
/>
|
/>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
|
countryCode={{
|
||||||
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
|
...register("generalManagerPhoneCountryCode"),
|
||||||
|
}}
|
||||||
|
phone={{
|
||||||
|
...register("generalManagerPhone"),
|
||||||
|
placeholder: "912345678",
|
||||||
|
}}
|
||||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||||
phoneError={errors.generalManagerPhone}
|
phoneError={errors.generalManagerPhone}
|
||||||
label="Phone"
|
label="Phone"
|
||||||
@@ -319,7 +422,8 @@ export default function CompanyProfileForm({
|
|||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
|
Power of Attorney details are optional. Fill them in if you have
|
||||||
|
them, or skip to continue.
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Name"
|
label="PoA Name"
|
||||||
@@ -371,51 +475,126 @@ export default function CompanyProfileForm({
|
|||||||
No document requirements found for your account type.
|
No document requirements found for your account type.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
|
<SmartFileInput
|
||||||
|
file={uploadSetting}
|
||||||
|
value={documentFiles}
|
||||||
|
onChange={setDocumentFiles}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "confirm" && (
|
{step === "confirm" && (
|
||||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
<Box
|
||||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
p={16}
|
||||||
|
className="rounded-2xl border border-edr-border bg-edr-card"
|
||||||
|
>
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Review your registration
|
||||||
|
</Text>
|
||||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||||
Confirm the company details below before saving.
|
Confirm the company details below before saving.
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
<ReviewRow label="Company name" value={formValues.companyName} />
|
<ReviewRow
|
||||||
<ReviewRow label="Company email" value={formValues.companyEmail} />
|
label="Company name"
|
||||||
<ReviewRow label="Company phone" value={formValues.companyPhone} />
|
value={formValues.companyName}
|
||||||
<ReviewRow label="Location" value={formValues.companyLocation} />
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="Company email"
|
||||||
|
value={formValues.companyEmail}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="Company phone"
|
||||||
|
value={formValues.companyPhone}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="Location"
|
||||||
|
value={formValues.companyLocation}
|
||||||
|
/>
|
||||||
<ReviewRow label="Address" value={formValues.companyAddress} />
|
<ReviewRow label="Address" value={formValues.companyAddress} />
|
||||||
<ReviewRow label="TIN" value={formValues.tinNumber} />
|
<ReviewRow label="TIN" value={formValues.tinNumber} />
|
||||||
<ReviewRow label="VAT" value={formValues.vatNumber} />
|
<ReviewRow label="VAT" value={formValues.vatNumber} />
|
||||||
<ReviewRow label="FAN" value={formValues.fanNumber} />
|
<ReviewRow label="FAN" value={formValues.fanNumber} />
|
||||||
<ReviewRow label="Contact person" value={formValues.contactPersonName} />
|
<ReviewRow
|
||||||
<ReviewRow label="Contact phone" value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`} />
|
label="Contact person"
|
||||||
<ReviewRow label="General manager" value={formValues.generalManagerName} />
|
value={formValues.contactPersonName}
|
||||||
<ReviewRow label="GM email" value={formValues.generalManagerEmail} />
|
/>
|
||||||
<ReviewRow label="GM phone" value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`} />
|
<ReviewRow
|
||||||
<ReviewRow label="PoA name" value={formValues.poaName || undefined} />
|
label="Contact phone"
|
||||||
<ReviewRow label="PoA phone" value={formValues.poaPhone && formValues.poaPhoneCountryCode ? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}` : undefined} />
|
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
|
||||||
<ReviewRow label="PoA email" value={formValues.poaEmail || undefined} />
|
/>
|
||||||
<ReviewRow label="PoA location" value={formValues.poaLocation || undefined} />
|
<ReviewRow
|
||||||
|
label="General manager"
|
||||||
|
value={formValues.generalManagerName}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="GM email"
|
||||||
|
value={formValues.generalManagerEmail}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="GM phone"
|
||||||
|
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="PoA name"
|
||||||
|
value={formValues.poaName || undefined}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="PoA phone"
|
||||||
|
value={
|
||||||
|
formValues.poaPhone && formValues.poaPhoneCountryCode
|
||||||
|
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="PoA email"
|
||||||
|
value={formValues.poaEmail || undefined}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="PoA location"
|
||||||
|
value={formValues.poaLocation || undefined}
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="space-between" pt="xs">
|
<Group justify="space-between" pt="xs">
|
||||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
<Button
|
||||||
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
variant="default"
|
||||||
|
onClick={prevStep}
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
>
|
||||||
|
{step === "company"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
onClick={
|
||||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
step === "confirm"
|
||||||
|
? handleSubmit((data) => onSubmit(buildPayload(data, user)))
|
||||||
|
: nextStep
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
(step === "documents" && !hasDocuments && loadingDocuments)
|
||||||
|
}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
rightSection={
|
||||||
|
!isPending && step !== "confirm" && step !== "documents" ? (
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
{step === "documents"
|
||||||
|
? "Continue"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Submit Registration"
|
||||||
|
: "Next Step"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -427,10 +606,20 @@ export default function CompanyProfileForm({
|
|||||||
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
<Box p={12} className="rounded-xl bg-edr-bg">
|
||||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c="edr-muted"
|
||||||
|
className="uppercase tracking-wide"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
c={value?.trim() ? "edr-text" : "edr-muted"}
|
||||||
|
mt={4}
|
||||||
|
>
|
||||||
{value?.trim() ? value : "Not provided"}
|
{value?.trim() ? value : "Not provided"}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export function DraftBookingView({
|
|||||||
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const pricing = booking.pricingBreakdown ?? generatedPricing ?? null;
|
const pricing = (booking.pricingBreakdown ??
|
||||||
|
generatedPricing ??
|
||||||
|
null) as Freight.PricingBreakdown | null;
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Box, Group, Text } from "@mantine/core";
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { CreditCard, Download } from "lucide-react";
|
import { CreditCard, Download } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
@@ -13,21 +15,30 @@ import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout
|
|||||||
import { CancelledBanner } from "./components/Notices";
|
import { CancelledBanner } from "./components/Notices";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||||
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
import { PaymentCard } from "./components/pricing";
|
import { PaymentCard } from "./components/pricing";
|
||||||
import { ScheduleCard } from "./components/ScheduleCard";
|
import { ScheduleCard } from "./components/ScheduleCard";
|
||||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||||
import { StatusHero } from "./components/StatusHero";
|
import { StatusHero } from "./components/StatusHero";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { fmtDate, isNegative } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
|
|
||||||
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
|
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Two-step flow: POST /payments/initiate to create the intent, then send the
|
||||||
|
// browser to the public /payments/checkout page which redirects to the
|
||||||
|
// selected provider to complete payment.
|
||||||
const payMutation = useMutation({
|
const payMutation = useMutation({
|
||||||
mutationFn: () => api.bookings.pay.call({ id: booking.id }),
|
mutationFn: (method: PaymentMethod) =>
|
||||||
onSuccess: (data) => {
|
api.payments.initiate.call({ bookingId: booking.id, method }),
|
||||||
if (data.redirectUrl) window.location.href = data.redirectUrl;
|
onSuccess: (_data, method) => {
|
||||||
|
window.location.href = paymentsService.checkoutUrl({
|
||||||
|
bookingId: booking.id,
|
||||||
|
method,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,9 +58,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
<HeaderButton
|
<HeaderButton
|
||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
label={payMutation.isPending ? "Processing…" : "Pay now"}
|
label="Pay now"
|
||||||
onClick={() => payMutation.mutate()}
|
onClick={() => setPayModalOpen(true)}
|
||||||
disabled={payMutation.isPending}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -128,7 +138,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
{showCountdown && (
|
{showCountdown && (
|
||||||
<PaymentDeadlineCard
|
<PaymentDeadlineCard
|
||||||
paymentDeadline={booking.paymentDeadline!}
|
paymentDeadline={booking.paymentDeadline!}
|
||||||
onPay={() => payMutation.mutate()}
|
onPay={() => setPayModalOpen(true)}
|
||||||
paying={payMutation.isPending}
|
paying={payMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -142,6 +152,26 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PaymentMethodModal
|
||||||
|
opened={payModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!payMutation.isPending) {
|
||||||
|
setPayModalOpen(false);
|
||||||
|
payMutation.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
|
processing={payMutation.isPending}
|
||||||
|
error={
|
||||||
|
payMutation.isError
|
||||||
|
? payMutation.error instanceof Error
|
||||||
|
? payMutation.error.message
|
||||||
|
: "Could not start payment. Please try again."
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onConfirm={(method) => payMutation.mutate(method)}
|
||||||
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Banknote,
|
||||||
|
Building2,
|
||||||
|
CreditCard,
|
||||||
|
Smartphone,
|
||||||
|
Wallet,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { PaymentMethod } from "@/services/payments.service";
|
||||||
|
|
||||||
|
interface ProviderOption {
|
||||||
|
method: PaymentMethod;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS: ProviderOption[] = [
|
||||||
|
{
|
||||||
|
method: "TELEBIRR",
|
||||||
|
label: "telebirr",
|
||||||
|
description: "Ethiopian mobile money",
|
||||||
|
icon: Smartphone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "CBE_BIRR",
|
||||||
|
label: "CBE Birr",
|
||||||
|
description: "Commercial Bank of Ethiopia",
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "EBIRR",
|
||||||
|
label: "E-Birr",
|
||||||
|
description: "Electronic payment gateway",
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "WAAFI",
|
||||||
|
label: "WAAFI",
|
||||||
|
description: "Djibouti mobile money",
|
||||||
|
icon: Smartphone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "CARD",
|
||||||
|
label: "Card",
|
||||||
|
description: "Visa / Mastercard",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "DMONEY",
|
||||||
|
label: "D-Money",
|
||||||
|
description: "Djibouti D-money",
|
||||||
|
icon: Banknote,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "CAC_BANK",
|
||||||
|
label: "CAC Bank",
|
||||||
|
description: "CAC Int Bank (OTP)",
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ProviderRow({
|
||||||
|
option,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
option: ProviderOption;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = option.icon;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
onClick={onSelect}
|
||||||
|
gap={12}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "13px 14px",
|
||||||
|
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
|
||||||
|
backgroundColor: selected ? "#ECF6F1" : "#fff",
|
||||||
|
transition: "border-color .12s, background-color .12s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
|
||||||
|
color: selected ? "#fff" : "#475569",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={19} />
|
||||||
|
</Box>
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Text fz="14px" fw={700} c="#10202F">
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12.5px" c="#9AA8B5">
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
|
||||||
|
backgroundColor: selected ? "#0A6F4D" : "transparent",
|
||||||
|
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentMethodModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
amountLabel,
|
||||||
|
onConfirm,
|
||||||
|
processing,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Human-readable total, e.g. "ETB 12,500". */
|
||||||
|
amountLabel?: string;
|
||||||
|
onConfirm: (method: PaymentMethod) => void;
|
||||||
|
processing?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) {
|
||||||
|
const [method, setMethod] = useState<PaymentMethod | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size={460}
|
||||||
|
title={
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={800} fz="17px" c="#10202F">
|
||||||
|
Choose a payment method
|
||||||
|
</Text>
|
||||||
|
{amountLabel && (
|
||||||
|
<Text fz="12.5px" c="#9AA8B5">
|
||||||
|
Amount due: {amountLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap={10}>
|
||||||
|
{PROVIDERS.map((option) => (
|
||||||
|
<ProviderRow
|
||||||
|
key={option.method}
|
||||||
|
option={option}
|
||||||
|
selected={method === option.method}
|
||||||
|
onSelect={() => setMethod(option.method)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text fz="12.5px" c="#C0392B" fw={600}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt={6}
|
||||||
|
radius={10}
|
||||||
|
color="edr-green"
|
||||||
|
disabled={!method || processing}
|
||||||
|
loading={processing}
|
||||||
|
onClick={() => method && onConfirm(method)}
|
||||||
|
styles={{
|
||||||
|
root: { height: 46 },
|
||||||
|
label: { fontSize: 14, fontWeight: 800 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processing ? "Redirecting…" : "Continue to payment"}
|
||||||
|
</Button>
|
||||||
|
<Text fz="11.5px" c="#9AA8B5" ta="center">
|
||||||
|
You'll be redirected to your provider to complete payment securely.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import BookingForm from "../../components/bookings/BookingForm";
|
|
||||||
import { bookingsService } from "../../services/bookings.service";
|
|
||||||
|
|
||||||
const CreateBookingPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: bookingsService.create,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["bookings"] });
|
|
||||||
navigate("/bookings");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-lg">
|
|
||||||
<h1 className="mb-4 text-2xl font-semibold text-gray-900">New booking</h1>
|
|
||||||
<BookingForm
|
|
||||||
onSubmit={mutation.mutate}
|
|
||||||
isSubmitting={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateBookingPage;
|
|
||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type RouteDirection,
|
|
||||||
} from "./new-booking-form/schema";
|
} from "./new-booking-form/schema";
|
||||||
import { SelectField } from "./new-booking-form/shared";
|
import { SelectField } from "./new-booking-form/shared";
|
||||||
import { Step5CargoDetails } from "./new-booking-form/steps";
|
import { Step5CargoDetails } from "./new-booking-form/steps";
|
||||||
@@ -90,13 +89,13 @@ function mapBookingToFormValues(
|
|||||||
booking: Freight.IBooking,
|
booking: Freight.IBooking,
|
||||||
referenceData: Freight.BookingReferenceData,
|
referenceData: Freight.BookingReferenceData,
|
||||||
): BookingFormInputValues {
|
): BookingFormInputValues {
|
||||||
const vals: BookingFormInputValues = {
|
const vals = {
|
||||||
...initialBookingFormValues,
|
...initialBookingFormValues,
|
||||||
contractType:
|
contractType:
|
||||||
(booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new",
|
(booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new",
|
||||||
previousContractRef: booking.previousContractId ?? "",
|
previousContractRef: booking.previousContractId ?? "",
|
||||||
serviceType:
|
serviceTypeId:
|
||||||
booking.serviceType === "RAIL_AND_FORWARDING" ? "rail_forwarding" : "rail",
|
referenceData.service.find((s) => s.code === booking.serviceType)?.id ?? "",
|
||||||
firstMile: {
|
firstMile: {
|
||||||
enabled: booking.firstMileEnabled ?? false,
|
enabled: booking.firstMileEnabled ?? false,
|
||||||
pickUpAddress: booking.firstMilePickupAddress ?? "",
|
pickUpAddress: booking.firstMilePickupAddress ?? "",
|
||||||
@@ -120,18 +119,15 @@ function mapBookingToFormValues(
|
|||||||
notes: "",
|
notes: "",
|
||||||
// Terms were accepted at creation; editing shouldn't re-gate on them.
|
// Terms were accepted at creation; editing shouldn't re-gate on them.
|
||||||
termsAccepted: true,
|
termsAccepted: true,
|
||||||
freightType: "",
|
|
||||||
bulkCommoditytype: "",
|
|
||||||
containers: [],
|
containers: [],
|
||||||
};
|
} as BookingFormInputValues;
|
||||||
|
|
||||||
const bookingCargoTypeId = (booking as any).cargoTypeId as string | undefined;
|
const bookingCargoTypeId = (booking as any).cargoTypeId as string | undefined;
|
||||||
if (booking.freightType === "BULK" && bookingCargoTypeId) {
|
if (booking.freightType === "BULK" && bookingCargoTypeId) {
|
||||||
for (const group of referenceData.cargo_type) {
|
for (const group of referenceData.cargo_type) {
|
||||||
const child = group.children?.find((c) => c.id === bookingCargoTypeId);
|
const child = group.children?.find((c) => c.id === bookingCargoTypeId);
|
||||||
if (child) {
|
if (child) {
|
||||||
vals.freightType = group.code.toLowerCase();
|
vals.cargoTypePath = [group.id, child.id];
|
||||||
vals.bulkCommoditytype = child.name;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,16 +295,24 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const serviceType = form.watch("serviceType");
|
const serviceTypeId = form.watch("serviceTypeId");
|
||||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||||
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
|
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
|
||||||
|
|
||||||
const direction: RouteDirection = useMemo(
|
const selectedService = useMemo(
|
||||||
() => getRouteDirection(originYard, destinationYard),
|
() => referenceData?.service.find((s) => s.id === serviceTypeId),
|
||||||
[originYard, destinationYard],
|
[serviceTypeId, referenceData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const direction = useMemo(() => {
|
||||||
|
const origin = referenceData?.yard.find((y) => y.name === originYard);
|
||||||
|
const destination = referenceData?.yard.find(
|
||||||
|
(y) => y.name === destinationYard,
|
||||||
|
);
|
||||||
|
return getRouteDirection(origin, destination);
|
||||||
|
}, [originYard, destinationYard, referenceData]);
|
||||||
|
|
||||||
const yardOptions = useMemo(() => {
|
const yardOptions = useMemo(() => {
|
||||||
if (!referenceData?.yard) return [];
|
if (!referenceData?.yard) return [];
|
||||||
return referenceData.yard.map((y) => ({
|
return referenceData.yard.map((y) => ({
|
||||||
@@ -339,29 +343,17 @@ export default function EditBookingPage() {
|
|||||||
const yards = referenceData?.yard ?? [];
|
const yards = referenceData?.yard ?? [];
|
||||||
const services = referenceData?.service ?? [];
|
const services = referenceData?.service ?? [];
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
const shippingLines = referenceData?.shipping_line ?? [];
|
||||||
const cargoTree = referenceData?.cargo_type ?? [];
|
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
|
|
||||||
const findYardId = (name: string): string =>
|
const findYardId = (name: string): string =>
|
||||||
yards.find((y) => y.name === name)?.id ?? "";
|
yards.find((y) => y.name === name)?.id ?? "";
|
||||||
|
|
||||||
const findServiceTypeId = (): string => {
|
|
||||||
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
|
|
||||||
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const findShippingLineId = (name: string): string | undefined =>
|
const findShippingLineId = (name: string): string | undefined =>
|
||||||
shippingLines.find((l) => l.name === name)?.id;
|
shippingLines.find((l) => l.name === name)?.id;
|
||||||
|
|
||||||
const selectedChild =
|
const cargoTypePath = data.cargoTypePath ?? [];
|
||||||
data.cargoType !== "container" && data.bulkCommoditytype
|
|
||||||
? cargoTree
|
|
||||||
.find((g) => g.code.toLowerCase() === data.freightType)
|
|
||||||
?.children?.find((c) => c.name === data.bulkCommoditytype)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
data.cargoType === "container" ? undefined : (selectedChild?.id ?? "");
|
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
|
||||||
|
|
||||||
const findContainerTypeId = (name: string): string => {
|
const findContainerTypeId = (name: string): string => {
|
||||||
for (const group of containerGroups) {
|
for (const group of containerGroups) {
|
||||||
@@ -379,11 +371,13 @@ export default function EditBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
|
const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
|
||||||
|
|
||||||
const apiPayload: Partial<CreateBookingPayload> = {
|
const apiPayload: Partial<CreateBookingPayload> = {
|
||||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
serviceTypeId: findServiceTypeId(),
|
serviceTypeId: data.serviceTypeId,
|
||||||
equipmentReturn:
|
equipmentReturn:
|
||||||
data.equipmentReturn === "with_return"
|
data.equipmentReturn === "with_return"
|
||||||
? "WITH_RETURN"
|
? "WITH_RETURN"
|
||||||
@@ -391,9 +385,9 @@ export default function EditBookingPage() {
|
|||||||
originYardId: findYardId(data.originYard),
|
originYardId: findYardId(data.originYard),
|
||||||
destinationYardId: findYardId(data.destinationYard),
|
destinationYardId: findYardId(data.destinationYard),
|
||||||
tradeDirection:
|
tradeDirection:
|
||||||
direction === "export"
|
direction === "EXPORT"
|
||||||
? "EXPORT"
|
? "EXPORT"
|
||||||
: direction === "domestic"
|
: direction === "DOMESTIC"
|
||||||
? "DOMESTIC"
|
? "DOMESTIC"
|
||||||
: "IMPORT",
|
: "IMPORT",
|
||||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||||
@@ -420,10 +414,10 @@ export default function EditBookingPage() {
|
|||||||
...(data.contractType === "renewal" && data.previousContractRef
|
...(data.contractType === "renewal" && data.previousContractRef
|
||||||
? { pnrCode: data.previousContractRef }
|
? { pnrCode: data.previousContractRef }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
|
...(selectedSvc?.includesFirstMile && data.firstMile.enabled
|
||||||
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
|
...(selectedSvc?.includesLastMile && data.lastMile.enabled
|
||||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.shippingLine
|
...(data.shippingLine
|
||||||
@@ -523,7 +517,7 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<Controller
|
<Controller
|
||||||
name="serviceType"
|
name="serviceTypeId"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<SelectField
|
<SelectField
|
||||||
@@ -531,13 +525,9 @@ export default function EditBookingPage() {
|
|||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Service Type *"
|
label="Service Type *"
|
||||||
placeholder="Select service type..."
|
placeholder="Select service type..."
|
||||||
data={[
|
data={(referenceData?.service ?? [])
|
||||||
{ value: "rail", label: "Rail Transport Only" },
|
.filter((s) => s.canBeBookedAlone)
|
||||||
{
|
.map((s) => ({ value: s.id, label: s.serviceName }))}
|
||||||
value: "rail_forwarding",
|
|
||||||
label: "Logistics (Rail + Forwarding)",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -560,7 +550,9 @@ export default function EditBookingPage() {
|
|||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
{serviceType === "rail_forwarding" && (
|
{(selectedService?.includesFirstMile ||
|
||||||
|
selectedService?.includesLastMile ||
|
||||||
|
selectedService?.includesCustoms) && (
|
||||||
<Paper withBorder radius="md">
|
<Paper withBorder radius="md">
|
||||||
<Controller
|
<Controller
|
||||||
name="firstMile.enabled"
|
name="firstMile.enabled"
|
||||||
@@ -570,7 +562,7 @@ export default function EditBookingPage() {
|
|||||||
icon={<Truck size={16} color="#6B7C8E" />}
|
icon={<Truck size={16} color="#6B7C8E" />}
|
||||||
title="First Mile - Pick-up"
|
title="First Mile - Pick-up"
|
||||||
description="Truck pick-up from your premises to the origin rail yard."
|
description="Truck pick-up from your premises to the origin rail yard."
|
||||||
checked={field.value}
|
checked={field.value ?? false}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -610,7 +602,7 @@ export default function EditBookingPage() {
|
|||||||
icon={<Truck size={16} color="#6B7C8E" />}
|
icon={<Truck size={16} color="#6B7C8E" />}
|
||||||
title="Last Mile - Delivery"
|
title="Last Mile - Delivery"
|
||||||
description="Truck delivery from the destination rail yard to the final address."
|
description="Truck delivery from the destination rail yard to the final address."
|
||||||
checked={field.value}
|
checked={field.value ?? false}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -650,7 +642,7 @@ export default function EditBookingPage() {
|
|||||||
icon={<FileText size={16} color="#6B7C8E" />}
|
icon={<FileText size={16} color="#6B7C8E" />}
|
||||||
title="Customs Clearing Service"
|
title="Customs Clearing Service"
|
||||||
description="EDR handles customs documentation and clearance on your behalf."
|
description="EDR handles customs documentation and clearance on your behalf."
|
||||||
checked={field.value}
|
checked={field.value ?? false}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -710,7 +702,7 @@ export default function EditBookingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{direction && direction !== "domestic" && (
|
{direction && direction !== "DOMESTIC" && (
|
||||||
<Controller
|
<Controller
|
||||||
name="shippingLine"
|
name="shippingLine"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -763,7 +755,7 @@ export default function EditBookingPage() {
|
|||||||
<Box>
|
<Box>
|
||||||
<Step5CargoDetails
|
<Step5CargoDetails
|
||||||
form={form}
|
form={form}
|
||||||
direction={direction}
|
direction={direction!}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
isLoading={!referenceData}
|
isLoading={!referenceData}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -179,11 +179,7 @@ export default function MyBookings() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original;
|
const b = row.original;
|
||||||
const cargoLabel =
|
const cargoLabel =
|
||||||
b.freightType === "BULK"
|
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
|
||||||
? "Bulk Cargo"
|
|
||||||
: b.freightType === "BREAK_BULK"
|
|
||||||
? "Break Bulk"
|
|
||||||
: "Cargo";
|
|
||||||
return (
|
return (
|
||||||
<Group gap={12} wrap="nowrap" align="center">
|
<Group gap={12} wrap="nowrap" align="center">
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -193,7 +193,6 @@ export default function NewBookingPage() {
|
|||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
// @ts-ignore
|
|
||||||
freightType:
|
freightType:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? ("CONTAINER" as const)
|
? ("CONTAINER" as const)
|
||||||
@@ -322,7 +321,12 @@ export default function NewBookingPage() {
|
|||||||
)}
|
)}
|
||||||
{step === 6 && <StepDocuments form={form} />}
|
{step === 6 && <StepDocuments form={form} />}
|
||||||
{step === 7 && (
|
{step === 7 && (
|
||||||
<Step8Review form={form} setStep={setStep} direction={direction!} />
|
<Step8Review
|
||||||
|
form={form}
|
||||||
|
setStep={setStep}
|
||||||
|
direction={direction!}
|
||||||
|
referenceData={referenceData}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ import type { Freight } from "@edr/types";
|
|||||||
import { DeepPartial, Path } from "react-hook-form";
|
import { DeepPartial, Path } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
export const MOCK_VALID_CONTRACTS = [
|
|
||||||
"EDR-2024-10001",
|
|
||||||
"EDR-2024-10002",
|
|
||||||
"EDR-2023-88123",
|
|
||||||
"EDR-2022-55442",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
{ id: 1, label: "Contract Type", short: "Contract" },
|
{ id: 1, label: "Contract Type", short: "Contract" },
|
||||||
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
{ id: 2, label: "Service Type & Mile", short: "Service" },
|
||||||
@@ -75,6 +68,7 @@ export const bookingFormSchema = z
|
|||||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||||
previousContractRef: z.string(),
|
previousContractRef: z.string(),
|
||||||
serviceTypeId: z.string("Select a service type."),
|
serviceTypeId: z.string("Select a service type."),
|
||||||
|
|
||||||
firstMile: z
|
firstMile: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
@@ -218,6 +212,7 @@ export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
|
|||||||
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||||
previousContractRef: "",
|
previousContractRef: "",
|
||||||
|
|
||||||
|
serviceTypeId: "",
|
||||||
firstMile: {
|
firstMile: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
pickUpAddress: "",
|
pickUpAddress: "",
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
|
||||||
import { Field, FieldError, Input, Switch } from "@edr/ui-common";
|
|
||||||
import { type BookingFormValues } from "./schema";
|
|
||||||
import { StepHeader } from "./shared";
|
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
|
||||||
|
|
||||||
export function Step3FirstLastMile({ form }: { form: BookingForm }) {
|
|
||||||
const firstMileEnabled = form.watch("firstMileEnabled");
|
|
||||||
const lastMileEnabled = form.watch("lastMileEnabled");
|
|
||||||
const equipmentReturn = form.watch("equipmentReturn");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<StepHeader
|
|
||||||
title="First & Last Mile"
|
|
||||||
description="Configure trucking and container return options."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="divide-y divide-border rounded-xl border border-border">
|
|
||||||
<div className="p-4">
|
|
||||||
<Controller
|
|
||||||
name="firstMileEnabled"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">First Mile - Pick-up</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
Truck pick-up from your premises (Door to Port) to the
|
|
||||||
origin rail yard.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
if (!value) {
|
|
||||||
form.setValue("pickUpAddress", "", {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{firstMileEnabled && (
|
|
||||||
<Controller
|
|
||||||
name="pickUpAddress"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field className="mt-3" data-invalid={fieldState.invalid}>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Pick-up address *"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<Controller
|
|
||||||
name="lastMileEnabled"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Last Mile - Delivery</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
Truck delivery from the destination rail yard to the final
|
|
||||||
address (Port to Door).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
if (!value) {
|
|
||||||
form.setValue("deliveryAddress", "", {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{lastMileEnabled && (
|
|
||||||
<Controller
|
|
||||||
name="deliveryAddress"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field className="mt-3" data-invalid={fieldState.invalid}>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Delivery address *"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lastMileEnabled && (
|
|
||||||
<div className="mt-4 border-t border-border pt-4">
|
|
||||||
<Controller
|
|
||||||
name="equipmentReturn"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Equipment Return</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
{field.value === "with_return"
|
|
||||||
? "Container returned to EDR after unloading."
|
|
||||||
: "Container retained by the customer after delivery."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={field.value === "with_return"}
|
|
||||||
onCheckedChange={(value) => {
|
|
||||||
field.onChange(
|
|
||||||
value ? "with_return" : "without_return",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { SelectField, StepHeader, StepLabel } from "./shared";
|
import { SelectField, StepHeader, StepLabel } from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step4Route({
|
export function Step4Route({
|
||||||
form,
|
form,
|
||||||
@@ -31,44 +35,36 @@ export function Step4Route({
|
|||||||
|
|
||||||
const shippingLineOptions = useMemo(() => {
|
const shippingLineOptions = useMemo(() => {
|
||||||
if (!referenceData?.shipping_line) return [];
|
if (!referenceData?.shipping_line) return [];
|
||||||
return referenceData.shipping_line.map((sl) => ({ value: sl.name, label: sl.name }));
|
return referenceData.shipping_line.map((sl) => ({
|
||||||
|
value: sl.name,
|
||||||
|
label: sl.name,
|
||||||
|
}));
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const originData = useMemo(
|
const originData = useMemo(() => {
|
||||||
() => {
|
return yardOptions
|
||||||
return yardOptions.filter((o) => o.value !== destinationYard).filter((o) => {
|
.filter((o) => o.value !== destinationYard)
|
||||||
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
.filter((o) => {
|
||||||
if(!dest) return true;
|
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
|
||||||
|
if (!dest) return true;
|
||||||
const origin = referenceData?.yard.find((y) => y.id === o.value);
|
const origin = referenceData?.yard.find((y) => y.id === o.value);
|
||||||
|
|
||||||
// can't go from Djibouti to Djibouti
|
|
||||||
if(dest?.country === 'Djibouti' && origin?.country == 'Djibouti') return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[yardOptions, destinationYard],
|
|
||||||
);
|
|
||||||
console.log({yardOptions,originYard, destinationYard})
|
|
||||||
const destData = useMemo(
|
|
||||||
() => {
|
|
||||||
return yardOptions.filter((o) => o.value !== originYard).filter((d) => {
|
|
||||||
|
|
||||||
|
|
||||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
|
||||||
if(!origin) return true;
|
|
||||||
|
|
||||||
const dest = referenceData?.yard.find((y) => y.id === d.value);
|
|
||||||
// can't go from Djibouti to Djibouti
|
// can't go from Djibouti to Djibouti
|
||||||
// if(origin.country === 'Djibouti' && dest?.country == 'Djibouti') return false;
|
if (dest?.country === "Djibouti" && origin?.country == "Djibouti")
|
||||||
|
return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
},
|
}, [yardOptions, destinationYard]);
|
||||||
[yardOptions, originYard],
|
console.log({ yardOptions, originYard, destinationYard });
|
||||||
);
|
const destData = useMemo(() => {
|
||||||
|
return yardOptions.filter((o) => o.value !== originYard);
|
||||||
|
}, [yardOptions, originYard]);
|
||||||
|
|
||||||
const direction = getRouteDirection(referenceData?.yard.find((y) => y.id === originYard), referenceData?.yard.find((y) => y.name === destinationYard));
|
const direction = getRouteDirection(
|
||||||
|
referenceData?.yard.find((y) => y.id === originYard),
|
||||||
|
referenceData?.yard.find((y) => y.name === destinationYard),
|
||||||
|
);
|
||||||
|
|
||||||
const directionStyle: Record<string, string> = {
|
const directionStyle: Record<string, string> = {
|
||||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
|
||||||
import { Field, FieldError, SmartFileInput } from "@edr/ui-common";
|
|
||||||
import {
|
|
||||||
BOOKING_DOCS_SETTING,
|
|
||||||
REQUIRED_DOC_KEYS,
|
|
||||||
type BookingFormValues,
|
|
||||||
} from "./schema";
|
|
||||||
import { getUploadedRequiredCount, StepHeader } from "./shared";
|
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
|
||||||
|
|
||||||
export function Step7Documents({ form }: { form: BookingForm }) {
|
|
||||||
const documents = form.watch("documents");
|
|
||||||
const uploadedRequired = getUploadedRequiredCount(documents);
|
|
||||||
const documentErrors = form.formState.errors.documents as
|
|
||||||
| Record<string, { message?: string }>
|
|
||||||
| undefined;
|
|
||||||
const smartFileErrors = Object.fromEntries(
|
|
||||||
Object.entries(documentErrors ?? {}).map(([key, value]) => [
|
|
||||||
key,
|
|
||||||
value?.message ?? "",
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<StepHeader
|
|
||||||
title="Compliance Documents"
|
|
||||||
description="Upload your company's legal credentials for EDR contract eligibility verification (US-04)."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
|
||||||
<div
|
|
||||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold ${uploadedRequired === REQUIRED_DOC_KEYS.length
|
|
||||||
? "bg-emerald-100 text-emerald-700"
|
|
||||||
: "bg-primary/10 text-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{uploadedRequired}/{REQUIRED_DOC_KEYS.length}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{uploadedRequired < REQUIRED_DOC_KEYS.length
|
|
||||||
? `${REQUIRED_DOC_KEYS.length - uploadedRequired} mandatory document(s) still needed.`
|
|
||||||
: "All mandatory documents uploaded. Power of Attorney is optional."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="documents"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<SmartFileInput
|
|
||||||
file={BOOKING_DOCS_SETTING}
|
|
||||||
value={field.value}
|
|
||||||
onChange={(value) => field.onChange(value)}
|
|
||||||
errors={smartFileErrors}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea, Title } from "@mantine/core";
|
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
@@ -9,19 +9,28 @@ import {
|
|||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
import type { Freight } from "@/types";
|
import type { Freight } from "@/types";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step8Review({
|
export function Step8Review({
|
||||||
form,
|
form,
|
||||||
setStep,
|
setStep,
|
||||||
direction,
|
direction,
|
||||||
|
referenceData,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
direction: Freight.ScheduleTradeDirection;
|
direction: Freight.ScheduleTradeDirection;
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
}) {
|
}) {
|
||||||
const values = form.watch();
|
const values = form.watch();
|
||||||
const errors = form.formState.errors;
|
const errors = form.formState.errors;
|
||||||
|
const serviceType = referenceData?.service.find(
|
||||||
|
(s) => s.id === values.serviceTypeId,
|
||||||
|
);
|
||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
label,
|
label,
|
||||||
@@ -56,17 +65,17 @@ export function Step8Review({
|
|||||||
const containerSummary =
|
const containerSummary =
|
||||||
values.cargoType === "container" && values.containers.length > 0
|
values.cargoType === "container" && values.containers.length > 0
|
||||||
? values.containers
|
? values.containers
|
||||||
.filter((c) => +c.qty > 0)
|
.filter((c) => +c.qty > 0)
|
||||||
.map((c) => `${c.qty} × ${c.type}`)
|
.map((c) => `${c.qty} × ${c.type}`)
|
||||||
.join(", ")
|
.join(", ")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const totalVgm =
|
const totalVgm =
|
||||||
values.cargoType === "container"
|
values.cargoType === "container"
|
||||||
? values.containers.reduce(
|
? values.containers.reduce(
|
||||||
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
|
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const documents = (values.documents ?? {}) as BookingDocuments;
|
const documents = (values.documents ?? {}) as BookingDocuments;
|
||||||
@@ -76,14 +85,15 @@ export function Step8Review({
|
|||||||
}).length;
|
}).length;
|
||||||
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
|
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
|
||||||
|
|
||||||
const cargoValue =
|
const cargoValue = (() => {
|
||||||
values.cargoType === "container"
|
if (values.cargoType === "container") return containerSummary;
|
||||||
? containerSummary
|
if (!referenceData) return "";
|
||||||
: values.freightType === "bulk"
|
const path = values.cargoTypePath ?? [];
|
||||||
? `Bulk — ${values.bulkCommoditytype}`
|
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
|
||||||
: values.freightType === "break_bulk"
|
if (!group) return "";
|
||||||
? `Break-Bulk`
|
const child = group.children?.find((c) => c.id === path[1]);
|
||||||
: "";
|
return child ? `${group.name} — ${child.name}` : group.name;
|
||||||
|
})();
|
||||||
|
|
||||||
function ReviewCard({
|
function ReviewCard({
|
||||||
title,
|
title,
|
||||||
@@ -99,7 +109,13 @@ export function Step8Review({
|
|||||||
py="sm"
|
py="sm"
|
||||||
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
|
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wider">
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
tt="uppercase"
|
||||||
|
c="dimmed"
|
||||||
|
className="tracking-wider"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -124,17 +140,7 @@ export function Step8Review({
|
|||||||
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
value={values.contractType === "new" ? "New Contract" : "Renewal"}
|
||||||
target={1}
|
target={1}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row label="Service" value={serviceType?.name ?? ""} target={2} />
|
||||||
label="Service"
|
|
||||||
value={
|
|
||||||
values.serviceType === "rail"
|
|
||||||
? "Rail Only"
|
|
||||||
: values.serviceType === "rail_forwarding"
|
|
||||||
? "Rail + Forwarding"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
target={2}
|
|
||||||
/>
|
|
||||||
</ReviewCard>
|
</ReviewCard>
|
||||||
|
|
||||||
<ReviewCard title="First & Last Mile">
|
<ReviewCard title="First & Last Mile">
|
||||||
@@ -260,9 +266,7 @@ export function Step8Review({
|
|||||||
}
|
}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||||
error={
|
error={fieldState.error?.message ?? errors.termsAccepted?.message}
|
||||||
fieldState.error?.message ?? errors.termsAccepted?.message
|
|
||||||
}
|
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { CheckCircle2, Loader2, Save, UserCheck, XCircle } from "lucide-react";
|
import { CheckCircle2, Save, UserCheck, XCircle } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
@@ -100,8 +100,8 @@ export default function TabPowerOfAttorney({
|
|||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text c="edr-muted" size="sm">
|
<Text c="edr-muted" size="sm">
|
||||||
Power of Attorney details are optional. Fill them in if you have
|
Power of Attorney details are optional. Fill them in if you have an
|
||||||
an authorized representative, or leave blank.
|
authorized representative, or leave blank.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -162,13 +162,17 @@ export default function TabPowerOfAttorney({
|
|||||||
{mutation.isSuccess && (
|
{mutation.isSuccess && (
|
||||||
<Group gap={6} c="green">
|
<Group gap={6} c="green">
|
||||||
<CheckCircle2 size={16} />
|
<CheckCircle2 size={16} />
|
||||||
<Text size="sm" fw={500}>Saved successfully</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
Saved successfully
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{mutation.isError && (
|
{mutation.isError && (
|
||||||
<Group gap={6} c="red">
|
<Group gap={6} c="red">
|
||||||
<XCircle size={16} />
|
<XCircle size={16} />
|
||||||
<Text size="sm" fw={500}>Save failed</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
Save failed
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Calendar, MapPin } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import { consignments } from "../consignments/consignments.mock";
|
|
||||||
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
|
|
||||||
|
|
||||||
export interface ShipmentFormData {
|
|
||||||
consignmentId?: number;
|
|
||||||
consignmentReference?: string;
|
|
||||||
originStation?: string;
|
|
||||||
destinationStation?: string;
|
|
||||||
mode?: ShipmentMode;
|
|
||||||
status?: ShipmentStatus;
|
|
||||||
currentLocation?: string;
|
|
||||||
eta?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewShipmentPageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
shipment?: ShipmentFormData;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
export default function NewShipmentPage({
|
|
||||||
mode = "create",
|
|
||||||
shipment,
|
|
||||||
children,
|
|
||||||
}: NewShipmentPageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Shipment" : "New Shipment";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update shipment tracking information."
|
|
||||||
: "Create a new shipment for real-time tracking.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Shipment";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Shipment"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* Consignment */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Consignment *</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={shipment?.consignmentId ?? ""}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select consignment
|
|
||||||
</option>
|
|
||||||
{consignments.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.trackingNumber} — {c.customer} ({c.originStation} →{" "}
|
|
||||||
{c.destinationStation})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Origin and destination auto-fill from the linked consignment.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={shipment?.status ?? "In Transit"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>In Transit</option>
|
|
||||||
<option>Delivered</option>
|
|
||||||
<option>Delayed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Transport Mode</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={shipment?.mode ?? "rail"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="rail">Rail</option>
|
|
||||||
<option value="truck">Truck</option>
|
|
||||||
<option value="multimodal">Multimodal</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Origin */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Origin Station</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={shipment?.originStation ?? ""}
|
|
||||||
placeholder="Auto-filled from consignment"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destination */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Destination Station</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={shipment?.destinationStation ?? ""}
|
|
||||||
placeholder="Auto-filled from consignment"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ETA */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Estimated Arrival</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={shipment?.eta ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Location */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Current Location</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={shipment?.currentLocation ?? ""}
|
|
||||||
placeholder="e.g. Dire Dawa Yard"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
List,
|
List,
|
||||||
MapPin,
|
MapPin,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
Search,
|
||||||
Train,
|
Train,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -17,7 +15,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import NewShipmentPage from "./NewShipmentPage";
|
|
||||||
import DeleteShipmentDialog from "./DeleteShipmentDialog";
|
import DeleteShipmentDialog from "./DeleteShipmentDialog";
|
||||||
import {
|
import {
|
||||||
shipments,
|
shipments,
|
||||||
@@ -93,13 +90,6 @@ export default function TrackingPage() {
|
|||||||
className="pl-8!"
|
className="pl-8!"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewShipmentPage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
New Shipment
|
|
||||||
</Button>
|
|
||||||
</NewShipmentPage>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -279,7 +269,10 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t pt-3" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
<div
|
||||||
|
className="flex items-center justify-end gap-2 border-t pt-3"
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -292,27 +285,12 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
<Eye />
|
<Eye />
|
||||||
View
|
View
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<NewShipmentPage
|
|
||||||
mode="edit"
|
|
||||||
shipment={{
|
|
||||||
bookingId: shipment.bookingId,
|
|
||||||
bookingReference: shipment.bookingReference,
|
|
||||||
originStation: shipment.originStation,
|
|
||||||
destinationStation: shipment.destinationStation,
|
|
||||||
mode: shipment.mode,
|
|
||||||
status: shipment.status,
|
|
||||||
currentLocation: shipment.currentLocation,
|
|
||||||
eta: shipment.eta,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewShipmentPage>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()} variant="destructive">
|
<DropdownMenuItem
|
||||||
|
onSelect={(e: Event) => e.preventDefault()}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
Remove
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -331,7 +309,9 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
<CardHeader className="flex flex-row items-center justify-between border-b">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Shipment List</CardTitle>
|
<CardTitle>Shipment List</CardTitle>
|
||||||
<CardDescription>All shipments and their current status.</CardDescription>
|
<CardDescription>
|
||||||
|
All shipments and their current status.
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -435,27 +415,14 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
<Eye />
|
<Eye />
|
||||||
View
|
View
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<NewShipmentPage
|
|
||||||
mode="edit"
|
|
||||||
shipment={{
|
|
||||||
bookingId: shipment.bookingId,
|
|
||||||
bookingReference: shipment.bookingReference,
|
|
||||||
originStation: shipment.originStation,
|
|
||||||
destinationStation: shipment.destinationStation,
|
|
||||||
mode: shipment.mode,
|
|
||||||
status: shipment.status,
|
|
||||||
currentLocation: shipment.currentLocation,
|
|
||||||
eta: shipment.eta,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewShipmentPage>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
<DeleteShipmentDialog
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()} variant="destructive">
|
shipmentReference={shipment.reference}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e: Event) => e.preventDefault()}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
Remove
|
Remove
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
CreateBookingPayload,
|
CreateBookingPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
} from "./bookings.service";
|
} from "./bookings.service";
|
||||||
|
import {
|
||||||
|
paymentsService,
|
||||||
|
InitiatePaymentPayload,
|
||||||
|
InitiateResponse,
|
||||||
|
IntentStatus,
|
||||||
|
} from "./payments.service";
|
||||||
import { consignmentsService } from "./consignments.service";
|
import { consignmentsService } from "./consignments.service";
|
||||||
import { trackingService } from "./tracking.service";
|
import { trackingService } from "./tracking.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
@@ -31,6 +37,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
|
DashboardSummary,
|
||||||
} from "./companies.service";
|
} from "./companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import type {
|
import type {
|
||||||
@@ -113,6 +120,12 @@ export const api = {
|
|||||||
"updateProfile",
|
"updateProfile",
|
||||||
companiesService.updateProfile,
|
companiesService.updateProfile,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getDashboard: endpoint<void, DashboardSummary>(
|
||||||
|
"companies",
|
||||||
|
"getDashboard",
|
||||||
|
companiesService.getDashboard,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
@@ -174,12 +187,6 @@ export const api = {
|
|||||||
bookingsService.uploadDocuments(id, files),
|
bookingsService.uploadDocuments(id, files),
|
||||||
),
|
),
|
||||||
|
|
||||||
pay: endpoint<{ id: string }, { redirectUrl: string }>(
|
|
||||||
"bookings",
|
|
||||||
"pay",
|
|
||||||
({ id }) => bookingsService.pay(id),
|
|
||||||
),
|
|
||||||
|
|
||||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"checkPayment",
|
"checkPayment",
|
||||||
@@ -194,6 +201,20 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
payments: {
|
||||||
|
initiate: endpoint<InitiatePaymentPayload, InitiateResponse>(
|
||||||
|
"payments",
|
||||||
|
"initiate",
|
||||||
|
paymentsService.initiate,
|
||||||
|
),
|
||||||
|
|
||||||
|
getIntent: endpoint<{ bookingId: string }, IntentStatus>(
|
||||||
|
"payments",
|
||||||
|
"getIntent",
|
||||||
|
({ bookingId }) => paymentsService.getIntent(bookingId),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
consignments: {
|
consignments: {
|
||||||
list: endpoint<void, PaginatedResponse<Freight.IConsignment>>(
|
list: endpoint<void, PaginatedResponse<Freight.IConsignment>>(
|
||||||
"consignments",
|
"consignments",
|
||||||
|
|||||||
@@ -139,11 +139,6 @@ export const bookingsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
pay: async (id: string): Promise<{ redirectUrl: string }> => {
|
|
||||||
const { data } = await client.post(`/api/bookings/${id}/payment/pay`);
|
|
||||||
return data.data ?? data;
|
|
||||||
},
|
|
||||||
|
|
||||||
signContract: async (
|
signContract: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: SignContractPayload,
|
payload: SignContractPayload,
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ export interface CreateCompanyPayload {
|
|||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FreightVolumePoint {
|
||||||
|
month: string;
|
||||||
|
tonnes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
deliveredCount: number;
|
||||||
|
completionRate: number;
|
||||||
|
spendYtd: number;
|
||||||
|
spendCurrency: string;
|
||||||
|
spendYtdChangePct: number;
|
||||||
|
freightVolume: {
|
||||||
|
totalTonnes: number;
|
||||||
|
totalValue: number;
|
||||||
|
currency: string;
|
||||||
|
ytdChangePct: number;
|
||||||
|
monthly: FreightVolumePoint[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const companiesService = {
|
export const companiesService = {
|
||||||
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
||||||
try {
|
try {
|
||||||
@@ -97,6 +117,13 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDashboard: async (): Promise<DashboardSummary> => {
|
||||||
|
const response = await client.get<ApiResponse<DashboardSummary>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.DASHBOARD,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
uploadDocuments: async (
|
uploadDocuments: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
files: Record<string, File | File[] | null>,
|
files: Record<string, File | File[] | null>,
|
||||||
|
|||||||
86
apps/edr-freight-web/portal/src/services/payments.service.ts
Normal file
86
apps/edr-freight-web/portal/src/services/payments.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const P = URL_CONSTANTS.PAYMENTS;
|
||||||
|
|
||||||
|
/** Payment methods supported by the central payment microservice. */
|
||||||
|
export type PaymentMethod =
|
||||||
|
| "TELEBIRR"
|
||||||
|
| "CBE_BIRR"
|
||||||
|
| "EBIRR"
|
||||||
|
| "WAAFI"
|
||||||
|
| "CARD"
|
||||||
|
| "DMONEY"
|
||||||
|
| "CAC_BANK";
|
||||||
|
|
||||||
|
export type PaymentPlatform = "web" | "mobile";
|
||||||
|
|
||||||
|
export interface InitiatePaymentPayload {
|
||||||
|
bookingId: string;
|
||||||
|
method: PaymentMethod;
|
||||||
|
platform?: PaymentPlatform;
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientAction {
|
||||||
|
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||||
|
url?: string;
|
||||||
|
appId?: string;
|
||||||
|
receiveCode?: string;
|
||||||
|
shortCode?: string;
|
||||||
|
providerOrderId?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitiateResponse {
|
||||||
|
intentId: string;
|
||||||
|
status: string;
|
||||||
|
clientAction?: ClientAction;
|
||||||
|
merchantOrderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentStatus extends InitiateResponse {
|
||||||
|
paidAt?: string;
|
||||||
|
failureCode?: string;
|
||||||
|
failureMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the absolute URL for the public browser-checkout page, which
|
||||||
|
* (re)initiates the payment and auto-redirects to the provider's checkout.
|
||||||
|
* Used as the "pay" step after a successful `initiate`.
|
||||||
|
*/
|
||||||
|
function buildCheckoutUrl(payload: {
|
||||||
|
bookingId: string;
|
||||||
|
method: PaymentMethod;
|
||||||
|
platform?: PaymentPlatform;
|
||||||
|
}): string {
|
||||||
|
const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
bookingId: payload.bookingId,
|
||||||
|
method: payload.method,
|
||||||
|
platform: payload.platform ?? "web",
|
||||||
|
});
|
||||||
|
return `${base}${P.CHECKOUT}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentsService = {
|
||||||
|
initiate: async (
|
||||||
|
payload: InitiatePaymentPayload,
|
||||||
|
): Promise<InitiateResponse> => {
|
||||||
|
const { data } = await client.post(P.INITIATE, {
|
||||||
|
platform: "web",
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getIntent: async (bookingId: string): Promise<IntentStatus> => {
|
||||||
|
const { data } = await client.get(P.INTENT(bookingId));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
checkoutUrl: buildCheckoutUrl,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user