This commit is contained in:
Marshal
2026-07-22 10:52:27 +00:00
82 changed files with 2895 additions and 2733 deletions

View File

@@ -385,26 +385,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Import Overview",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
@@ -418,36 +423,43 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Export Overview",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Djibouti Unloading",
href: "/dashboard/export-djibouti-unloading",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Interchange Documents",
href: "/dashboard/interchange-documents",
icon: <FileText />,
permission: FREIGHT_PERMS.interchangeDocuments.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
@@ -461,6 +473,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Intercity Cargo",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
@@ -492,7 +505,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.warehouseAllocationRules.view,
permission: [
FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view,
],
},
{
label: "Fee Invoices",
@@ -555,7 +571,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Staff",
href: "/user-management",
icon: <Users />,
permission: FREIGHT_PERMS.admin,
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
],
},
@@ -600,16 +621,27 @@ const filterSidebarByPermission = (
return keys.some((key) => hasFreightPermission(user, key));
};
const itemAllowed = (item: SidebarItem): boolean => {
// GL positions are locked to their single clearance page.
if (etGl) return isEtClearanceItem(item);
if (djGl) return isDjClearanceItem(item);
// Everyone else: hide the GL-only clearance pages entirely.
if (isClearanceItem(item)) return false;
return permissionAllowed(item);
};
// Recursive: children are filtered first; a group (item with children) stays
// only while it still has visible children — so parents without their own
// permission key never leak a whole subtree the user cannot open.
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
items
.map((item) =>
item.children ? { ...item, children: filterItems(item.children) } : item,
)
.filter((item) => {
if (etGl || djGl) {
// GL positions are locked to their single clearance page (parents
// survive only as the path to that page).
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
return isTarget(item) || (item.children?.length ?? 0) > 0;
}
// Everyone else: hide the GL-only clearance pages entirely.
if (isClearanceItem(item)) return false;
if (!permissionAllowed(item)) return false;
if (item.children) return item.children.length > 0;
return true;
});
// Recursive: a group's own permission gates the whole subtree, leaves are
// checked individually, and a group with no surviving children disappears.

View File

