gti Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI

This commit is contained in:
natib21
2026-07-17 09:47:56 +00:00
65 changed files with 2208 additions and 889 deletions

View File

@@ -61,7 +61,6 @@ interface RefContainerType {
name: string;
code: string;
is_reefer?: boolean;
wagons_per_unit?: number;
}
interface RefContainerGroup {
size: string;

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

@@ -384,7 +384,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Max wagon count",
type: "number",
required: true,
description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
description: "No upper limit — must be at least the min wagon count",
},
{ name: "scorePoints", label: "Score points", type: "number", required: true },
{ name: "isActive", label: "Active", type: "boolean" },

View File

@@ -1,20 +1,14 @@
/**
* Client mirror of the backend's contiguous-range rules for priority configs
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
* currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
* range always starts at the lowest uncovered wagon count. The backend
* re-validates on submit AND on approval; this only drives the form prefill.
* currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next
* range always starts at the lowest uncovered wagon count. There is no upper
* ceiling. The backend re-validates on submit AND on approval; this only
* drives the form prefill.
*/
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
export const PRIORITY_RANGE_CAPS: Record<PriorityRuleType, number> = {
WAGON: 50,
CURRENCY: 35,
CUSTOMS: 15,
};
export interface PriorityRangeRule {
id?: unknown;
type?: unknown;
@@ -23,10 +17,13 @@ export interface PriorityRangeRule {
maxWagonCount?: unknown;
}
const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"];
/**
* Where the next range for `type` (+`currency`) must start, excluding
* `excludeId` (the rule being edited). Null when the chain already covers
* 1..cap — no further rule fits.
* `excludeId` (the rule being edited). Null only when `type` is not yet a
* known priority rule type — the chain itself is unbounded, so a next start
* always exists.
*/
export function nextPriorityRangeStart(
rules: PriorityRangeRule[],
@@ -34,8 +31,7 @@ export function nextPriorityRangeStart(
currency: string | null | undefined,
excludeId?: string,
): number | null {
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
if (!cap) return null;
if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null;
const scoped = rules
.filter(
@@ -56,5 +52,5 @@ export function nextPriorityRangeStart(
if (r.min > next) break; // gap before this rule — fill it first
next = Math.max(next, r.max + 1);
}
return next > cap ? null : next;
return next;
}