mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
fix schule issue and contianer type issue
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const URL_CONSTANTS = {
|
||||
REFRESH_TOKEN: "/api/auth/refresh-token",
|
||||
LOGOUT: "/api/auth/logout",
|
||||
PROFILE: "/auth/profile",
|
||||
CHANGE_PASSWORD: "/api/auth/change-password",
|
||||
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
|
||||
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
|
||||
},
|
||||
@@ -19,6 +20,15 @@ export const URL_CONSTANTS = {
|
||||
CHECK_AVAILABILITY: "/api/auth/check-availability",
|
||||
},
|
||||
|
||||
// The signed-in user's own account record. Distinct from COMPANIES_API.PROFILE,
|
||||
// which is the company's business profile — these are the identity fields that
|
||||
// OTPs and SMS notifications are actually delivered to.
|
||||
ACCOUNT: {
|
||||
CONTACT_OTP: "/api/me/contact/otp",
|
||||
CONTACT: "/api/me/contact",
|
||||
NAME: "/api/me/name",
|
||||
},
|
||||
|
||||
OTP: {
|
||||
SEND: "/api/otp/send",
|
||||
VERIFY: "/api/otp/verify",
|
||||
|
||||
@@ -29,17 +29,20 @@ import {
|
||||
ShieldCheck,
|
||||
User,
|
||||
UserCheck,
|
||||
UserCog,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||
import TabAccount from "./settings/TabAccount";
|
||||
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
||||
import TabContactPerson from "./settings/TabContactPerson";
|
||||
import TabDocuments from "./settings/TabDocuments";
|
||||
import TabGeneralManager from "./settings/TabGeneralManager";
|
||||
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
||||
|
||||
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
|
||||
type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents";
|
||||
|
||||
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
||||
function tabIncomplete(
|
||||
@@ -62,6 +65,9 @@ function tabIncomplete(
|
||||
!profile.generalManagerEmail ||
|
||||
!profile.generalManagerPhone
|
||||
);
|
||||
case "account":
|
||||
// Account fields live on the IAM user, not the company profile, and are
|
||||
// always populated (signup requires them) — nothing to nag about here.
|
||||
case "poa":
|
||||
case "documents":
|
||||
return false;
|
||||
@@ -69,6 +75,7 @@ function tabIncomplete(
|
||||
}
|
||||
|
||||
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "account", label: "Account", icon: <UserCog size={16} /> },
|
||||
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
|
||||
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
||||
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
|
||||
@@ -175,6 +182,7 @@ function ProfileHeader({ profile }: { profile: ProfileResponse }) {
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = (searchParams.get("tab") as SettingsTab) || "company";
|
||||
|
||||
@@ -313,6 +321,21 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{/* Deliberately NOT wrapped in the `locked` fieldset below: that lock
|
||||
is for company-profile edits awaiting review. Account identity is
|
||||
the user's own login/notification details — they must stay editable
|
||||
even mid-review, or a customer whose phone changed while pending
|
||||
would be locked out of their own OTPs. */}
|
||||
<Tabs.Panel value="account">
|
||||
{user ? (
|
||||
<TabAccount user={user} />
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* While a change request is pending, every panel's inputs + submit
|
||||
buttons are disabled via the native fieldset; tab switching stays
|
||||
enabled so the customer can still review what they submitted. */}
|
||||
|
||||
@@ -56,11 +56,10 @@ export default function ContractViewPage() {
|
||||
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
// The signing OTP goes to the CONTRACT COMPANY's registered phone (the number
|
||||
// the server verifies against), NOT the signed-in user's — those can differ,
|
||||
// and sending to the user's phone left the code filed under a number verify
|
||||
// never checks. The server owns the number; we only get back a masked hint of
|
||||
// where it landed.
|
||||
// The signing OTP goes to the signed-in user's own registered phone, resolved
|
||||
// server-side from their account (the same number the server verifies
|
||||
// against). The client never picks the number, so send and verify can't
|
||||
// disagree; we only get back a masked hint of where it landed.
|
||||
const [otpSentTo, setOtpSentTo] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { CheckCircle2, KeyRound, Save, XCircle } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
/**
|
||||
* Mirrors IAM's own `IsStrongPassword` rule on ChangePasswordDto — minLength 8,
|
||||
* ≥1 lowercase, ≥1 number, ≥1 symbol, uppercase NOT required. Kept in step with
|
||||
* the server so the user gets a precise message inline instead of a generic 400.
|
||||
*/
|
||||
const strongPassword = z
|
||||
.string()
|
||||
.min(8, "At least 8 characters")
|
||||
.regex(/[a-z]/, "Include a lowercase letter")
|
||||
.regex(/\d/, "Include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Include a symbol");
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
oldPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: strongPassword,
|
||||
confirmPassword: z.string().min(1, "Confirm your new password"),
|
||||
})
|
||||
.refine((d) => d.newPassword === d.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
})
|
||||
.refine((d) => d.newPassword !== d.oldPassword, {
|
||||
path: ["newPassword"],
|
||||
message: "New password must be different from your current one",
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
/** IAM returns bare error codes; turn them into something a customer can act on. */
|
||||
const MESSAGES: Record<string, string> = {
|
||||
unable_to_change_password: "Your current password is incorrect.",
|
||||
new_password_same_as_old:
|
||||
"New password must be different from your current one.",
|
||||
new_passwords_do_not_match: "The new passwords do not match.",
|
||||
user_credentials_not_found: "This account has no password set.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Password changes go straight to IAM's `PATCH /api/auth/change-password`, which
|
||||
* verifies the old password and owns the credential write (argon hashing,
|
||||
* retiring the previous credential). The freight app deliberately implements no
|
||||
* part of that — it only collects the fields.
|
||||
*/
|
||||
export default function ChangePasswordCard() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) => api.auth.changePassword.call(data),
|
||||
// Never leave the old password sitting in component state after a change.
|
||||
onSuccess: () => reset(),
|
||||
});
|
||||
|
||||
const errorMessage = (err: unknown): string => {
|
||||
const raw = (
|
||||
err as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data?.message;
|
||||
const code = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!code) return "Could not change your password. Please try again.";
|
||||
return MESSAGES[code] ?? code;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<KeyRound size={20} />
|
||||
<Title order={3}>Password</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Change the password you use to sign in. You'll need your current one.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
|
||||
<Stack gap="md">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
autoComplete="current-password"
|
||||
error={errors.oldPassword?.message}
|
||||
{...register("oldPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
description="At least 8 characters, with a lowercase letter, a number and a symbol."
|
||||
autoComplete="new-password"
|
||||
error={errors.newPassword?.message}
|
||||
{...register("newPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm New Password"
|
||||
autoComplete="new-password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{mutation.isSuccess && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Password changed
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{mutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{errorMessage(mutation.error)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
399
apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
Normal file
399
apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
UserCog,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
ControlledPhoneField,
|
||||
isValidPhone,
|
||||
toEthiopianE164,
|
||||
} from "@/components/PhoneField";
|
||||
import type { AuthUser, ContactChannel } from "@/types/auth";
|
||||
import ChangePasswordCard from "./ChangePasswordCard";
|
||||
|
||||
const schema = z.object({
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.min(1, "Phone number is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
email: z.string().min(1, "Email is required").email("Enter a valid email"),
|
||||
nameEn: z.string().min(1, "Name is required"),
|
||||
nameAm: z.string().min(1, "Amharic name is required"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
/** A contact change that still needs its code entered. */
|
||||
interface PendingChange {
|
||||
channel: ContactChannel;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL: Record<ContactChannel, string> = {
|
||||
phone: "phone number",
|
||||
email: "email address",
|
||||
};
|
||||
|
||||
const normaliseEmail = (v: string) => v.trim().toLowerCase();
|
||||
|
||||
interface TabAccountProps {
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user's own account — the phone and email that OTPs and SMS
|
||||
* notifications are actually delivered to. Distinct from the company profile
|
||||
* tabs, which hold business contact details for the organisation.
|
||||
*
|
||||
* Changing phone or email is verified: the server sends a code to the NEW value
|
||||
* and only writes it once the code comes back, so a typo'd number can never
|
||||
* silently take over the account's notifications. Each code is bound to a single
|
||||
* channel, so changing both walks the user through one verification per channel.
|
||||
*/
|
||||
export default function TabAccount({ user }: TabAccountProps) {
|
||||
const queryClient = useQueryClient();
|
||||
// Head of the queue is the change currently being verified. Changing phone AND
|
||||
// email in one save enqueues both — a code proves one channel, never two.
|
||||
const [queue, setQueue] = useState<PendingChange[]>([]);
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [completed, setCompleted] = useState<ContactChannel[]>([]);
|
||||
const [otp, setOtp] = useState("");
|
||||
|
||||
const current = queue[0] ?? null;
|
||||
const step = completed.length + 1;
|
||||
const totalSteps = completed.length + queue.length;
|
||||
|
||||
const defaultValues = useMemo(
|
||||
(): FormData => ({
|
||||
// Normalise to the same E.164 shape the phone input emits. Some accounts
|
||||
// store a local `0911…`; comparing raw against the field's `+2519…` would
|
||||
// read as "changed" on every save and hijack the email's turn.
|
||||
phoneNumber: toEthiopianE164(user.phoneNumber),
|
||||
email: user.email ?? "",
|
||||
nameEn: user.name?.en ?? "",
|
||||
nameAm: user.name?.am ?? "",
|
||||
}),
|
||||
[user],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
values: defaultValues,
|
||||
// Verifying one channel refetches the user, which re-syncs `values`. Without
|
||||
// keepDirtyValues that resync would silently discard an edit the user has
|
||||
// typed into the *other* field but not yet verified.
|
||||
resetOptions: { keepDirtyValues: true },
|
||||
});
|
||||
|
||||
const refreshUser = () =>
|
||||
queryClient.invalidateQueries({ queryKey: api.auth.getMyInfo.queryKey() });
|
||||
|
||||
/** Name needs no proof of possession, so it saves straight through. */
|
||||
const nameMutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.account.updateName.call({
|
||||
name: { en: data.nameEn, am: data.nameAm },
|
||||
}),
|
||||
onSuccess: refreshUser,
|
||||
});
|
||||
|
||||
/** Step 1 of a contact change: ask the server to code the new value. */
|
||||
const otpMutation = useMutation({
|
||||
mutationFn: (change: PendingChange) =>
|
||||
api.account.sendContactOtp.call(change),
|
||||
onSuccess: (res) => {
|
||||
setOtp("");
|
||||
setSentTo(res.sentTo);
|
||||
},
|
||||
onError: () => {
|
||||
// Could not even send — drop the flow rather than strand the user in a
|
||||
// modal asking for a code that was never issued.
|
||||
setQueue([]);
|
||||
setSentTo(null);
|
||||
},
|
||||
});
|
||||
|
||||
/** Step 2: hand the code back; the server verifies and writes atomically. */
|
||||
const contactMutation = useMutation({
|
||||
mutationFn: (body: PendingChange & { otp: string }) =>
|
||||
api.account.updateContact.call(body),
|
||||
onSuccess: async (_res, body) => {
|
||||
setOtp("");
|
||||
setSentTo(null);
|
||||
setCompleted((prev) => [...prev, body.channel]);
|
||||
await refreshUser();
|
||||
|
||||
// Advance to the next queued channel, keeping the modal open so a
|
||||
// both-changed save is one continuous flow.
|
||||
const rest = queue.slice(1);
|
||||
setQueue(rest);
|
||||
if (rest[0]) otpMutation.mutate(rest[0]);
|
||||
},
|
||||
});
|
||||
|
||||
const startQueue = (changes: PendingChange[]) => {
|
||||
setCompleted([]);
|
||||
setQueue(changes);
|
||||
otpMutation.mutate(changes[0]);
|
||||
};
|
||||
|
||||
const cancelQueue = () => {
|
||||
setQueue([]);
|
||||
setSentTo(null);
|
||||
setOtp("");
|
||||
contactMutation.reset();
|
||||
};
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
setCompleted([]);
|
||||
|
||||
const changes: PendingChange[] = [];
|
||||
if (toEthiopianE164(data.phoneNumber) !== defaultValues.phoneNumber) {
|
||||
changes.push({ channel: "phone", value: data.phoneNumber });
|
||||
}
|
||||
if (normaliseEmail(data.email) !== normaliseEmail(defaultValues.email)) {
|
||||
changes.push({ channel: "email", value: normaliseEmail(data.email) });
|
||||
}
|
||||
|
||||
// Name carries no verification, so it saves alongside rather than queueing.
|
||||
if (
|
||||
data.nameEn !== defaultValues.nameEn ||
|
||||
data.nameAm !== defaultValues.nameAm
|
||||
) {
|
||||
nameMutation.mutate(data);
|
||||
}
|
||||
|
||||
if (changes.length) startQueue(changes);
|
||||
};
|
||||
|
||||
const errorMessage = (err: unknown): string => {
|
||||
const res = (
|
||||
err as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data?.message;
|
||||
if (Array.isArray(res)) return res[0];
|
||||
return res ?? "Something went wrong. Please try again.";
|
||||
};
|
||||
|
||||
const busy =
|
||||
otpMutation.isPending ||
|
||||
contactMutation.isPending ||
|
||||
nameMutation.isPending;
|
||||
|
||||
const savedSummary =
|
||||
completed.length && !queue.length
|
||||
? `Your ${completed.map((c) => CHANNEL_LABEL[c]).join(" and ")} ${
|
||||
completed.length > 1 ? "were" : "was"
|
||||
} verified and updated`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<UserCog size={20} />
|
||||
<Title order={3}>Account</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Your login details. Verification codes and SMS notifications are sent
|
||||
to the phone number below.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="phoneNumber"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="you@company.com"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bekele"
|
||||
error={errors.nameEn?.message}
|
||||
{...register("nameEn")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Full Name (Amharic)"
|
||||
placeholder="አበበ በቀለ"
|
||||
error={errors.nameAm?.message}
|
||||
{...register("nameAm")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Text c="edr-muted" size="xs" mt="sm">
|
||||
Changing your phone number or email requires a verification code
|
||||
sent to the new one.
|
||||
</Text>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{savedSummary && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{savedSummary}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{nameMutation.isSuccess && !savedSummary && !queue.length && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Saved successfully
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{(otpMutation.isError || nameMutation.isError) && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{errorMessage(otpMutation.error ?? nameMutation.error)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={busy}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<Modal
|
||||
opened={current !== null}
|
||||
onClose={cancelQueue}
|
||||
title="Verify your new contact details"
|
||||
centered
|
||||
>
|
||||
{current && (
|
||||
<Stack gap="md">
|
||||
{totalSteps > 1 && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
Step {step} of {totalSteps}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Alert icon={<ShieldCheck size={16} />} color="blue">
|
||||
{sentTo ? (
|
||||
<>
|
||||
We sent a 6-digit code to <b>{sentTo}</b>. Enter it to
|
||||
confirm your new {CHANNEL_LABEL[current.channel]}.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sending a code to your new {CHANNEL_LABEL[current.channel]}…
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{completed.length > 0 && queue.length > 0 && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm">
|
||||
{CHANNEL_LABEL[completed[completed.length - 1]]} updated —
|
||||
one more to confirm.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
disabled={!sentTo || otpMutation.isPending}
|
||||
aria-label="Verification code"
|
||||
/>
|
||||
|
||||
{contactMutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm">{errorMessage(contactMutation.error)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={otpMutation.isPending || !sentTo}
|
||||
onClick={() => {
|
||||
// Clear any "invalid code" error first: the user is asking for
|
||||
// a fresh code, not retrying the old one, so leaving the
|
||||
// failure on screen would describe a request they didn't make.
|
||||
contactMutation.reset();
|
||||
otpMutation.mutate(current);
|
||||
}}
|
||||
>
|
||||
Resend code
|
||||
</Button>
|
||||
<Button
|
||||
loading={contactMutation.isPending}
|
||||
disabled={otp.length !== 6 || !sentTo}
|
||||
onClick={() => contactMutation.mutate({ ...current, otp })}
|
||||
>
|
||||
Verify & Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
|
||||
<ChangePasswordCard />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import type {
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type {
|
||||
AuthUser,
|
||||
ChangePasswordPayload,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
@@ -81,6 +82,11 @@ import type {
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
ResetTicket,
|
||||
SendContactOtpPayload,
|
||||
SendContactOtpResponse,
|
||||
UpdateAccountNamePayload,
|
||||
UpdateContactPayload,
|
||||
UpdateContactResponse,
|
||||
} from "@/types/auth";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -144,9 +150,34 @@ export const api = {
|
||||
"verifyOTP",
|
||||
authService.verifyOTP,
|
||||
),
|
||||
changePassword: endpoint<ChangePasswordPayload, void>(
|
||||
"auth",
|
||||
"changePassword",
|
||||
authService.changePassword,
|
||||
),
|
||||
logout: endpoint<void, void>("auth", "logout", authService.logout),
|
||||
},
|
||||
|
||||
// The signed-in user's own IAM account — the phone/email that OTPs and SMS
|
||||
// actually go to. Separate from `companies`, which is business profile data.
|
||||
account: {
|
||||
sendContactOtp: endpoint<SendContactOtpPayload, SendContactOtpResponse>(
|
||||
"account",
|
||||
"sendContactOtp",
|
||||
authService.sendContactOtp,
|
||||
),
|
||||
updateContact: endpoint<UpdateContactPayload, UpdateContactResponse>(
|
||||
"account",
|
||||
"updateContact",
|
||||
authService.updateContact,
|
||||
),
|
||||
updateName: endpoint<UpdateAccountNamePayload, { success: true }>(
|
||||
"account",
|
||||
"updateName",
|
||||
authService.updateAccountName,
|
||||
),
|
||||
},
|
||||
|
||||
companies: {
|
||||
getInfo: endpoint<void, CompanyInfoResponse | null>(
|
||||
"companies",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
AuthUser,
|
||||
ChangePasswordPayload,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
ForgotPasswordRequestPayload,
|
||||
@@ -11,11 +12,17 @@ import type {
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
ResetTicket,
|
||||
SendContactOtpPayload,
|
||||
SendContactOtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
UpdateAccountNamePayload,
|
||||
UpdateContactPayload,
|
||||
UpdateContactResponse,
|
||||
} from "@/types/auth";
|
||||
import { client } from "@/utils/api";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { ApiResponse } from "@edr/types";
|
||||
|
||||
export const authService = {
|
||||
@@ -105,6 +112,44 @@ export const authService = {
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Change the signed-in user's password via IAM's own route: it verifies the
|
||||
* old password with argon and owns the credential write (deactivating the
|
||||
* previous one), so this app never touches password material.
|
||||
*/
|
||||
changePassword: async (body: ChangePasswordPayload) => {
|
||||
await client.patch(URL_CONSTANTS.AUTH.CHANGE_PASSWORD, body);
|
||||
},
|
||||
|
||||
// The three calls below manage the signed-in user's own account record
|
||||
// (`/api/me`), which is what OTPs and SMS notifications are delivered to.
|
||||
// Changing phone/email is OTP-gated server-side: the code goes to the NEW
|
||||
// value, and the write only lands once it is verified.
|
||||
|
||||
sendContactOtp: async (body: SendContactOtpPayload) => {
|
||||
const res = await client.post<ApiResponse<SendContactOtpResponse>>(
|
||||
URL_CONSTANTS.ACCOUNT.CONTACT_OTP,
|
||||
body,
|
||||
);
|
||||
return unwrap(res.data);
|
||||
},
|
||||
|
||||
updateContact: async (body: UpdateContactPayload) => {
|
||||
const res = await client.patch<ApiResponse<UpdateContactResponse>>(
|
||||
URL_CONSTANTS.ACCOUNT.CONTACT,
|
||||
body,
|
||||
);
|
||||
return unwrap(res.data);
|
||||
},
|
||||
|
||||
updateAccountName: async (body: UpdateAccountNamePayload) => {
|
||||
const res = await client.patch<ApiResponse<{ success: true }>>(
|
||||
URL_CONSTANTS.ACCOUNT.NAME,
|
||||
body,
|
||||
);
|
||||
return unwrap(res.data);
|
||||
},
|
||||
|
||||
refreshToken: async () => {
|
||||
const refreshTokenCookie = document.cookie
|
||||
.split("; ")
|
||||
|
||||
@@ -128,8 +128,6 @@ export interface SignContractPayload {
|
||||
consentText?: string;
|
||||
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
|
||||
otp?: string;
|
||||
/** Phone the OTP was sent to; required when role=CUSTOMER. */
|
||||
otpPhone?: string;
|
||||
}
|
||||
|
||||
export interface ApproveDeliveryResponse {
|
||||
|
||||
@@ -268,9 +268,10 @@ export const contractsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
// Ask the server to send the signing OTP to the CONTRACT COMPANY's registered
|
||||
// phone. The client never picks the number (the server verifies against the
|
||||
// same one), so send and verify can't disagree. Returns a masked hint.
|
||||
// Ask the server to send the signing OTP to the signer's own registered phone.
|
||||
// The client never picks the number (the server resolves it from the
|
||||
// authenticated user and verifies against the same one), so send and verify
|
||||
// can't disagree. Returns a masked hint.
|
||||
sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => {
|
||||
const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id));
|
||||
return data.data ?? data;
|
||||
|
||||
@@ -45,6 +45,45 @@ export interface OtpResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the signed-in user's password. The old password is the proof of
|
||||
* possession — IAM verifies it server-side and owns the credential write, so the
|
||||
* freight app never hashes or stores a password itself.
|
||||
*/
|
||||
export interface ChangePasswordPayload {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
/** The contact channel being changed on the signed-in user's own account. */
|
||||
export type ContactChannel = "email" | "phone";
|
||||
|
||||
export interface SendContactOtpPayload {
|
||||
channel: ContactChannel;
|
||||
/** The NEW value being moved to — the code is sent here, not to the old one. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SendContactOtpResponse {
|
||||
/** Masked hint of where the code landed, e.g. `+251•••••4567`. */
|
||||
sentTo: string;
|
||||
}
|
||||
|
||||
export interface UpdateContactPayload extends SendContactOtpPayload {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export interface UpdateContactResponse {
|
||||
success: true;
|
||||
/** The canonical stored value (E.164 for phone, lowercased for email). */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UpdateAccountNamePayload {
|
||||
name: { am: string; en?: string };
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityPayload {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
|
||||
Reference in New Issue
Block a user