mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
enhance service filtering and dashboard functionality with company profile support
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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<PaginatedBookings> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DashboardSummaryResponseDto> {
|
||||
return this.companiesService.getDashboardSummary(user.id);
|
||||
return this.companiesService.getDashboardSummary(
|
||||
user.id,
|
||||
query.companyProfileId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("fetch-etrade-info")
|
||||
|
||||
@@ -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<DashboardSummaryResponseDto> {
|
||||
// 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<OnboardingGate />
|
||||
|
||||
@@ -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<SwitchResult> | 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<File[]>([]);
|
||||
const [createError, setCreateError] = useState<string | null>(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 */}
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
{/* Service selector (customer companies only) */}
|
||||
{canSwitch && (
|
||||
{/* Add a service (customer companies that don't yet have all three) */}
|
||||
{canAddService && (
|
||||
<Menu
|
||||
width={220}
|
||||
position="bottom-end"
|
||||
@@ -319,43 +301,25 @@ export function AppLayout({
|
||||
color="edr-green"
|
||||
radius={999}
|
||||
size="sm"
|
||||
leftSection={<ArrowLeftRight size={15} strokeWidth={1.8} />}
|
||||
leftSection={<Plus size={15} strokeWidth={1.8} />}
|
||||
rightSection={<ChevronDown size={14} strokeWidth={1.8} />}
|
||||
styles={{ root: { height: 36 } }}
|
||||
visibleFrom="xs"
|
||||
>
|
||||
{serviceLabel(activeProfileType as ServiceType)}
|
||||
Add service
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Select service</Menu.Label>
|
||||
{CUSTOMER_SERVICES.map((type) => {
|
||||
const isActive = type === activeProfileType;
|
||||
const exists = profileExists(type);
|
||||
return (
|
||||
<Menu.Item
|
||||
key={type}
|
||||
onClick={() => handleSelectService(type)}
|
||||
leftSection={
|
||||
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.Label>Add a service</Menu.Label>
|
||||
{addableServices.map((type) => (
|
||||
<Menu.Item
|
||||
key={type}
|
||||
onClick={() => handleAddService(type)}
|
||||
leftSection={<Plus size={15} strokeWidth={1.8} />}
|
||||
>
|
||||
{serviceLabel(type)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
@@ -468,39 +432,25 @@ export function AppLayout({
|
||||
<Divider />
|
||||
<Box px="sm" py="xs">
|
||||
<Stack gap={6}>
|
||||
{companyProfiles.map((p) => {
|
||||
const isActive = p.type === activeProfileType;
|
||||
return (
|
||||
<Group
|
||||
key={p.reference}
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
{companyProfiles.map((p) => (
|
||||
<Group
|
||||
key={p.reference}
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isActive && (
|
||||
<Check
|
||||
size={13}
|
||||
color={primaryDarkColor}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
|
||||
</Text>
|
||||
<Text size="xs" ff="monospace" c="dimmed">
|
||||
{p.reference}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string, string> = {
|
||||
@@ -10,21 +10,3 @@ export const PROFILE_TYPE_LABELS: Record<string, string> = {
|
||||
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.`;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(
|
||||
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() {
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<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} />
|
||||
|
||||
<StatsSection
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Box, Group, Text } from "@mantine/core";
|
||||
import { ArrowRight, Truck } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ModeIndicator } from "@/components/ModeIndicator";
|
||||
import { cv } from "../constants";
|
||||
|
||||
interface HelloSectionProps {
|
||||
@@ -24,7 +23,6 @@ export const HelloSection = memo(function HelloSection({
|
||||
<Text fz={26} fw={800} c="edr-text" className="tracking-tight">
|
||||
{companyName} 👋
|
||||
</Text>
|
||||
<ModeIndicator />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -5,17 +5,25 @@ import { getMyInvoices } from "@/lib/currentCustomer";
|
||||
import { api } from "@/services/api";
|
||||
import { ACTIVE_STATUSES } from "./constants";
|
||||
|
||||
export function useMyPortalData() {
|
||||
const { user, customer } = useAuth();
|
||||
export function useMyPortalData(selectedProfileId?: string) {
|
||||
const { user, customer, company } = useAuth();
|
||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||
|
||||
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||
|
||||
const bookingsQuery = useQuery(
|
||||
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 activeBookings = allBookings.filter((b) =>
|
||||
@@ -56,6 +64,7 @@ export function useMyPortalData() {
|
||||
return {
|
||||
user,
|
||||
customer,
|
||||
companyProfiles,
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
allBookings,
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { ModeIndicator } from "@/components/ModeIndicator";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import {
|
||||
BookingTypeBadge,
|
||||
CargoModeCell,
|
||||
@@ -256,10 +257,13 @@ function fmtDate(iso?: string | null): string {
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
// 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(
|
||||
api.bookings.list.queryOptions({
|
||||
input: { statuses, page: 1, pageSize: 1 },
|
||||
input: { statuses, companyProfileId, page: 1, pageSize: 1 },
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
@@ -335,8 +339,18 @@ export default function MyBookings() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = 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>(
|
||||
null,
|
||||
);
|
||||
@@ -352,10 +366,15 @@ export default function MyBookings() {
|
||||
};
|
||||
|
||||
const hasExtraFilters =
|
||||
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
|
||||
!!typeFilter ||
|
||||
!!freightFilter ||
|
||||
!!serviceFilter ||
|
||||
!!createdFrom ||
|
||||
!!createdTo;
|
||||
const clearExtraFilters = () => {
|
||||
setTypeFilter(null);
|
||||
setFreightFilter(null);
|
||||
setServiceFilter(null);
|
||||
setCreatedFrom("");
|
||||
setCreatedTo("");
|
||||
resetPage();
|
||||
@@ -366,6 +385,7 @@ export default function MyBookings() {
|
||||
statuses,
|
||||
bookingType: typeFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
companyProfileId: serviceFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
// include the whole selected end day
|
||||
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
|
||||
@@ -376,6 +396,7 @@ export default function MyBookings() {
|
||||
statuses,
|
||||
typeFilter,
|
||||
freightFilter,
|
||||
serviceFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
pagination.pageIndex,
|
||||
@@ -387,25 +408,33 @@ export default function MyBookings() {
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||
const allCount = useStatusCount(undefined);
|
||||
// Per-card lifecycle counts (one cheap query each, total-only). Scoped to the
|
||||
// selected service so the cards match the filtered table.
|
||||
const svc = serviceFilter ?? undefined;
|
||||
const allCount = useStatusCount(undefined, svc);
|
||||
const activeCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const paymentCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const draftCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const doneCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const transitCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const closedCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||
all: allCount,
|
||||
@@ -617,7 +646,6 @@ export default function MyBookings() {
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
Bookings
|
||||
</Title>
|
||||
<ModeIndicator />
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Track every cargo booking — from draft to delivery.
|
||||
@@ -723,6 +751,22 @@ export default function MyBookings() {
|
||||
style={{ width: 150 }}
|
||||
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
|
||||
type="date"
|
||||
value={createdFrom}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ModeIndicator } from "@/components/ModeIndicator";
|
||||
import { PayNowButton } from "../bookings/payments/PayNowButton";
|
||||
import {
|
||||
BORDER,
|
||||
@@ -121,7 +120,6 @@ export default function ContractDetailPage() {
|
||||
{contract.reference}
|
||||
</Title>
|
||||
<ContractStatusBadge status={contract.status} />
|
||||
<ModeIndicator size="sm" />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
General contract · {isContainer ? "Containerised" : "Bulk"}
|
||||
|
||||
@@ -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() {
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
General Contracts
|
||||
</Title>
|
||||
<ModeIndicator />
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Reserve a quantity once, then place orders against it until the
|
||||
|
||||
@@ -128,7 +128,7 @@ export const api = {
|
||||
companiesService.updateProfile,
|
||||
),
|
||||
|
||||
getDashboard: endpoint<void, DashboardSummary>(
|
||||
getDashboard: endpoint<string | void, DashboardSummary>(
|
||||
"companies",
|
||||
"getDashboard",
|
||||
companiesService.getDashboard,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -162,9 +162,12 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getDashboard: async (): Promise<DashboardSummary> => {
|
||||
getDashboard: async (
|
||||
companyProfileId?: string,
|
||||
): Promise<DashboardSummary> => {
|
||||
const response = await client.get<ApiResponse<DashboardSummary>>(
|
||||
URL_CONSTANTS.COMPANIES_API.DASHBOARD,
|
||||
{ params: companyProfileId ? { companyProfileId } : undefined },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user