diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index b1481d0d4..5b38c4d65 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { + return this.companiesService.getDashboardSummary(user.id); + } + @Patch('profile') @ApiOperation({ summary: 'Update profile (flattened settings page)' }) async updateProfile( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index d18573460..406c4f509 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index f383b9e55..086e8d767 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { @@ -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 { + // 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 { await this.findCompanyById(id); const updated = await this.companiesRepo.update(id, dto); diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts new file mode 100644 index 000000000..365cf1daa --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -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, + ) {} + + /** Count of delivered/completed bookings for a company within [from, to). */ + async countDelivered(companyId: string, from: Date, to: Date): Promise { + 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 { + 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 { + 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 { + 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 { + 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), + })); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts new file mode 100644 index 000000000..c0d99643c --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts @@ -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; +} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index dd3627fef..080df0ed2 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -173,7 +173,7 @@ const App = () => { } /> - }> + }> {item.href && !isLast ? ( - + {item.label} ) : ( diff --git a/apps/edr-freight-web/portal/src/components/bookings/BookingForm.tsx b/apps/edr-freight-web/portal/src/components/bookings/BookingForm.tsx deleted file mode 100644 index 44736a486..000000000 --- a/apps/edr-freight-web/portal/src/components/bookings/BookingForm.tsx +++ /dev/null @@ -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) => { - event.preventDefault(); - onSubmit({ - reference, - customerId, - scheduledDate, - totalAmount: Number(totalAmount), - }); - }; - - return ( -
- setReference(e.target.value)} - required - /> - setCustomerId(e.target.value)} - required - /> - setScheduledDate(e.target.value)} - required - /> - setTotalAmount(e.target.value)} - min="0" - /> - - - ); -}; - -export default BookingForm; diff --git a/apps/edr-freight-web/portal/src/components/bookings/BookingTable.tsx b/apps/edr-freight-web/portal/src/components/bookings/BookingTable.tsx deleted file mode 100644 index ccac88c57..000000000 --- a/apps/edr-freight-web/portal/src/components/bookings/BookingTable.tsx +++ /dev/null @@ -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[] = [ - { 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) => ( - row.id} - emptyMessage="No bookings yet" - /> -); - -export default BookingTable; diff --git a/apps/edr-freight-web/portal/src/components/consignments/ConsignmentForm.tsx b/apps/edr-freight-web/portal/src/components/consignments/ConsignmentForm.tsx deleted file mode 100644 index 1738cb1d0..000000000 --- a/apps/edr-freight-web/portal/src/components/consignments/ConsignmentForm.tsx +++ /dev/null @@ -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) => { - event.preventDefault(); - onSubmit({ - bookingId, - trackingNumber, - cargoType, - weightKg: Number(weightKg), - originStation, - destinationStation, - }); - }; - - return ( -
- setBookingId(e.target.value)} - required - /> - setTrackingNumber(e.target.value)} - required - /> - setCargoType(e.target.value)} - /> - setWeightKg(e.target.value)} - min="0" - /> - setOriginStation(e.target.value)} - required - /> - setDestinationStation(e.target.value)} - required - /> - - - ); -}; - -export default ConsignmentForm; diff --git a/apps/edr-freight-web/portal/src/components/consignments/ConsignmentTable.tsx b/apps/edr-freight-web/portal/src/components/consignments/ConsignmentTable.tsx deleted file mode 100644 index cc04524bf..000000000 --- a/apps/edr-freight-web/portal/src/components/consignments/ConsignmentTable.tsx +++ /dev/null @@ -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[] = [ - { 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) => ( -
row.id} - emptyMessage="No consignments yet" - /> -); - -export default ConsignmentTable; diff --git a/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx b/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx index 2003457f0..fa10a795e 100644 --- a/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx +++ b/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx @@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"; import { cn } from "@/lib/utils"; import { api } from "@/services/api"; import { Loader2, AlertCircle } from "lucide-react"; -import * as React from "react"; import { Select, @@ -64,11 +63,7 @@ export function DynamicSelect({ const options = [...data.children].sort((a, b) => a.order - b.order); return ( - diff --git a/apps/edr-freight-web/portal/src/components/tracking/TrackingTimeline.tsx b/apps/edr-freight-web/portal/src/components/tracking/TrackingTimeline.tsx index c167e1371..ac02239ad 100644 --- a/apps/edr-freight-web/portal/src/components/tracking/TrackingTimeline.tsx +++ b/apps/edr-freight-web/portal/src/components/tracking/TrackingTimeline.tsx @@ -17,7 +17,7 @@ const TrackingTimeline = ({ events }: TrackingTimelineProps) => {
{event.location} - {event.status} + {event.status}
- - - - @@ -279,7 +269,10 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) { -
e.stopPropagation()}> +
e.stopPropagation()} + >