enhance service filtering and dashboard functionality with company profile support

This commit is contained in:
Marshal
2026-06-23 07:54:36 +00:00
parent fd1fc1ca69
commit 1d77f377f3
19 changed files with 206 additions and 235 deletions

View File

@@ -148,15 +148,10 @@ export class BookingsController {
}, },
}; };
} }
// Scope to the active operational profile (importer/exporter) when one // Company-wide by default; the optional filter.companyProfileId (per-page
// resolves; otherwise fall back to company-level scoping. // service filter) narrows within the company. The company guard always
const companyProfileId = // applies, so a customer can only ever see their own company's bookings.
await this.bookingsService.resolveActiveCompanyProfileId(userId); return this.bookingsService.findAll(filter, companyId);
return this.bookingsService.findAll(
filter,
companyId,
companyProfileId ?? undefined,
);
} }
@Get('by-company/:companyId/customer-view') @Get('by-company/:companyId/customer-view')

View File

@@ -656,10 +656,11 @@ export class BookingsService {
assignedToSchedule: filter.assignedToSchedule, assignedToSchedule: filter.assignedToSchedule,
// A forced company scope (portal/customer) overrides any caller-provided // A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings. // 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 // The company guard always applies; the optional companyProfileId filter
// company so nothing breaks for not-yet-onboarded customers. // (from the per-page service filter) narrows WITHIN the company — the repo
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId, // ANDs both, so cross-company access is impossible.
companyProfileId: forceCompanyProfileId, companyId: forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
contractType: filter.contractType, contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId, serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId, cargoTypeId: filter.cargoTypeId,
@@ -694,18 +695,15 @@ export class BookingsService {
filter: FilterBookingDto, filter: FilterBookingDto,
): Promise<PaginatedBookings> { ): Promise<PaginatedBookings> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId); 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({ return this.bookingsRepository.findAllPaginated({
page: filter.page ?? 1, page: filter.page ?? 1,
pageSize: filter.pageSize ?? 20, pageSize: filter.pageSize ?? 20,
statuses: BookingsService.PAYABLE_STATUSES, statuses: BookingsService.PAYABLE_STATUSES,
excludePaymentStatus: 'PAID', excludePaymentStatus: 'PAID',
companyId: companyProfileId ? undefined : company.id, // Company-wide: payables span all of the customer's services.
companyProfileId: companyProfileId ?? undefined, companyId: company.id,
companyProfileId: filter.companyProfileId,
sortBy: filter.sortBy, sortBy: filter.sortBy,
sortOrder: filter.sortOrder, sortOrder: filter.sortOrder,
}); });

View File

@@ -38,6 +38,15 @@ export class FilterBookingDto {
@IsUUID() @IsUUID()
companyId?: string; companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Narrow to a single operational profile (importer/exporter/freight_forwarder) within the company.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
contractType?: string; contractType?: string;

View File

@@ -28,6 +28,7 @@ import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetActiveModeDto } from "./dto/set-active-mode.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
import { import {
ResponseCompanyDto, ResponseCompanyDto,
ResponseCompanyProfileDto, ResponseCompanyProfileDto,
@@ -86,8 +87,12 @@ export class CompaniesController {
}) })
async getDashboard( async getDashboard(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@Query() query: DashboardQueryDto,
): Promise<DashboardSummaryResponseDto> { ): Promise<DashboardSummaryResponseDto> {
return this.companiesService.getDashboardSummary(user.id); return this.companiesService.getDashboardSummary(
user.id,
query.companyProfileId,
);
} }
@Post("fetch-etrade-info") @Post("fetch-etrade-info")

View File