@@ -61,8 +61,11 @@ export default function ResetPasswordAction({
if (!allowed) return null;
// SMS is domestic-only: a foreign number counts as unavailable, same as a
// missing one, so staff can't send a link that will never arrive.
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
const channelMissing =
!!target && (channel === "email" ? !target.email : !target.phone);
!!target && (channel === "email" ? !target.email : !phoneUsable);
return (
<>
@@ -106,9 +109,13 @@ export default function ResetPasswordAction({
<Radio
value="phone"
label="SMS"
disabled={!target.phone}
disabled={!phoneUsable}
description={
target.phone ?? "No phone number on this account"
!target.phone
? "No phone number on this account"
: target.phoneIsDomestic === false
? `${target.phone} — foreign number, SMS unavailable; use email`
: target.phone
}
/>
<Radio

View File

@@ -298,34 +298,82 @@ export function ProfileApprovalActions({
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
);
const [rejectOpen, setRejectOpen] = useState(false);
const [decision, setDecision] = useState<
"reject" | "suspend" | "reactivate" | null
>(null);
const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
const confirmReject = () => {
// Decisions the customer must be given a reason for. Reject/suspend/reactivate
// all capture a required message through the same modal; the API refuses
// suspend/reactivate without one.
const DECISIONS = {
reject: {
title: "Reject profile",
intro:
"Tell the customer what needs fixing. They'll see this note and can " +
"amend and resubmit the role for approval.",
label: "Reason for rejection",
placeholder: "e.g. The uploaded business license is expired.",
confirmLabel: "Reject profile",
color: "red",
status: "rejected" as ProfileStatus,
},
suspend: {
title: "Suspend role",
intro:
"Explain why this role is being suspended. The customer will see this " +
"message and cannot operate under the role until it is reactivated.",
label: "Reason for suspension",
placeholder: "e.g. Outstanding invoices unpaid for over 90 days.",
confirmLabel: "Suspend role",
color: "orange",
status: "suspended" as ProfileStatus,
},
reactivate: {
title: "Reactivate role",
intro:
"Explain why this role is being reactivated. The customer will see " +
"this message and can operate under the role again.",
label: "Reactivation message",
placeholder: "e.g. Outstanding payments have been settled.",
confirmLabel: "Reactivate role",
color: "edr-green",
status: "active" as ProfileStatus,
},
} as const;
const openDecision = (kind: keyof typeof DECISIONS) => {
setNote("");
setDecision(kind);
};
const active = decision ? DECISIONS[decision] : null;
const confirmDecision = () => {
if (!active) return;
mutate(
{ profileId, status: "rejected", note: note.trim() },
{ onSuccess: () => setRejectOpen(false) },
{ profileId, status: active.status, note: note.trim() },
{ onSuccess: () => setDecision(null) },
);
};
const rejectModal = (
const decisionModal = active && (
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
title="Reject profile"
opened
onClose={() => setDecision(null)}
title={active.title}
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Tell the customer what needs fixing. They'll see this note and can
amend and resubmit the role for approval.
{active.intro}
</Text>
<Textarea
label="Reason for rejection"
placeholder="e.g. The uploaded business license is expired."
label={active.label}
placeholder={active.placeholder}
autosize
minRows={3}
value={note}
@@ -335,18 +383,18 @@ export function ProfileApprovalActions({
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectOpen(false)}
onClick={() => setDecision(null)}
disabled={isPending}
>
Cancel
</Button>
<Button
color="red"
color={active.color}
loading={isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
onClick={confirmDecision}
>
Reject profile
{active.confirmLabel}
</Button>
</Group>
</Stack>
@@ -368,7 +416,7 @@ export function ProfileApprovalActions({
if (status === "pending") {
return (
<>
{rejectModal}
{decisionModal}
<Group gap={6} wrap="nowrap">
<Button
size="xs"
@@ -385,7 +433,7 @@ export function ProfileApprovalActions({
variant="light"
color="red"
radius="md"
onClick={() => setRejectOpen(true)}
onClick={() => openDecision("reject")}
>
Reject
</Button>
@@ -411,29 +459,33 @@ export function ProfileApprovalActions({
if (status === "active") {
return (
<Button
size="xs"
variant="light"
color="orange"
radius="md"
loading={isPending}
onClick={() => act("suspended")}
>
Suspend
</Button>
<>
{decisionModal}
<Button
size="xs"
variant="light"
color="orange"
radius="md"
loading={isPending}
onClick={() => openDecision("suspend")}
>
Suspend
</Button>
</>
);
}
if (status === "suspended") {
return (
<Group gap={6} wrap="nowrap">
{decisionModal}
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
onClick={() => openDecision("reactivate")}
>
Reactivate
</Button>

View File

@@ -2,41 +2,59 @@ import { useNavigate } from "react-router-dom";
import { ArrowRight, FileText, Train, Users } from "lucide-react";
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
const links = [
{
title: "Booking requests",
description: "Review and action incoming freight bookings",
href: "/dashboard/booking-requests",
icon: FileText,
permission: [FREIGHT_PERMS.bookings.view],
},
{
title: "Train scheduling v2",
description: "Full allocation workflow — assign, pin wagons, finalize",
href: "/dashboard/operations/train-scheduling-v2",
icon: Train,
permission: [FREIGHT_PERMS.trainScheduling.view],
},
{
title: "Trains",
description: "Manage train master data and fleet status",
href: "/dashboard/trains",
icon: Train,
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
},
{
title: "User management",
description: "Employees, roles, and permissions",
href: "/user-management",
icon: Users,
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
];
export function OverviewQuickLinks() {
const navigate = useNavigate();
const { user } = useAuth();
const visible = links.filter((link) =>
link.permission.some((key) => hasPermission(user, key)),
);
if (!visible.length) return null;
return (
<Stack gap="md" h="100%">
<Text fw={600}>Quick links</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{links.map((link) => {
{visible.map((link) => {
const Icon = link.icon;
return (
<Card

View File

@@ -378,6 +378,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
);
// A truck may only carry out its OWN assigned containers — when the selected
// truck has an assigned load, other trucks' containers are not offered.
const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase());
// Mantine Selects throw on duplicate option values — legacy bookings can carry
// the same container number on two lines, so dedupe defensively.
const containerSelectData = [
@@ -390,7 +393,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
},
]),
).values(),
];
].filter(
(option) =>
assignedLoad.length === 0 ||
assignedLoad.includes(option.value.toUpperCase()) ||
containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()),
);
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedCargoWeight = Number(
selectedContainerNumbers

View File

@@ -81,6 +81,10 @@ const VIEW_FILTERS: Record<
};
const SORT_OPTIONS = [
// Queue ordering: awaiting first approval → pending profile changes → the
// rest, newest first within each group. The default, so whatever marketing
// must act on is always on top of the list.
{ value: "review:DESC", label: "Needs review first" },
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
{ value: "name:ASC", label: "Name (AZ)" },
@@ -93,11 +97,11 @@ export default function CustomersPage() {
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [view, setView] = useState<CustomerView>("all");
const [sort, setSort] = useState<string>("createdAt:DESC");
const [sort, setSort] = useState<string>("review:DESC");
const filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [
"name" | "createdAt" | "updatedAt",
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {

View File

@@ -20,12 +20,14 @@ import {
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
import "@/components/overview/overview.css";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
const TAB_ITEMS: Array<{
@@ -40,6 +42,8 @@ const TAB_ITEMS: Array<{
| "customers"
| "staff";
metricKey: string;
/** Any of these keys grants the tab. */
permission: string[];
}> = [
{
value: "bookings",
@@ -47,6 +51,7 @@ const TAB_ITEMS: Array<{
icon: FileText,
kpiKey: "bookings",
metricKey: "totalActive",
permission: [FREIGHT_PERMS.bookings.view],
},
{
value: "contracts",
@@ -54,6 +59,7 @@ const TAB_ITEMS: Array<{
icon: FileSignature,
kpiKey: "contracts",
metricKey: "totalActive",
permission: [FREIGHT_PERMS.contracts.view],
},
{
value: "billing",
@@ -61,6 +67,7 @@ const TAB_ITEMS: Array<{
icon: Banknote,
kpiKey: "billing",
metricKey: "successfulPaymentsMtd",
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
},
{
value: "operations",
@@ -68,6 +75,12 @@ const TAB_ITEMS: Array<{
icon: Train,
kpiKey: "operations",
metricKey: "trainsActive",
permission: [
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.firstMile.view,
FREIGHT_PERMS.lastMile.view,
],
},
{
value: "customers",
@@ -75,6 +88,7 @@ const TAB_ITEMS: Array<{
icon: Users,
kpiKey: "customers",
metricKey: "totalCustomers",
permission: [FREIGHT_PERMS.customers.view],
},
{
value: "staff",
@@ -82,6 +96,12 @@ const TAB_ITEMS: Array<{
icon: UserCheck,
kpiKey: "staff",
metricKey: "activeEmployees",
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
];
@@ -98,7 +118,19 @@ const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
const queryClient = useQueryClient();
const { data: summary, isLoading, isError, refetch, isFetching } = useOverview(range);
const { user } = useAuth();
const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range);
// Permission-scoped view: only tabs the user may see; a restricted role
// (e.g. operations) gets a summary 403 — that is not a connection problem.
const visibleTabs = TAB_ITEMS.filter((tab) =>
tab.permission.some((key) => hasPermission(user, key)),
);
const currentTab = visibleTabs.some((t) => t.value === activeTab)
? activeTab
: visibleTabs[0]?.value;
const accessDenied =
(error as { response?: { status?: number } } | null)?.response?.status === 403;
const handleRefresh = () => {
void refetch();
@@ -126,7 +158,7 @@ const OverviewPage = () => {
/>
)}
{isError && (
{isError && !accessDenied && (
<Alert
icon={<AlertCircle size={16} />}
color="red"
@@ -142,48 +174,52 @@ const OverviewPage = () => {
</Alert>
)}
<Tabs
value={activeTab}
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
variant="pills"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{TAB_ITEMS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.value;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={<Icon size={17} />}
rightSection={
summary ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
>
{getTabBadge(tab)}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
{visibleTabs.length > 0 && (
<Tabs
value={currentTab}
onChange={(value) =>
setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value)
}
variant="pills"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = currentTab === tab.value;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={<Icon size={17} />}
rightSection={
summary ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
>
{getTabBadge(tab)}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
{TAB_ITEMS.map((tab) => (
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
<OverviewTabContent tab={tab.value} range={range} />
</Tabs.Panel>
))}
</Tabs>
{visibleTabs.map((tab) => (
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
<OverviewTabContent tab={tab.value} range={range} />
</Tabs.Panel>
))}
</Tabs>
)}
<Paper p="lg" radius="lg" withBorder>
<OverviewQuickLinks />

View File

@@ -936,6 +936,12 @@ const FirstMilePage = () => {
};
const openBulkAssign = () => {
// A single selection has full booking context (details, container list) —
// use the richer single-record flow instead of the blank bulk form.
if (selectedIds.length === 1) {
openAssign(selectedIds[0]);
return;
}
setBulkMode(true);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);

View File

@@ -1066,6 +1066,12 @@ const LastMilePage = () => {
};
const openBulkAssign = () => {
// A single selection has full booking context (details, container list) —
// use the richer single-record flow instead of the blank bulk form.
if (selectedIds.length === 1) {
openAssign(selectedIds[0]);
return;
}
setBulkMode(true);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);

View File

@@ -131,6 +131,8 @@ export interface CustomerResetTarget {
name: string;
email: string | null;
phone: string | null;
/** SMS gateway is domestic-only; `false` means SMS can't reach this phone. `null` = no phone. */
phoneIsDomestic: boolean | null;
}
/** Mirrors backend `Company` (+ its `companyProfiles`). */
@@ -207,7 +209,8 @@ export interface CompanyListFilter {
* already `active`, so `status` alone can never surface them.
*/
hasPendingChangeRequest?: boolean;
sortBy?: "name" | "createdAt" | "updatedAt";
/** `review` = queue ordering: awaiting first approval → pending changes → rest, newest first within each. */
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
sortOrder?: "ASC" | "DESC";
}