mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add dashboard summary endpoint and related DTOs for company KPIs
This commit is contained in:
@@ -15,6 +15,7 @@ import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
@@ -45,6 +46,12 @@ export class CompaniesController {
|
||||
return new ProfileResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
||||
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
||||
return this.companiesService.getDashboardSummary(user.id);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||
async updateProfile(
|
||||
|
||||
@@ -6,14 +6,16 @@ import { CompaniesService } from './companies.service';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
@@ -9,6 +10,7 @@ import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
@@ -27,6 +29,7 @@ export class CompaniesService {
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly ffClientsRepo: FFClientRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
) {}
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
@@ -98,6 +101,122 @@ export class CompaniesService {
|
||||
return { profile, company };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
||||
* current user's company bookings. All figures are scoped to that company.
|
||||
*
|
||||
* Note: delivered/spend/volume all derive from the bookings table — there is
|
||||
* no separate data source for them. On-time delivery rate is replaced by
|
||||
* completion rate (delivered ÷ committed): the schema has no ETA /
|
||||
* promised-delivery date, so on-time cannot be computed.
|
||||
*
|
||||
* Period attribution uses booking.created_at: there is no delivery-date
|
||||
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||
* delivered/completed status.
|
||||
*/
|
||||
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
|
||||
// A user without a company profile has no bookings — return an empty summary
|
||||
// rather than 404, so the portal home still renders.
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||
if (!companyId) return this.emptyDashboardSummary();
|
||||
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||
// Same point in the previous year, so YoY compares like-for-like windows.
|
||||
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
|
||||
|
||||
const [
|
||||
deliveredThis,
|
||||
committedThis,
|
||||
spendThisByCcy,
|
||||
spendPrevByCcy,
|
||||
tonnageThis,
|
||||
tonnagePrev,
|
||||
monthlyRows,
|
||||
] = await Promise.all([
|
||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
||||
]);
|
||||
|
||||
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
||||
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||
|
||||
return {
|
||||
deliveredCount: deliveredThis,
|
||||
// Share of committed bookings that reached delivered/completed.
|
||||
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
|
||||
spendYtd: spend.total,
|
||||
spendCurrency: spend.currency,
|
||||
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||
freightVolume: {
|
||||
totalTonnes: Math.round(tonnageThis),
|
||||
totalValue: spend.total,
|
||||
currency: spend.currency,
|
||||
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
|
||||
monthly: this.buildMonthlySeries(now, monthlyRows),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private emptyDashboardSummary(): DashboardSummaryResponseDto {
|
||||
const now = new Date();
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
completionRate: 0,
|
||||
spendYtd: 0,
|
||||
spendCurrency: 'ETB',
|
||||
spendYtdChangePct: 0,
|
||||
freightVolume: {
|
||||
totalTonnes: 0,
|
||||
totalValue: 0,
|
||||
currency: 'ETB',
|
||||
ytdChangePct: 0,
|
||||
monthly: this.buildMonthlySeries(now, []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** First day of the month `n` months before `from`. */
|
||||
private monthsAgo(from: Date, n: number): Date {
|
||||
return new Date(from.getFullYear(), from.getMonth() - n, 1);
|
||||
}
|
||||
|
||||
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
||||
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
||||
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||
}
|
||||
|
||||
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
|
||||
private changePct(current: number, previous: number): number {
|
||||
if (previous <= 0) return 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
|
||||
private buildMonthlySeries(
|
||||
now: Date,
|
||||
rows: { year: number; month: number; tonnes: number }[],
|
||||
): { month: string; tonnes: number }[] {
|
||||
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||
const series: { month: string; tonnes: number }[] = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/** Booking statuses that represent a delivered/finished shipment. */
|
||||
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
|
||||
|
||||
/**
|
||||
* Statuses that represent real, committed freight (excludes drafts and dead
|
||||
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
|
||||
*/
|
||||
const COMMITTED_STATUSES = [
|
||||
'APPROVED',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'COMPLETED',
|
||||
'DELIVERED',
|
||||
'CONSOLIDATED',
|
||||
] as const;
|
||||
|
||||
export interface CurrencyTotal {
|
||||
currency: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MonthlyTonnage {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
tonnes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only aggregation queries against the bookings table, scoped to a
|
||||
* company, that back the portal dashboard. Lives in the companies module so it
|
||||
* can be exposed via `companies.controller` without a circular dependency on
|
||||
* BookingsModule (which already imports CompaniesModule).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CompanyDashboardRepository {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookings: Repository<Booking>,
|
||||
) {}
|
||||
|
||||
/** Count of delivered/completed bookings for a company within [from, to). */
|
||||
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
||||
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
||||
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('b.payment_currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere("b.payment_status = 'PAID'")
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.groupBy('b.payment_currency')
|
||||
.getRawMany<{ currency: string; total: string }>();
|
||||
|
||||
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||
}
|
||||
|
||||
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
||||
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
const row = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getRawOne<{ total: string }>();
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Committed tonnage grouped by calendar month within [from, to). */
|
||||
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.groupBy('year')
|
||||
.addGroupBy('month')
|
||||
.getRawMany<{ year: string; month: string; total: string }>();
|
||||
|
||||
return rows.map((r) => ({
|
||||
year: Number(r.year),
|
||||
month: Number(r.month),
|
||||
tonnes: Number(r.total),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class FreightVolumePointDto {
|
||||
@ApiProperty({ example: 'May', description: 'Short month label' })
|
||||
month!: string;
|
||||
|
||||
@ApiProperty({ example: 940, description: 'Tonnage shipped in the month' })
|
||||
tonnes!: number;
|
||||
}
|
||||
|
||||
export class FreightVolumeDto {
|
||||
@ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' })
|
||||
totalTonnes!: number;
|
||||
|
||||
@ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' })
|
||||
totalValue!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' })
|
||||
ytdChangePct!: number;
|
||||
|
||||
@ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' })
|
||||
monthly!: FreightVolumePointDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* KPIs for the portal dashboard (MyPortalPage), aggregated from the current
|
||||
* user's company bookings. All figures are scoped to that company.
|
||||
*
|
||||
* Note: every metric here derives from the bookings table — there is no
|
||||
* separate "non-booking" data source for delivered/spend/volume. On-time
|
||||
* delivery rate is replaced by completion rate: no ETA / promised-delivery
|
||||
* column exists in the schema, so on-time cannot be computed, whereas
|
||||
* completion rate (delivered ÷ committed) can.
|
||||
*/
|
||||
export class DashboardSummaryResponseDto {
|
||||
@ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' })
|
||||
deliveredCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 92,
|
||||
description: 'Share of committed bookings that have been delivered/completed (YTD), in percent',
|
||||
})
|
||||
completionRate!: number;
|
||||
|
||||
@ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' })
|
||||
spendYtd!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
spendCurrency!: string;
|
||||
|
||||
@ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' })
|
||||
spendYtdChangePct!: number;
|
||||
|
||||
@ApiProperty({ type: FreightVolumeDto })
|
||||
freightVolume!: FreightVolumeDto;
|
||||
}
|
||||
@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
|
||||
GET_INFO: "/api/companies/getInfo",
|
||||
CREATE: "/api/companies/create",
|
||||
PROFILE: "/api/companies/profile",
|
||||
DASHBOARD: "/api/companies/dashboard",
|
||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
||||
import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
|
||||
import { shipments, type Shipment } from "@/pages/tracking/shipments.mock";
|
||||
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
||||
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
||||
|
||||
/**
|
||||
* Mock "logged-in customer". When auth integrates, replace this with the value
|
||||
@@ -16,16 +14,6 @@ export function getCurrentCustomer(): Customer {
|
||||
);
|
||||
}
|
||||
|
||||
export function getMyBookings(): Booking[] {
|
||||
const me = getCurrentCustomer();
|
||||
return bookings.filter((b) => b.customerId === me.id);
|
||||
}
|
||||
|
||||
export function getMyShipments(): Shipment[] {
|
||||
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
|
||||
return shipments.filter((s) => myBookingIds.has(s.bookingId));
|
||||
}
|
||||
|
||||
export function getMyInvoices(): Invoice[] {
|
||||
const me = getCurrentCustomer();
|
||||
return invoices.filter((inv) => inv.customerId === me.id);
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
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 { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { api } from "@/services/api";
|
||||
@@ -38,6 +38,9 @@ const cv = (token: string) => {
|
||||
return `var(--mantine-color-${name}-${shade ?? "6"})`;
|
||||
};
|
||||
|
||||
/** Format a signed percentage for KPI deltas, e.g. 16 → "+16%", -4 → "-4%". */
|
||||
const formatPct = (n: number) => `${n >= 0 ? "+" : ""}${n}%`;
|
||||
|
||||
const ACTIVE_STATUSES = [
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
@@ -356,12 +359,8 @@ const INVOICE_BADGE: Record<
|
||||
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
||||
};
|
||||
|
||||
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
|
||||
const VOLUME_DATA = [420, 680, 510, 820, 750, 940];
|
||||
|
||||
export default function MyPortalPage() {
|
||||
const { user, customer } = useAuth();
|
||||
const myShipments = useMemo(() => getMyShipments(), []);
|
||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -371,10 +370,17 @@ export default function MyPortalPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions());
|
||||
const dashboard = dashboardQuery.data;
|
||||
|
||||
const allBookings = bookingsQuery.data?.items ?? [];
|
||||
const activeBookings = allBookings.filter((b) =>
|
||||
ACTIVE_STATUSES.includes(b.status),
|
||||
);
|
||||
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const newActiveThisWeek = activeBookings.filter(
|
||||
(b) => new Date(b.createdAt).getTime() >= weekAgo,
|
||||
).length;
|
||||
|
||||
const visibleBookings = allBookings;
|
||||
const outstandingInvoices = myInvoices.filter(
|
||||
@@ -384,9 +390,6 @@ export default function MyPortalPage() {
|
||||
(sum, inv) => sum + inv.amount,
|
||||
0,
|
||||
);
|
||||
const deliveredCount =
|
||||
myShipments.filter((s) => s.status === "Delivered").length || 12;
|
||||
|
||||
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
||||
const companyName = (customer as any)?.companyName ?? displayName;
|
||||
|
||||
@@ -398,7 +401,9 @@ export default function MyPortalPage() {
|
||||
? "Good afternoon,"
|
||||
: "Good evening,";
|
||||
const recentInvoices = myInvoices.slice(0, 3);
|
||||
const maxVolume = Math.max(...VOLUME_DATA);
|
||||
|
||||
const volumePoints = dashboard?.freightVolume.monthly ?? [];
|
||||
const maxVolume = Math.max(1, ...volumePoints.map((p) => p.tonnes));
|
||||
|
||||
return (
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
@@ -471,8 +476,12 @@ export default function MyPortalPage() {
|
||||
<StatKpi
|
||||
icon={Truck}
|
||||
label="Active Shipments"
|
||||
value={activeBookings.length.toString()}
|
||||
delta="+2 this week"
|
||||
value={
|
||||
bookingsQuery.isPending ? "—" : activeBookings.length.toString()
|
||||
}
|
||||
delta={
|
||||
bookingsQuery.isPending ? "" : `+${newActiveThisWeek} this week`
|
||||
}
|
||||
deltaColor="edr-green.7"
|
||||
/>
|
||||
<StatKpi
|
||||
@@ -485,17 +494,23 @@ export default function MyPortalPage() {
|
||||
/>
|
||||
<StatKpi
|
||||
icon={CheckCircle2}
|
||||
label="Delivered (May)"
|
||||
value={deliveredCount.toString()}
|
||||
delta="96% on-time"
|
||||
label="Delivered (YTD)"
|
||||
value={dashboard ? dashboard.deliveredCount.toString() : "—"}
|
||||
delta={dashboard ? `${dashboard.completionRate}% completed` : ""}
|
||||
deltaColor="edr-muted"
|
||||
divider
|
||||
/>
|
||||
<StatKpi
|
||||
icon={Wallet}
|
||||
label="Spend YTD"
|
||||
value="ETB 1.24M"
|
||||
delta="+16% YoY"
|
||||
value={
|
||||
dashboard
|
||||
? formatCurrency(dashboard.spendYtd, dashboard.spendCurrency)
|
||||
: "—"
|
||||
}
|
||||
delta={
|
||||
dashboard ? `${formatPct(dashboard.spendYtdChangePct)} YoY` : ""
|
||||
}
|
||||
deltaColor="edr-green.7"
|
||||
divider
|
||||
/>
|
||||
@@ -670,37 +685,57 @@ export default function MyPortalPage() {
|
||||
Freight Volume
|
||||
</Text>
|
||||
<Group gap={10} align="baseline" mt={4} mb={22}>
|
||||
<Text fz={26} fw={800} c="edr-text">
|
||||
4,180 t
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted">
|
||||
ETB 1.24M
|
||||
</Text>
|
||||
<Text fz={12} fw={700} c="edr-green.7">
|
||||
+16% YTD
|
||||
</Text>
|
||||
{dashboardQuery.isPending ? (
|
||||
<Skeleton height={32} width={180} radius="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Text fz={26} fw={800} c="edr-text">
|
||||
{(dashboard?.freightVolume.totalTonnes ?? 0).toLocaleString()}{" "}
|
||||
t
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted">
|
||||
{formatCurrency(
|
||||
dashboard?.freightVolume.totalValue ?? 0,
|
||||
dashboard?.freightVolume.currency ?? "ETB",
|
||||
)}
|
||||
</Text>
|
||||
<Text fz={12} fw={700} c="edr-green.7">
|
||||
{formatPct(dashboard?.freightVolume.ytdChangePct ?? 0)} YTD
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group align="flex-end" gap={10} className="h-[110px]">
|
||||
{VOLUME_DATA.map((val, i) => {
|
||||
const isLast = i === VOLUME_DATA.length - 1;
|
||||
return (
|
||||
<Box
|
||||
key={i}
|
||||
className="flex flex-1 flex-col items-center gap-2"
|
||||
>
|
||||
{dashboardQuery.isPending ? (
|
||||
<Skeleton height={110} radius="md" />
|
||||
) : volumePoints.length === 0 ? (
|
||||
<Box className="flex h-[110px] items-center">
|
||||
<Text fz={13} c="edr-muted">
|
||||
No freight volume yet.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Group align="flex-end" gap={10} className="h-[110px]">
|
||||
{volumePoints.map((point, i) => {
|
||||
const isLast = i === volumePoints.length - 1;
|
||||
return (
|
||||
<Box
|
||||
bg={isLast ? "edr-green" : "edr-soft"}
|
||||
bd={isLast ? undefined : "1px solid edr-border"}
|
||||
h={Math.round((val / maxVolume) * 86)}
|
||||
className="w-full rounded-t-md"
|
||||
/>
|
||||
<Text fz={11} c="edr-muted">
|
||||
{MONTHS[i]}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
key={point.month}
|
||||
className="flex flex-1 flex-col items-center gap-2"
|
||||
>
|
||||
<Box
|
||||
bg={isLast ? "edr-green" : "edr-soft"}
|
||||
bd={isLast ? undefined : "1px solid edr-border"}
|
||||
h={Math.round((point.tonnes / maxVolume) * 86)}
|
||||
className="w-full rounded-t-md"
|
||||
/>
|
||||
<Text fz={11} c="edr-muted">
|
||||
{point.month}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import type {
|
||||
CompanyInfoResponse,
|
||||
CreateCompanyPayload,
|
||||
DashboardSummary,
|
||||
} from "./companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type {
|
||||
@@ -119,6 +120,12 @@ export const api = {
|
||||
"updateProfile",
|
||||
companiesService.updateProfile,
|
||||
),
|
||||
|
||||
getDashboard: endpoint<void, DashboardSummary>(
|
||||
"companies",
|
||||
"getDashboard",
|
||||
companiesService.getDashboard,
|
||||
),
|
||||
},
|
||||
|
||||
bookings: {
|
||||
|
||||
@@ -59,6 +59,26 @@ export interface CreateCompanyPayload {
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface FreightVolumePoint {
|
||||
month: string;
|
||||
tonnes: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
deliveredCount: number;
|
||||
completionRate: number;
|
||||
spendYtd: number;
|
||||
spendCurrency: string;
|
||||
spendYtdChangePct: number;
|
||||
freightVolume: {
|
||||
totalTonnes: number;
|
||||
totalValue: number;
|
||||
currency: string;
|
||||
ytdChangePct: number;
|
||||
monthly: FreightVolumePoint[];
|
||||
};
|
||||
}
|
||||
|
||||
export const companiesService = {
|
||||
getInfo: async (): Promise<CompanyInfoResponse | null> => {
|
||||
try {
|
||||
@@ -97,6 +117,13 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getDashboard: async (): Promise<DashboardSummary> => {
|
||||
const response = await client.get<ApiResponse<DashboardSummary>>(
|
||||
URL_CONSTANTS.COMPANIES_API.DASHBOARD,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
uploadDocuments: async (
|
||||
companyId: string,
|
||||
files: Record<string, File | File[] | null>,
|
||||
|
||||
Reference in New Issue
Block a user