Merge pull request #755 from Tria-plc/freight/feat/fixes-v1

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-17 11:55:43 +03:00
committed by GitHub
12 changed files with 490 additions and 254 deletions

View File

@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@Injectable()
export class CompaniesRepository extends BaseRepository<Company> {
/**
* A company still being filled in by its owner in the portal wizard: it was
* self-registered (so it has an external profile) and nobody has submitted
* onboarding yet. The row exists from the wizard's first click, carrying a
* placeholder name + TIN, so it must not be offered up for review.
* Staff-created companies have no external profiles and are never drafts.
*/
private static readonly DRAFT_SQL = `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
constructor(
@InjectRepository(Company)
repo: Repository<Company>,
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const {
page = 1,
pageSize = 20,
search,
type,
kind,
status,
onboardingCompleted,
} = query;
const qb = this.repository
.createQueryBuilder('company')
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
// External profiles carry onboardingCompleted, which the backoffice list
// uses to flag customers still mid-onboarding (not yet reviewable).
.leftJoinAndSelect('company.profiles', 'profiles')
.where('company.deleted_at IS NULL');
if (type) {
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.status = :status', { status });
}
if (onboardingCompleted !== undefined) {
qb.andWhere(
onboardingCompleted
? `NOT ${CompaniesRepository.DRAFT_SQL}`
: CompaniesRepository.DRAFT_SQL,
);
}
if (search) {
const term = `%${search.trim()}%`;
qb.andWhere(
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
}
async getStats(): Promise<CompanyStatsResponseDto> {
const rows: { status: string; count: string }[] = await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.getRawMany();
// Drafts are counted separately rather than under `pending`: they carry
// status=pending from creation, which would otherwise inflate the review
// queue's KPI with customers who haven't submitted anything yet.
const rows: { status: string; is_draft: boolean; count: string }[] =
await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.addGroupBy(CompaniesRepository.DRAFT_SQL)
.getRawMany();
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const map = new Map<string, number>();
let onboarding = 0;
let total = 0;
for (const row of rows) {
const count = parseInt(row.count, 10);
total += count;
if (row.is_draft) onboarding += count;
else map.set(row.status, (map.get(row.status) ?? 0) + count);
}
return {
total,
active: map.get('active') ?? 0,
pending: map.get('pending') ?? 0,
onboarding,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
};

View File

@@ -372,6 +372,9 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
// External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id);
return company;
}
@@ -962,6 +965,28 @@ export class CompaniesService {
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an
// application that doesn't exist yet. Staff-created companies have no
// external profiles and are exempt.
//
// Only the review decision itself is gated (a profile still awaiting one:
// Pending, or Rejected and awaiting re-approval). Profiles already in
// service stay managable so staff can suspend/blacklist them — including to
// undo an approval granted before this guard existed.
const awaitingReview =
existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected;
if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
);
}
}
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };

View File

@@ -1,7 +1,10 @@
export class CompanyStatsResponseDto {
total!: number;
active!: number;
/** Submitted applications awaiting review. Excludes drafts. */
pending!: number;
/** Self-registered companies still working through the onboarding wizard. */
onboarding!: number;
suspended!: number;
blacklisted!: number;
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
@@ -37,4 +37,14 @@ export class ListCompaniesQueryDto {
@IsOptional()
@IsIn(Object.values(CompanyStatus))
status?: CompanyStatus;
@ApiPropertyOptional({
description:
"Filter by onboarding submission. `true` = reviewable applications; " +
"`false` = drafts still in the portal wizard. Omit for both.",
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
@IsBoolean()
onboardingCompleted?: boolean;
}

View File

@@ -62,6 +62,13 @@ export class ResponseCompanyDto {
attributes?: Record<string, any> | null;
profiles?: ResponseExternalProfileDto[];
companyProfiles?: ResponseCompanyProfileDto[];
/**
* Whether the owning portal user has submitted the onboarding wizard.
* Approval decisions are blocked while this is false. Staff-created
* companies (no external profiles) count as completed. Undefined when the
* external profiles weren't loaded.
*/
onboardingCompleted?: boolean;
createdAt: Date;
updatedAt: Date;
@@ -84,6 +91,10 @@ export class ResponseCompanyDto {
this.companyProfiles = company.companyProfiles?.map(
(p) => new ResponseCompanyProfileDto(p),
);
this.onboardingCompleted = company.profiles
? company.profiles.length === 0 ||
company.profiles.some((p) => p.onboardingCompleted)
: undefined;
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -18,6 +18,7 @@ import {
Send,
Settings,
ShieldCheck,
Settings2,
Ship,
SlidersHorizontal,
Train,
@@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <LayoutDashboard />,
},
{
label: "Staff",
href: "/user-management",
icon: <Users />,
},
{
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
},
{
label: "Contracts",
@@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
{
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
// Operations hub: clearance-document review for contracts WITHOUT
// customs clearing (contract-level for one-time, per-booking for general).
{
@@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
},
{
label: "Payments",
href: "/dashboard/payments",
@@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
],
},
{
title: "Operations",
// title: "Port & Terminal",
items: [
{
label: "Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
label: "Operations",
icon: <Settings />,
children: [
{
label: "Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
icon: <Ship />,
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
icon: <Ship />,
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
label: "Fleet Management",
icon: <Truck />,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
{
title: "Fleet Management",
items: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: FREIGHT_PERMS.fleet.view,
},
children: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
title: "Port & Terminal",
items: [
{
label: "Imports",
href: "/dashboard/import-warehouse",
@@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
],
},
],
},
{
title: "Warehouse Management",
items: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
label: "Warehouse Management",
icon: <Container />,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
children: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
},
],
},
],
},
{
title: "Administration",
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "File settings",
@@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "Configuration",
href: "/dashboard/configuration",
@@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
{
label: "Staff",
href: "/user-management",
icon: <Users />,
},
],
},
];
@@ -599,7 +599,10 @@ const findActiveSidebarLabel = (
): string | undefined => {
const path = pathname.toLowerCase();
const candidates = flattenSidebarItems(sections)
.map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() }))
.map(({ href, label }) => ({
label,
href: href.split("?")[0].toLowerCase(),
}))
.sort((a, b) => b.href.length - a.href.length);
return candidates.find(
@@ -674,10 +677,7 @@ const App = () => {
<Routes>
<Route path="/auth" element={<LoginPage />} />
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route
path="um/set-password"
element={<SetPassword />}
/>
<Route path="um/set-password" element={<SetPassword />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>

View File

@@ -280,13 +280,20 @@ export function InvoiceStatusBadge({
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
* Rejecting captures a note the customer sees so they can fix and reapply.
*
* `locked` (customer hasn't submitted onboarding) withholds the review decision
* only — there's no application to judge yet, and the API rejects the call
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
* an already-active profile is still managable.
*/
export function ProfileApprovalActions({
profileId,
status,
locked = false,
}: {
profileId: string;
status: ProfileStatus;
locked?: boolean;
}) {
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
@@ -346,6 +353,18 @@ export function ProfileApprovalActions({
</Modal>
);
// Pending/rejected are the two states awaiting a reviewer's decision the
// exact pair the API gates on until the customer submits.
if (locked && (status === "pending" || status === "rejected")) {
return (
<Tooltip label="Available once the customer submits their onboarding application">
<Text size="xs" c="dimmed" fs="italic">
Awaiting submission
</Text>
</Tooltip>
);
}
if (status === "pending") {
return (
<>

View File

@@ -92,7 +92,9 @@ const FreightSidebar = ({
walk(item.children, key);
});
};
sections.forEach((section) => walk(section.items, section.title));
sections.forEach((section, i) =>
walk(section.items, section?.title ?? "" + i++),
);
return acc;
}, [sections, isHrefActive, branchActive]);
@@ -126,6 +128,7 @@ const FreightSidebar = ({
opened={isOpen}
classNames={navClassNames(active)}
onClick={() => toggle(key)}
childrenOffset="sm"
rightSection={
<Box
component="span"
@@ -142,7 +145,7 @@ const FreightSidebar = ({
size={16}
className="text-edr-muted transition-transform duration-200"
style={{
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
transform: isOpen ? "rotate(-180deg)" : "rotate(0deg)",
}}
/>
</Box>
@@ -166,6 +169,7 @@ const FreightSidebar = ({
active={active}
component={Link}
classNames={navClassNames(active)}
onClick={onClose}
to={item.href!}
/>
);
@@ -177,19 +181,21 @@ const FreightSidebar = ({
() =>
sections.map((section) => (
<Box key={section.title}>
<Text
size="xs"
tt="uppercase"
px="sm"
mb={6}
className={"text-edr-muted!"}
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
{section.title}
</Text>
{section.title && (
<Text
size="xs"
tt="uppercase"
px="sm"
mb={6}
className={"text-edr-muted!"}
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
{section.title}
</Text>
)}
<Stack gap={2}>
{section.items.map((item, i) =>
renderItem(item, itemKey(section.title, item, i)),
renderItem(item, itemKey(section.title ?? "" + i, item, i)),
)}
</Stack>
</Box>
@@ -257,7 +263,7 @@ const FreightSidebar = ({
px="sm"
pb="md"
>
<Stack gap="lg">{renderedSections}</Stack>
<Stack gap="md">{renderedSections}</Stack>
</AppShell.Section>
</AppShell.Navbar>
);

View File

@@ -12,7 +12,7 @@ export interface SidebarItem {
export interface SidebarSection {
/** Section label shown above a group of nav items (e.g. "Main menu"). */
title: string;
title?: string;
items: SidebarItem[];
/** When true, section title uses muted grey instead of dark text. */
mutedTitle?: boolean;

View File

@@ -1,5 +1,6 @@
import {
ActionIcon,
Alert,
Anchor,
Badge,
Box,
@@ -22,6 +23,7 @@ import {
Download,
Eye,
FileText,
Hourglass,
IdCard,
LayoutGrid,
Package,
@@ -60,6 +62,7 @@ import type {
CustomerDocument,
CustomerPayment,
} from "@/types/customer";
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
@@ -166,6 +169,13 @@ export default function CustomerDetailPage() {
);
const paidCurrency = payments[0]?.currency ?? "ETB";
// The company row is created on the wizard's first click, so a draft reaches
// this page with a placeholder name/TIN. `stillOnboarding` drives the banner
// and badge; `canReview` gates the approve/reject buttons and mirrors the
// API's rule exactly, so no button is offered that the server would reject.
const stillOnboarding = company ? isOnboardingDraft(company) : false;
const canReview = company ? hasSubmittedOnboarding(company) : true;
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
() => [
{
@@ -273,11 +283,12 @@ export default function CustomerDetailPage() {
<ProfileApprovalActions
profileId={row.original.id}
status={row.original.status}
locked={!canReview}
/>
),
},
],
[view],
[view, canReview],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
@@ -602,7 +613,13 @@ export default function CustomerDetailPage() {
meta={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} />
{stillOnboarding ? (
<Badge color="gray" variant="light" size="sm" radius="sm">
Onboarding in progress
</Badge>
) : (
<CompanyStatusBadge status={company.status} />
)}
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
@@ -631,6 +648,21 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
{stillOnboarding && (
<Alert
color="gray"
variant="light"
radius="md"
icon={<Hourglass size={18} />}
title="This customer hasn't submitted their application yet"
>
They're still filling in the onboarding wizard, so the details
below are an unfinished draft — the company name and TIN are
placeholders until they reach those steps. Role profiles become
reviewable once the application is submitted.
</Alert>
)}
<ChangeRequestReview company={company} />
<KpiStrip
@@ -642,10 +674,16 @@ export default function CustomerDetailPage() {
color: "edr-green",
},
{
label: "Pending approval",
value: company.companyProfiles.filter(
(p) => p.status === "pending",
).length,
// A draft's profiles are all `pending` by construction, which
// would read as a review backlog that doesn't exist yet.
label: stillOnboarding
? "Awaiting submission"
: "Pending approval",
value: stillOnboarding
? "—"
: company.companyProfiles.filter(
(p) => p.status === "pending",
).length,
icon: IdCard,
color: "yellow",
},

View File

@@ -16,6 +16,7 @@ import {
Building2,
CheckCircle2,
Clock,
Hourglass,
Mail,
Phone,
RefreshCw,
@@ -36,6 +37,7 @@ import {
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import {
DataTable,
DataTableFooter,
@@ -43,22 +45,39 @@ import {
type ColumnDef,
} from "@edr/ui-common";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
* review, so it excludes drafts — a company row exists from the onboarding
* wizard's first click and would otherwise pad the review queue. Those drafts
* get their own view instead of disappearing, so staff can still chase them.
*/
type CustomerView = "all" | "pending" | "onboarding" | "active";
const VIEW_FILTERS: Record<
CustomerView,
{ status?: CompanyStatus; onboardingCompleted?: boolean }
> = {
all: {},
pending: { status: "pending", onboardingCompleted: true },
onboarding: { onboardingCompleted: false },
active: { status: "active" },
};
export default function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
const [view, setView] = useState<CustomerView>("all");
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
...VIEW_FILTERS[view],
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
[pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
@@ -114,6 +133,17 @@ export default function CustomersPage() {
id: "status",
header: "Status",
cell: ({ row }) => {
// A draft's profiles are all `pending` by construction, so the
// "N pending" review hint would be a lie until they submit.
if (isOnboardingDraft(row.original)) {
return (
<Tooltip label="Customer is still filling in the onboarding wizard">
<Badge color="gray" variant="light" size="sm" radius="sm">
Onboarding
</Badge>
</Tooltip>
);
}
const pending = (row.original.companyProfiles ?? []).filter(
(p) => p.status === "pending",
).length;
@@ -206,6 +236,12 @@ export default function CustomersPage() {
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
{
label: "Onboarding",
value: stats?.onboarding ?? "—",
icon: Hourglass,
color: "gray",
},
{
label: "Blacklisted",
value: stats?.blacklisted ?? "—",
@@ -243,14 +279,15 @@ export default function CustomersPage() {
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
value={view}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
setView(v as CustomerView);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
/>

View File

@@ -146,10 +146,38 @@ export interface Company {
website?: string | null;
attributes?: Record<string, unknown> | null;
companyProfiles: CompanyProfile[];
/**
* Whether the customer submitted their onboarding application. A company row
* is created on the wizard's first click, so a `pending` company with this
* false is a half-filled draft — not reviewable. Staff-created companies are
* always true. Undefined on endpoints that don't load external profiles.
*/
onboardingCompleted?: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Whether the customer has submitted their onboarding application. Mirrors the
* API's review gate (`setCompanyProfileStatus`): until this is true, a role
* awaiting a decision cannot be approved or rejected. Companies loaded without
* external profiles (`undefined`) are treated as submitted — absence of the
* flag must not lock staff out.
*/
export function hasSubmittedOnboarding(company: Company): boolean {
return company.onboardingCompleted !== false;
}
/**
* A pristine draft: still `pending` and never submitted, so its name/TIN are
* placeholders and there is nothing to review. Drives presentation only — the
* approval gate is `hasSubmittedOnboarding`, which also covers the (corrupted)
* case of a company activated before that gate existed.
*/
export function isOnboardingDraft(company: Company): boolean {
return company.status === "pending" && !hasSubmittedOnboarding(company);
}
/** Query parameters for the company list. */
export interface CompanyListFilter {
page: number;
@@ -158,6 +186,8 @@ export interface CompanyListFilter {
type?: CompanyType;
kind?: CompanyKind;
status?: CompanyStatus;
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
onboardingCompleted?: boolean;
}
/** Standard paginated list envelope (matches the bookings service shape). */
@@ -170,7 +200,10 @@ export interface PaginatedCompanies {
export interface CompanyStats {
total: number;
active: number;
/** Submitted applications awaiting review. Excludes drafts. */
pending: number;
/** Self-registered companies still working through the onboarding wizard. */
onboarding: number;
suspended: number;
blacklisted: number;
}