@@ -7,7 +7,10 @@ import {
import { CompaniesRepository } from "./companies.repository"; import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyProfileRepository } from "./company-profile.repository";
import { ExternalProfileRepository } from "./external-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 { MinioService } from "../minio/minio.service";
import { ETradeService } from "./services/etrade.service"; import { ETradeService } from "./services/etrade.service";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
@@ -320,6 +323,7 @@ export class CompaniesService {
*/ */
async getDashboardSummary( async getDashboardSummary(
userId: string, userId: string,
companyProfileId?: string,
): Promise<DashboardSummaryResponseDto> { ): Promise<DashboardSummaryResponseDto> {
// A user without a company profile has no bookings — return an empty summary // A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders. // rather than 404, so the portal home still renders.
@@ -327,17 +331,17 @@ export class CompaniesService {
const companyId = profile?.company?.id ?? profile?.companyId ?? null; const companyId = profile?.company?.id ?? profile?.companyId ?? null;
if (!companyId) return this.emptyDashboardSummary(); if (!companyId) return this.emptyDashboardSummary();
// Scope KPIs to the active operational profile (importer/exporter mode) when // Company-wide by default (all services' data). An optional companyProfileId
// one resolves; otherwise aggregate across the whole company. // (from the per-page service filter) narrows to one operational profile —
const companyProfileId = profile?.activeProfileType // but only after we confirm it belongs to this user's company, since the
? ((await this.companyProfilesRepo.findByType( // dashboard scope has no company guard at the repository layer.
companyId, let scope: DashboardScope = { companyId };
profile.activeProfileType, if (companyProfileId) {
)) ?? null) const owned = await this.companyProfilesRepo.findByCompanyId(companyId);
: null; if (owned.some((p) => p.id === companyProfileId)) {
const scope = companyProfileId scope = { companyProfileId };
? { companyProfileId: companyProfileId.id } }
: { companyId }; }
const now = new Date(); const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1); const yearStart = new Date(now.getFullYear(), 0, 1);

View File

@@ -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;
}

View File

@@ -228,14 +228,7 @@ const sidebarItems: SidebarItem[] = [
const App = () => { const App = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { const { user, company, companyType, createProfileAndSwitch } = useAuth();
user,
company,
activeProfileType,
companyType,
switchMode,
createProfileAndSwitch,
} = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User"; const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email; const userEmail = user?.email;
@@ -278,8 +271,6 @@ const App = () => {
userEmail={userEmail} userEmail={userEmail}
companyProfiles={companyProfiles} companyProfiles={companyProfiles}
companyType={companyType} companyType={companyType}
activeProfileType={activeProfileType}
onSwitchMode={switchMode}
onCreateProfile={createProfileAndSwitch} onCreateProfile={createProfileAndSwitch}
> >
<OnboardingGate /> <OnboardingGate />

View File

@@ -19,9 +19,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
ArrowLeftRight,
Bell, Bell,
Check,
ChevronDown, ChevronDown,
FileSignature, FileSignature,
LogOut, LogOut,
@@ -61,13 +59,9 @@ export interface AppLayoutProps {
userEmail?: string; userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */ /** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[]; 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; companyType?: string | null;
/** The active operational mode (importer/exporter/...). */ /** Create a new service profile of the given type (with business license). */
activeProfileType?: string | null;
/** Switch to an existing profile of the given type. */
onSwitchMode?: (type: ServiceType) => Promise<SwitchResult> | void;
/** Create the profile of the given type (with business license) then switch. */
onCreateProfile?: ( onCreateProfile?: (
type: ServiceType, type: ServiceType,
licenseFiles: File[], licenseFiles: File[],
@@ -146,8 +140,6 @@ export function AppLayout({
userEmail, userEmail,
companyProfiles = [], companyProfiles = [],
companyType, companyType,
activeProfileType,
onSwitchMode,
onCreateProfile, onCreateProfile,
children, children,
}: AppLayoutProps) { }: AppLayoutProps) {
@@ -174,16 +166,16 @@ export function AppLayout({
const initials = getInitials(userName); const initials = getInitials(userName);
const activePage = getActivePage(sidebarItems, activePath); const activePage = getActivePage(sidebarItems, activePath);
// ── Service selection (customer companies only) ── // ── Add a service (customer companies only) ──
// A customer can operate as importer, exporter and/or freight forwarder, // A customer can operate as importer, exporter and/or freight forwarder. The
// and switch between whichever service profiles their company has. // 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 isCustomer = companyType === "customer";
const canSwitch =
isCustomer &&
CUSTOMER_SERVICES.includes(activeProfileType as ServiceType);
const profileExists = (type: ServiceType) => const profileExists = (type: ServiceType) =>
companyProfiles.some((p) => p.type === type); 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 [switching, setSwitching] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -191,22 +183,12 @@ export function AppLayout({
const [licenseFiles, setLicenseFiles] = useState<File[]>([]); const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const handleSelectService = async (type: ServiceType) => { const handleAddService = (type: ServiceType) => {
if (type === activeProfileType) return; // Collect a business license, then create the profile.
if (profileExists(type)) { setCreateTarget(type);
setSwitching(true); setLicenseFiles([]);
try { setCreateError(null);
await onSwitchMode?.(type); setCreateOpen(true);
} finally {
setSwitching(false);
}
} else {
// No profile yet — collect a business license, then create + switch.
setCreateTarget(type);
setLicenseFiles([]);
setCreateError(null);
setCreateOpen(true);
}
}; };
const handleCreateConfirm = async () => { const handleCreateConfirm = async () => {
@@ -302,8 +284,8 @@ export function AppLayout({
{/* Right: switch + search + bell + avatar */} {/* Right: switch + search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center"> <Group gap={10} wrap="nowrap" align="center">
{/* Service selector (customer companies only) */} {/* Add a service (customer companies that don't yet have all three) */}
{canSwitch && ( {canAddService && (
<Menu <Menu
width={220} width={220}
position="bottom-end" position="bottom-end"
@@ -319,43 +301,25 @@ export function AppLayout({
color="edr-green" color="edr-green"
radius={999} radius={999}
size="sm" size="sm"
leftSection={<ArrowLeftRight size={15} strokeWidth={1.8} />} leftSection={<Plus size={15} strokeWidth={1.8} />}
rightSection={<ChevronDown size={14} strokeWidth={1.8} />} rightSection={<ChevronDown size={14} strokeWidth={1.8} />}
styles={{ root: { height: 36 } }} styles={{ root: { height: 36 } }}
visibleFrom="xs" visibleFrom="xs"
> >
{serviceLabel(activeProfileType as ServiceType)} Add service
</Button> </Button>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label>Select service</Menu.Label> <Menu.Label>Add a service</Menu.Label>
{CUSTOMER_SERVICES.map((type) => { {addableServices.map((type) => (
const isActive = type === activeProfileType; <Menu.Item
const exists = profileExists(type); key={type}
return ( onClick={() => handleAddService(type)}
<Menu.Item leftSection={<Plus size={15} strokeWidth={1.8} />}
key={type} >
onClick={() => handleSelectService(type)} {serviceLabel(type)}
leftSection={ </Menu.Item>
isActive ? ( ))}
<Check size={15} strokeWidth={2} />
) : exists ? (
<ArrowLeftRight size={15} strokeWidth={1.8} />
) : (
<Plus size={15} strokeWidth={1.8} />
)
}
disabled={isActive}
>
{serviceLabel(type)}
{!exists && (
<Text span size="xs" c="dimmed" ml={6}>
(set up)
</Text>
)}
</Menu.Item>
);
})}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
)} )}
@@ -468,39 +432,25 @@ export function AppLayout({
<Divider /> <Divider />
<Box px="sm" py="xs"> <Box px="sm" py="xs">
<Stack gap={6}> <Stack gap={6}>
{companyProfiles.map((p) => { {companyProfiles.map((p) => (
const isActive = p.type === activeProfileType; <Group
return ( key={p.reference}
<Group justify="space-between"
key={p.reference} gap="sm"
justify="space-between" wrap="nowrap"
gap="sm" >
wrap="nowrap" <Text
size="xs"
fw={600}
style={{ color: textColor }}
> >
<Group gap={6} wrap="nowrap"> {PROFILE_TYPE_LABELS[p.type] ?? p.type}
{isActive && ( </Text>
<Check <Text size="xs" ff="monospace" c="dimmed">
size={13} {p.reference}
color={primaryDarkColor} </Text>
strokeWidth={2.5} </Group>
/> ))}
)}
<Text
size="xs"
fw={isActive ? 700 : 600}
style={{
color: isActive ? primaryDarkColor : textColor,
}}
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
</Group>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
);
})}
</Stack> </Stack>
</Box> </Box>
</> </>

View File

@@ -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 (
<Tooltip label={modeDataDescription(activeProfileType)} withArrow>
<Badge
size={size}
radius="sm"
variant="light"
color={isImport ? "edr-green" : "blue"}
leftSection={
isImport ? (
<ArrowDownToLine size={13} />
) : (
<ArrowUpFromLine size={13} />
)
}
styles={{
root: { textTransform: "none", letterSpacing: 0, fontWeight: 600 },
}}
>
Viewing: {label}
</Badge>
</Tooltip>
);
}
export default ModeIndicator;

View File

@@ -1,6 +1,6 @@
/** /**
* Operational-mode (importer/exporter/…) labels and helpers, shared by the app * Operational-service (importer/exporter/…) display labels, shared by the app
* header and the per-page mode indicator so there is a single source of truth. * header and the per-page service filters so there is a single source of truth.
*/ */
export const PROFILE_TYPE_LABELS: Record<string, string> = { export const PROFILE_TYPE_LABELS: Record<string, string> = {
@@ -10,21 +10,3 @@ export const PROFILE_TYPE_LABELS: Record<string, string> = {
dj_freight_forwarder: "DJ Freight Forwarder", dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter", 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.`;
}

View File

@@ -1,7 +1,9 @@
import type { Currency } from "@/pages/billing/invoices.mock"; import type { Currency } from "@/pages/billing/invoices.mock";
import { formatCurrency } 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 { useNavigate } from "react-router-dom";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import { import {
FreightVolumeSection, FreightVolumeSection,
HelloSection, HelloSection,
@@ -15,8 +17,12 @@ import { useMyPortalData } from "./hooks";
export default function MyPortalPage() { export default function MyPortalPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const { const {
customer, customer,
companyProfiles,
bookingsQuery, bookingsQuery,
dashboardQuery, dashboardQuery,
allBookings, allBookings,
@@ -30,7 +36,12 @@ export default function MyPortalPage() {
dashboard, dashboard,
volumePoints, volumePoints,
maxVolume, 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) => { const handleBookingClick = (id: string) => {
navigate(`/bookings/${id}`); navigate(`/bookings/${id}`);
@@ -40,6 +51,22 @@ export default function MyPortalPage() {
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}> <Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<HelloSection greeting={greeting} companyName={companyName} /> <HelloSection greeting={greeting} companyName={companyName} />
{serviceOptions.length > 1 && (
<Group justify="flex-end">
<Select
placeholder="All services"
data={serviceOptions}
value={selectedProfileId}
onChange={setSelectedProfileId}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 220 }}
aria-label="Filter dashboard by service"
/>
</Group>
)}
<SetupPrompt show={!customer} /> <SetupPrompt show={!customer} />
<StatsSection <StatsSection

View File

@@ -2,7 +2,6 @@ import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react"; import { ArrowRight, Truck } from "lucide-react";
import { memo } from "react"; import { memo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ModeIndicator } from "@/components/ModeIndicator";
import { cv } from "../constants"; import { cv } from "../constants";
interface HelloSectionProps { interface HelloSectionProps {
@@ -24,7 +23,6 @@ export const HelloSection = memo(function HelloSection({
<Text fz={26} fw={800} c="edr-text" className="tracking-tight"> <Text fz={26} fw={800} c="edr-text" className="tracking-tight">
{companyName} 👋 {companyName} 👋
</Text> </Text>
<ModeIndicator />
</Group> </Group>
</Box> </Box>

View File

@@ -5,17 +5,25 @@ import { getMyInvoices } from "@/lib/currentCustomer";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants"; import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData() { export function useMyPortalData(selectedProfileId?: string) {
const { user, customer } = useAuth(); const { user, customer, company } = useAuth();
const myInvoices = useMemo(() => getMyInvoices(), []); const myInvoices = useMemo(() => getMyInvoices(), []);
const companyProfiles = company?.company?.companyProfiles ?? [];
const bookingsQuery = useQuery( const bookingsQuery = useQuery(
api.bookings.list.queryOptions({ api.bookings.list.queryOptions({
input: { sortBy: "createdAt", sortOrder: "DESC" }, input: {
sortBy: "createdAt",
sortOrder: "DESC",
companyProfileId: selectedProfileId,
},
}), }),
); );
const dashboardQuery = useQuery(api.companies.getDashboard.queryOptions()); const dashboardQuery = useQuery(
api.companies.getDashboard.queryOptions({ input: selectedProfileId }),
);
const allBookings = bookingsQuery.data?.items ?? []; const allBookings = bookingsQuery.data?.items ?? [];
const activeBookings = allBookings.filter((b) => const activeBookings = allBookings.filter((b) =>
@@ -56,6 +64,7 @@ export function useMyPortalData() {
return { return {
user, user,
customer, customer,
companyProfiles,
bookingsQuery, bookingsQuery,
dashboardQuery, dashboardQuery,
allBookings, allBookings,

View File

@@ -34,7 +34,8 @@ import {
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton"; import { PayNowButton } from "./payments/PayNowButton";
import { ModeIndicator } from "@/components/ModeIndicator"; import useAuth from "@/hooks/useAuth";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import { import {
BookingTypeBadge, BookingTypeBadge,
CargoModeCell, CargoModeCell,
@@ -256,10 +257,13 @@ function fmtDate(iso?: string | null): string {
// ── Main component ──────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`). // Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined { function useStatusCount(
statuses: string | undefined,
companyProfileId?: string,
): number | undefined {
const { data } = useQuery( const { data } = useQuery(
api.bookings.list.queryOptions({ api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 }, input: { statuses, companyProfileId, page: 1, pageSize: 1 },
staleTime: 30_000, staleTime: 30_000,
}), }),
); );
@@ -335,8 +339,18 @@ export default function MyBookings() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null); const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [freightFilter, setFreightFilter] = useState<string | null>(null); const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>(""); const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>(""); const [createdTo, setCreatedTo] = useState<string>("");
// Operational-service options (importer / exporter / freight forwarder) for
// the per-page filter. Empty for non-customer companies.
const { company } = useAuth();
const companyProfiles = company?.company?.companyProfiles ?? [];
const serviceOptions = companyProfiles.map((p) => ({
value: p.id,
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
}));
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>( const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
null, null,
); );
@@ -352,10 +366,15 @@ export default function MyBookings() {
}; };
const hasExtraFilters = const hasExtraFilters =
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo; !!typeFilter ||
!!freightFilter ||
!!serviceFilter ||
!!createdFrom ||
!!createdTo;
const clearExtraFilters = () => { const clearExtraFilters = () => {
setTypeFilter(null); setTypeFilter(null);
setFreightFilter(null); setFreightFilter(null);
setServiceFilter(null);
setCreatedFrom(""); setCreatedFrom("");
setCreatedTo(""); setCreatedTo("");
resetPage(); resetPage();
@@ -366,6 +385,7 @@ export default function MyBookings() {
statuses, statuses,
bookingType: typeFilter ?? undefined, bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined, freightType: freightFilter ?? undefined,
companyProfileId: serviceFilter ?? undefined,
createdFrom: createdFrom || undefined, createdFrom: createdFrom || undefined,
// include the whole selected end day // include the whole selected end day
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined, createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
@@ -376,6 +396,7 @@ export default function MyBookings() {
statuses, statuses,
typeFilter, typeFilter,
freightFilter, freightFilter,
serviceFilter,
createdFrom, createdFrom,
createdTo, createdTo,
pagination.pageIndex, pagination.pageIndex,
@@ -387,25 +408,33 @@ export default function MyBookings() {
api.bookings.list.queryOptions({ input: filter }), api.bookings.list.queryOptions({ input: filter }),
); );
// Per-card lifecycle counts (one cheap query each, total-only). // Per-card lifecycle counts (one cheap query each, total-only). Scoped to the
const allCount = useStatusCount(undefined); // selected service so the cards match the filtered table.
const svc = serviceFilter ?? undefined;
const allCount = useStatusCount(undefined, svc);
const activeCount = useStatusCount( const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses, STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
svc,
); );
const paymentCount = useStatusCount( const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses, STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
svc,
); );
const draftCount = useStatusCount( const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses, STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
svc,
); );
const doneCount = useStatusCount( const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses, STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
svc,
); );
const transitCount = useStatusCount( const transitCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses, STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
svc,
); );
const closedCount = useStatusCount( const closedCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses, STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
svc,
); );
const cardCounts: Record<StatusFilterKey, number | undefined> = { const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount, all: allCount,
@@ -617,7 +646,6 @@ export default function MyBookings() {
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}> <Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Bookings Bookings
</Title> </Title>
<ModeIndicator />
</Group> </Group>
<Text size="sm" c="edr-muted" mt={4}> <Text size="sm" c="edr-muted" mt={4}>
Track every cargo booking from draft to delivery. Track every cargo booking from draft to delivery.
@@ -723,6 +751,22 @@ export default function MyBookings() {
style={{ width: 150 }} style={{ width: 150 }}
aria-label="Filter by cargo type" aria-label="Filter by cargo type"
/> />
{serviceOptions.length > 1 && (
<Select
placeholder="All services"
data={serviceOptions}
value={serviceFilter}
onChange={(v) => {
setServiceFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
aria-label="Filter by service"
/>
)}
<TextInput <TextInput
type="date" type="date"
value={createdFrom} value={createdFrom}

View File

@@ -25,7 +25,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { ModeIndicator } from "@/components/ModeIndicator";
import { PayNowButton } from "../bookings/payments/PayNowButton"; import { PayNowButton } from "../bookings/payments/PayNowButton";
import { import {
BORDER, BORDER,
@@ -121,7 +120,6 @@ export default function ContractDetailPage() {
{contract.reference} {contract.reference}
</Title> </Title>
<ContractStatusBadge status={contract.status} /> <ContractStatusBadge status={contract.status} />
<ModeIndicator size="sm" />
</Group> </Group>
<Text size="sm" c="dimmed" mt={2}> <Text size="sm" c="dimmed" mt={2}>
General contract · {isContainer ? "Containerised" : "Bulk"} General contract · {isContainer ? "Containerised" : "Bulk"}

View File

@@ -25,7 +25,6 @@ import {
type ColumnDef, type ColumnDef,
usePagination, usePagination,
} from "@edr/ui-common"; } from "@edr/ui-common";
import { ModeIndicator } from "@/components/ModeIndicator";
import { CargoModeCell, PaymentBadge } from "../bookings/booking-display"; import { CargoModeCell, PaymentBadge } from "../bookings/booking-display";
import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui"; import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui";
@@ -177,7 +176,6 @@ export default function ContractsList() {
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}> <Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
General Contracts General Contracts
</Title> </Title>
<ModeIndicator />
</Group> </Group>
<Text size="sm" c="edr-muted" mt={4}> <Text size="sm" c="edr-muted" mt={4}>
Reserve a quantity once, then place orders against it until the Reserve a quantity once, then place orders against it until the

View File

@@ -128,7 +128,7 @@ export const api = {
companiesService.updateProfile, companiesService.updateProfile,
), ),
getDashboard: endpoint<void, DashboardSummary>( getDashboard: endpoint<string | void, DashboardSummary>(
"companies", "companies",
"getDashboard", "getDashboard",
companiesService.getDashboard, companiesService.getDashboard,

View File

@@ -75,6 +75,8 @@ export interface BookingListFilter {
freightType?: string; freightType?: string;
/** IMPORT / EXPORT / DOMESTIC. */ /** IMPORT / EXPORT / DOMESTIC. */
tradeDirection?: string; tradeDirection?: string;
/** Narrow to a single operational profile (importer/exporter/freight_forwarder). */
companyProfileId?: string;
/** Created-date range (ISO). */ /** Created-date range (ISO). */
createdFrom?: string; createdFrom?: string;
createdTo?: string; createdTo?: string;

View File

@@ -162,9 +162,12 @@ export const companiesService = {
return unwrap(response.data); return unwrap(response.data);
}, },
getDashboard: async (): Promise<DashboardSummary> => { getDashboard: async (
companyProfileId?: string,
): Promise<DashboardSummary> => {
const response = await client.get<ApiResponse<DashboardSummary>>( const response = await client.get<ApiResponse<DashboardSummary>>(
URL_CONSTANTS.COMPANIES_API.DASHBOARD, URL_CONSTANTS.COMPANIES_API.DASHBOARD,
{ params: companyProfileId ? { companyProfileId } : undefined },
); );
return unwrap(response.data); return unwrap(response.data);
}, },