diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index f3afb5722..0545f1f94 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -148,15 +148,10 @@ export class BookingsController { }, }; } - // Scope to the active operational profile (importer/exporter) when one - // resolves; otherwise fall back to company-level scoping. - const companyProfileId = - await this.bookingsService.resolveActiveCompanyProfileId(userId); - return this.bookingsService.findAll( - filter, - companyId, - companyProfileId ?? undefined, - ); + // Company-wide by default; the optional filter.companyProfileId (per-page + // service filter) narrows within the company. The company guard always + // applies, so a customer can only ever see their own company's bookings. + return this.bookingsService.findAll(filter, companyId); } @Get('by-company/:companyId/customer-view') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b6f3ee220..fddc1cf61 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -656,10 +656,11 @@ export class BookingsService { assignedToSchedule: filter.assignedToSchedule, // A forced company scope (portal/customer) overrides any caller-provided // companyId so a customer can only ever see their own company's bookings. - // When an active profile resolves, scope to it; otherwise fall back to the - // company so nothing breaks for not-yet-onboarded customers. - companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId, - companyProfileId: forceCompanyProfileId, + // The company guard always applies; the optional companyProfileId filter + // (from the per-page service filter) narrows WITHIN the company — the repo + // ANDs both, so cross-company access is impossible. + companyId: forceCompanyId ?? filter.companyId, + companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -694,18 +695,15 @@ export class BookingsService { filter: FilterBookingDto, ): Promise { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - // Scope to the active operational profile when one resolves; fall back to - // company-level so not-yet-onboarded customers still see their payables. - const companyProfileId = - await this.companiesService.resolveActiveCompanyProfileId(userId); return this.bookingsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, statuses: BookingsService.PAYABLE_STATUSES, excludePaymentStatus: 'PAID', - companyId: companyProfileId ? undefined : company.id, - companyProfileId: companyProfileId ?? undefined, + // Company-wide: payables span all of the customer's services. + companyId: company.id, + companyProfileId: filter.companyProfileId, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index d52473813..ee5099c52 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -38,6 +38,15 @@ export class FilterBookingDto { @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Narrow to a single operational profile (importer/exporter/freight_forwarder) within the company.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiPropertyOptional() @IsOptional() contractType?: string; 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 fff8fc7e5..2fba1e878 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -28,6 +28,7 @@ import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; +import { DashboardQueryDto } from "./dto/dashboard-query.dto"; import { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -86,8 +87,12 @@ export class CompaniesController { }) async getDashboard( @CurrentUser() user: CurrentIamUser, + @Query() query: DashboardQueryDto, ): Promise { - return this.companiesService.getDashboardSummary(user.id); + return this.companiesService.getDashboardSummary( + user.id, + query.companyProfileId, + ); } @Post("fetch-etrade-info") 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 a4466efb2..939803abf 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,7 +7,10 @@ import { import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; -import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { + CompanyDashboardRepository, + DashboardScope, +} from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; import { ETradeService } from "./services/etrade.service"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; @@ -320,6 +323,7 @@ export class CompaniesService { */ async getDashboardSummary( userId: string, + companyProfileId?: string, ): Promise { // A user without a company profile has no bookings — return an empty summary // rather than 404, so the portal home still renders. @@ -327,17 +331,17 @@ export class CompaniesService { const companyId = profile?.company?.id ?? profile?.companyId ?? null; if (!companyId) return this.emptyDashboardSummary(); - // Scope KPIs to the active operational profile (importer/exporter mode) when - // one resolves; otherwise aggregate across the whole company. - const companyProfileId = profile?.activeProfileType - ? ((await this.companyProfilesRepo.findByType( - companyId, - profile.activeProfileType, - )) ?? null) - : null; - const scope = companyProfileId - ? { companyProfileId: companyProfileId.id } - : { companyId }; + // Company-wide by default (all services' data). An optional companyProfileId + // (from the per-page service filter) narrows to one operational profile — + // but only after we confirm it belongs to this user's company, since the + // dashboard scope has no company guard at the repository layer. + let scope: DashboardScope = { companyId }; + if (companyProfileId) { + const owned = await this.companyProfilesRepo.findByCompanyId(companyId); + if (owned.some((p) => p.id === companyProfileId)) { + scope = { companyProfileId }; + } + } const now = new Date(); const yearStart = new Date(now.getFullYear(), 0, 1); diff --git a/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts new file mode 100644 index 000000000..c41c3279a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional, IsUUID } from "class-validator"; + +export class DashboardQueryDto { + @ApiPropertyOptional({ + format: "uuid", + description: + "Narrow dashboard KPIs to a single operational profile (importer/exporter/freight_forwarder) of the user's company. Omit for company-wide totals.", + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; +} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 15137cb26..e72178452 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -228,14 +228,7 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { - user, - company, - activeProfileType, - companyType, - switchMode, - createProfileAndSwitch, - } = useAuth(); + const { user, company, companyType, createProfileAndSwitch } = useAuth(); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; @@ -278,8 +271,6 @@ const App = () => { userEmail={userEmail} companyProfiles={companyProfiles} companyType={companyType} - activeProfileType={activeProfileType} - onSwitchMode={switchMode} onCreateProfile={createProfileAndSwitch} > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 7f86ead43..284497af2 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -19,9 +19,7 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { - ArrowLeftRight, Bell, - Check, ChevronDown, FileSignature, LogOut, @@ -61,13 +59,9 @@ export interface AppLayoutProps { userEmail?: string; /** Operational profiles for the company — surfaced as reference chips in the account menu. */ companyProfiles?: { type: string; reference: string; status?: string }[]; - /** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */ + /** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */ companyType?: string | null; - /** The active operational mode (importer/exporter/...). */ - activeProfileType?: string | null; - /** Switch to an existing profile of the given type. */ - onSwitchMode?: (type: ServiceType) => Promise | void; - /** Create the profile of the given type (with business license) then switch. */ + /** Create a new service profile of the given type (with business license). */ onCreateProfile?: ( type: ServiceType, licenseFiles: File[], @@ -146,8 +140,6 @@ export function AppLayout({ userEmail, companyProfiles = [], companyType, - activeProfileType, - onSwitchMode, onCreateProfile, children, }: AppLayoutProps) { @@ -174,16 +166,16 @@ export function AppLayout({ const initials = getInitials(userName); const activePage = getActivePage(sidebarItems, activePath); - // ── Service selection (customer companies only) ── - // A customer can operate as importer, exporter and/or freight forwarder, - // and switch between whichever service profiles their company has. + // ── Add a service (customer companies only) ── + // A customer can operate as importer, exporter and/or freight forwarder. The + // header lets them ADD a service they don't have yet (creating a profile with + // its business license). Data is no longer scoped by an "active" service — + // every page shows all the company's data, with an optional per-page filter. const isCustomer = companyType === "customer"; - const canSwitch = - isCustomer && - CUSTOMER_SERVICES.includes(activeProfileType as ServiceType); - const profileExists = (type: ServiceType) => companyProfiles.some((p) => p.type === type); + const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t)); + const canAddService = isCustomer && addableServices.length > 0; const [switching, setSwitching] = useState(false); const [createOpen, setCreateOpen] = useState(false); @@ -191,22 +183,12 @@ export function AppLayout({ const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(null); - const handleSelectService = async (type: ServiceType) => { - if (type === activeProfileType) return; - if (profileExists(type)) { - setSwitching(true); - try { - await onSwitchMode?.(type); - } finally { - setSwitching(false); - } - } else { - // No profile yet — collect a business license, then create + switch. - setCreateTarget(type); - setLicenseFiles([]); - setCreateError(null); - setCreateOpen(true); - } + const handleAddService = (type: ServiceType) => { + // Collect a business license, then create the profile. + setCreateTarget(type); + setLicenseFiles([]); + setCreateError(null); + setCreateOpen(true); }; const handleCreateConfirm = async () => { @@ -302,8 +284,8 @@ export function AppLayout({ {/* Right: switch + search + bell + avatar */} - {/* Service selector (customer companies only) */} - {canSwitch && ( + {/* Add a service (customer companies that don't yet have all three) */} + {canAddService && ( } + leftSection={} rightSection={} styles={{ root: { height: 36 } }} visibleFrom="xs" > - {serviceLabel(activeProfileType as ServiceType)} + Add service - Select service - {CUSTOMER_SERVICES.map((type) => { - const isActive = type === activeProfileType; - const exists = profileExists(type); - return ( - handleSelectService(type)} - leftSection={ - isActive ? ( - - ) : exists ? ( - - ) : ( - - ) - } - disabled={isActive} - > - {serviceLabel(type)} - {!exists && ( - - (set up) - - )} - - ); - })} + Add a service + {addableServices.map((type) => ( + handleAddService(type)} + leftSection={} + > + {serviceLabel(type)} + + ))} )} @@ -468,39 +432,25 @@ export function AppLayout({ - {companyProfiles.map((p) => { - const isActive = p.type === activeProfileType; - return ( - ( + + - - {isActive && ( - - )} - - {PROFILE_TYPE_LABELS[p.type] ?? p.type} - - - - {p.reference} - - - ); - })} + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + {p.reference} + + + ))} diff --git a/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx b/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx deleted file mode 100644 index ff8f33bfb..000000000 --- a/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Badge, Tooltip } from "@mantine/core"; -import { ArrowDownToLine, ArrowUpFromLine } from "lucide-react"; - -import useAuth from "@/hooks/useAuth"; -import { modeDataDescription, modeDataLabel } from "@/constants/profileMode"; - -interface ModeIndicatorProps { - /** Mantine size token for the badge. */ - size?: "sm" | "md" | "lg"; -} - -/** - * Small pill showing which operational mode's data is currently on screen - * (Import / Export). The data itself is scoped server-side by the active - * profile; this just makes the scope visible. Switching is done via the header - * button — this is read-only. - * - * Renders nothing for non-customer companies or when no import/export mode is - * active, so it never interferes with forwarders or not-yet-onboarded users. - */ -export function ModeIndicator({ size = "md" }: ModeIndicatorProps) { - const { companyType, activeProfileType } = useAuth(); - - if (companyType !== "customer") return null; - - const label = modeDataLabel(activeProfileType); - if (!label) return null; - - const isImport = activeProfileType === "importer"; - - return ( - - - ) : ( - - ) - } - styles={{ - root: { textTransform: "none", letterSpacing: 0, fontWeight: 600 }, - }} - > - Viewing: {label} - - - ); -} - -export default ModeIndicator; diff --git a/apps/edr-freight-web/portal/src/constants/profileMode.ts b/apps/edr-freight-web/portal/src/constants/profileMode.ts index a73901ccb..7629ad34b 100644 --- a/apps/edr-freight-web/portal/src/constants/profileMode.ts +++ b/apps/edr-freight-web/portal/src/constants/profileMode.ts @@ -1,6 +1,6 @@ /** - * Operational-mode (importer/exporter/…) labels and helpers, shared by the app - * header and the per-page mode indicator so there is a single source of truth. + * Operational-service (importer/exporter/…) display labels, shared by the app + * header and the per-page service filters so there is a single source of truth. */ export const PROFILE_TYPE_LABELS: Record = { @@ -10,21 +10,3 @@ export const PROFILE_TYPE_LABELS: Record = { dj_freight_forwarder: "DJ Freight Forwarder", transporter: "Transporter", }; - -/** The data-scope label shown to the user (importer ⇒ "Import", exporter ⇒ "Export"). */ -export function modeDataLabel( - activeProfileType?: string | null, -): string | null { - if (activeProfileType === "importer") return "Import"; - if (activeProfileType === "exporter") return "Export"; - return null; -} - -/** Short helper sentence describing what the active mode scopes. */ -export function modeDataDescription( - activeProfileType?: string | null, -): string { - const label = modeDataLabel(activeProfileType); - if (!label) return ""; - return `Showing your ${label.toLowerCase()} data — switch in the header.`; -} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 0a4dcb6ce..4da182283 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -1,7 +1,9 @@ import type { Currency } from "@/pages/billing/invoices.mock"; import { formatCurrency } from "@/pages/billing/invoices.mock"; -import { Grid, Stack } from "@mantine/core"; +import { Group, Grid, Select, Stack } from "@mantine/core"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import { FreightVolumeSection, HelloSection, @@ -15,8 +17,12 @@ import { useMyPortalData } from "./hooks"; export default function MyPortalPage() { const navigate = useNavigate(); + const [selectedProfileId, setSelectedProfileId] = useState( + null, + ); const { customer, + companyProfiles, bookingsQuery, dashboardQuery, allBookings, @@ -30,7 +36,12 @@ export default function MyPortalPage() { dashboard, volumePoints, maxVolume, - } = useMyPortalData(); + } = useMyPortalData(selectedProfileId ?? undefined); + + const serviceOptions = companyProfiles.map((p) => ({ + value: p.id, + label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`, + })); const handleBookingClick = (id: string) => { navigate(`/bookings/${id}`); @@ -40,6 +51,22 @@ export default function MyPortalPage() { + {serviceOptions.length > 1 && ( + + { + setServiceFilter(v); + resetPage(); + }} + clearable + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 200 }} + aria-label="Filter by service" + /> + )} - General contract · {isContainer ? "Containerised" : "Bulk"} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index 40ebd4f45..34bcc37a3 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -25,7 +25,6 @@ import { type ColumnDef, usePagination, } from "@edr/ui-common"; -import { ModeIndicator } from "@/components/ModeIndicator"; import { CargoModeCell, PaymentBadge } from "../bookings/booking-display"; import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui"; @@ -177,7 +176,6 @@ export default function ContractsList() { General Contracts - Reserve a quantity once, then place orders against it until the diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 9ebd1cc01..bf170cfac 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -128,7 +128,7 @@ export const api = { companiesService.updateProfile, ), - getDashboard: endpoint( + getDashboard: endpoint( "companies", "getDashboard", companiesService.getDashboard, diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index a95026f93..0631a49aa 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -75,6 +75,8 @@ export interface BookingListFilter { freightType?: string; /** IMPORT / EXPORT / DOMESTIC. */ tradeDirection?: string; + /** Narrow to a single operational profile (importer/exporter/freight_forwarder). */ + companyProfileId?: string; /** Created-date range (ISO). */ createdFrom?: string; createdTo?: string; diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 791c0f8de..a5905589b 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -162,9 +162,12 @@ export const companiesService = { return unwrap(response.data); }, - getDashboard: async (): Promise => { + getDashboard: async ( + companyProfileId?: string, + ): Promise => { const response = await client.get>( URL_CONSTANTS.COMPANIES_API.DASHBOARD, + { params: companyProfileId ? { companyProfileId } : undefined }, ); return unwrap(response.data); },