mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 09:28:19 +00:00
fix issue, add transit flow, fix cancellation
This commit is contained in:
@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Home,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
Loader2,
|
||||
// MapPin,
|
||||
@@ -68,6 +69,10 @@ import {
|
||||
ShippingLineInvoicesPage,
|
||||
ShippingLineSettingsPage,
|
||||
} from "./pages/shipping-line";
|
||||
import {
|
||||
TransitAgentBookingsPage,
|
||||
TransitAgentOverviewPage,
|
||||
} from "./pages/transit-agent";
|
||||
import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
@@ -143,15 +148,15 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
* captured, so there is nothing for them to onboard — they go straight to home.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted, isShippingLine } = useAuth();
|
||||
const { company, onboardingCompleted, isShippingLine, isTransitAgent } =
|
||||
useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Keyed off a positive shipping-line identification, never off "no company":
|
||||
// that is also true mid-fetch and on error, which would let customers slip
|
||||
// past onboarding whenever the request failed.
|
||||
const needsOnboarding = isShippingLine
|
||||
? false
|
||||
: !company || !onboardingCompleted;
|
||||
// Keyed off a positive shipping-line / transit-agent identification, never
|
||||
// off "no company": that is also true mid-fetch and on error, which would let
|
||||
// customers slip past onboarding whenever the request failed.
|
||||
const needsOnboarding =
|
||||
isShippingLine || isTransitAgent ? false : !company || !onboardingCompleted;
|
||||
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
||||
|
||||
// Open by default while onboarding is pending (covers the login case).
|
||||
@@ -199,13 +204,14 @@ function OnboardingGate() {
|
||||
* contract/company-shaped page that has no meaning for it.
|
||||
*/
|
||||
function RequireCustomer() {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
|
||||
|
||||
// RequireCompany already awaits this query, but guard anyway: a refetch can
|
||||
// flip `isPending` back on, and redirecting on a half-loaded account would
|
||||
// throw the user into the wrong app.
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
|
||||
if (isTransitAgent) return <Navigate to="/transit-agent" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -218,6 +224,15 @@ function RequireShippingLine() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** Transit-agent routes, closed to every other account kind. */
|
||||
function RequireTransitAgent() {
|
||||
const { isTransitAgent, customerQuery } = useAuth();
|
||||
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (!isTransitAgent) return <Navigate to="/portal" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a signed-in account belongs. Shipping lines and customers have separate
|
||||
* apps, so every "you're already logged in" redirect has to pick between them.
|
||||
@@ -225,11 +240,15 @@ function RequireShippingLine() {
|
||||
* still in flight, which would land a shipping line on the customer home first.
|
||||
*/
|
||||
function useHomeRoute(): { ready: boolean; href: string } {
|
||||
const { isShippingLine, customerQuery } = useAuth();
|
||||
const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
|
||||
|
||||
return {
|
||||
ready: !customerQuery.isPending,
|
||||
href: isShippingLine ? "/shipping-line" : "/portal",
|
||||
href: isShippingLine
|
||||
? "/shipping-line"
|
||||
: isTransitAgent
|
||||
? "/transit-agent"
|
||||
: "/portal",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,6 +345,24 @@ const shippingLineSidebarItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar for transit agents. Two entries only — the rest of the portal
|
||||
* (contracts, invoices, settings, support) is company-scoped and has no meaning
|
||||
* for an agent, so nothing is filtered in from the other lists.
|
||||
*/
|
||||
const transitAgentSidebarItems: SidebarItem[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
href: "/transit-agent",
|
||||
icon: <LayoutDashboard size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/transit-agent/bookings",
|
||||
icon: <Package size={18} />,
|
||||
},
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -337,6 +374,7 @@ const App = () => {
|
||||
reapplyProfile,
|
||||
isAuthenticated,
|
||||
isShippingLine,
|
||||
isTransitAgent,
|
||||
} = useAuth();
|
||||
|
||||
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||
@@ -478,6 +516,39 @@ const App = () => {
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{/* Transit-agent app. Two pages, both empty for now, behind their
|
||||
own layout — there is no support widget because the chat is
|
||||
company-scoped and an agent has no company, exactly as for a
|
||||
shipping line. */}
|
||||
{isTransitAgent && (
|
||||
<Route element={<RequireTransitAgent />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={transitAgentSidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
showSupportWidget={false}
|
||||
>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/transit-agent"
|
||||
element={<TransitAgentOverviewPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/transit-agent/bookings"
|
||||
element={<TransitAgentBookingsPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{/* Customer app — unchanged. */}
|
||||
<Route element={<RequireCustomer />}>
|
||||
<Route
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import {
|
||||
companiesService,
|
||||
isShippingLineAccount,
|
||||
isTransitAgentAccount,
|
||||
} from "@/services/companies.service";
|
||||
import type {
|
||||
LoginPayload,
|
||||
@@ -184,10 +185,21 @@ const useAuth = () => {
|
||||
const isShippingLine = isShippingLineAccount(accountInfo);
|
||||
const shippingLine = isShippingLine ? accountInfo : null;
|
||||
|
||||
// Every customer-shaped field below is null/empty for a shipping line.
|
||||
const companyInfo = isShippingLine
|
||||
? null
|
||||
: (accountInfo as CompanyInfoResponse | null);
|
||||
/**
|
||||
* Transit agents share the portal with customers and shipping lines but have
|
||||
* no company, no external profile and no onboarding. Identified positively
|
||||
* from the backend's discriminator, for the same reason as the shipping line
|
||||
* above — never from "company is missing".
|
||||
*/
|
||||
const isTransitAgent = isTransitAgentAccount(accountInfo);
|
||||
const transitAgent = isTransitAgent ? accountInfo : null;
|
||||
|
||||
// Every customer-shaped field below is null/empty for a shipping line and for
|
||||
// a transit agent alike.
|
||||
const companyInfo =
|
||||
isShippingLine || isTransitAgent
|
||||
? null
|
||||
: (accountInfo as CompanyInfoResponse | null);
|
||||
const companyType = companyInfo?.company?.type ?? null;
|
||||
const companyStatus = companyInfo?.company?.status ?? null;
|
||||
// A company can create bookings only once an admin has approved it (active).
|
||||
@@ -306,6 +318,8 @@ const useAuth = () => {
|
||||
onboardingStep,
|
||||
isShippingLine,
|
||||
shippingLine,
|
||||
isTransitAgent,
|
||||
transitAgent,
|
||||
createProfile,
|
||||
reapplyProfile,
|
||||
login,
|
||||
|
||||
@@ -235,9 +235,9 @@ function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
|
||||
LOCO
|
||||
</Text>
|
||||
</Box>
|
||||
{wagons.map((w) => (
|
||||
{wagons.map((w, i) => (
|
||||
<Tooltip
|
||||
key={w.sequenceNo}
|
||||
key={w.allocationId ?? w.sequenceNo}
|
||||
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
|
||||
STATUS_TONES[w.status]?.label ?? w.status
|
||||
}`}
|
||||
@@ -257,7 +257,7 @@ function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
|
||||
}}
|
||||
>
|
||||
<Text fz={10} fw={700} c="#6B7C8E">
|
||||
W{w.sequenceNo}
|
||||
W{i + 1}
|
||||
</Text>
|
||||
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
|
||||
{w.wagonNumber ?? "—"}
|
||||
@@ -302,12 +302,21 @@ const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||
|
||||
function WagonCard({
|
||||
wagon,
|
||||
displayNo,
|
||||
selectable,
|
||||
selected,
|
||||
shared,
|
||||
onToggle,
|
||||
}: {
|
||||
wagon: BookingWagonAllocation;
|
||||
/**
|
||||
* 1-based position in THIS booking's wagon list — what the customer sees.
|
||||
* Deliberately not `sequenceNo`, which is the wagon's slot in the shared
|
||||
* train set and so starts wherever the previous booking left off (and
|
||||
* leaves holes when wagons are cancelled). Cancellation keys off
|
||||
* `allocationId`, so this number is display-only.
|
||||
*/
|
||||
displayNo: number;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
/** Shared consolidation wagon — not selectable for cancellation. */
|
||||
@@ -333,7 +342,7 @@ function WagonCard({
|
||||
checked={!!selected}
|
||||
onChange={onToggle}
|
||||
color="orange"
|
||||
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`}
|
||||
aria-label={`Select wagon ${displayNo} for cancellation`}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
@@ -354,7 +363,7 @@ function WagonCard({
|
||||
WAGON
|
||||
</Text>
|
||||
<Text fz={15} fw={800} lh={1.2}>
|
||||
{wagon.sequenceNo}
|
||||
{displayNo}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -727,7 +736,7 @@ export function WagonsTab({
|
||||
<CancelledWagonsSection rows={ownCancellations} />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
|
||||
{wagons.map((w) => {
|
||||
{wagons.map((w, i) => {
|
||||
// The shared consolidation wagon carries this booking's lone 20ft —
|
||||
// its other half belongs to the partner booking, so it can never be
|
||||
// cancelled on its own (the server rejects it too).
|
||||
@@ -740,6 +749,7 @@ export function WagonsTab({
|
||||
<WagonCard
|
||||
key={w.allocationId ?? w.sequenceNo}
|
||||
wagon={w}
|
||||
displayNo={i + 1}
|
||||
shared={isSharedWagon}
|
||||
selectable={
|
||||
canSelect &&
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Package } from "lucide-react";
|
||||
|
||||
import ShippingLinePlaceholder from "@/pages/shipping-line/ShippingLinePlaceholder";
|
||||
|
||||
/** Empty by design for now — see {@link TransitAgentOverviewPage}. */
|
||||
export default function TransitAgentBookingsPage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Bookings"
|
||||
description="Shipments assigned to you for transit."
|
||||
icon={<Package size={28} opacity={0.4} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LayoutDashboard } from "lucide-react";
|
||||
|
||||
import ShippingLinePlaceholder from "@/pages/shipping-line/ShippingLinePlaceholder";
|
||||
|
||||
/**
|
||||
* Empty by design for now. Reuses the shipping-line placeholder rather than a
|
||||
* transit-agent copy of it: it is generic empty-state chrome, and duplicating it
|
||||
* would mean two files to delete once either page gains real content.
|
||||
*/
|
||||
export default function TransitAgentOverviewPage() {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Overview"
|
||||
description="Your transit activity at a glance."
|
||||
icon={<LayoutDashboard size={28} opacity={0.4} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as TransitAgentOverviewPage } from "./TransitAgentOverviewPage";
|
||||
export { default as TransitAgentBookingsPage } from "./TransitAgentBookingsPage";
|
||||
@@ -97,7 +97,7 @@ export interface CompanyProfileResponse {
|
||||
* "shipping line" would skip onboarding for customers whenever the request
|
||||
* failed. Absent (older responses) means `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
export type AccountKind = "customer" | "shipping_line" | "transit_agent";
|
||||
|
||||
export interface CompanyInfoResponse {
|
||||
accountKind?: AccountKind;
|
||||
@@ -132,13 +132,38 @@ export interface ShippingLineInfoResponse {
|
||||
review: null;
|
||||
}
|
||||
|
||||
/** `GET /companies/getInfo` serves both portal audiences. */
|
||||
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse;
|
||||
/**
|
||||
* A signed-in transit agent. Like a shipping line it has no company, no
|
||||
* external profile and no onboarding — the agent record itself is the account.
|
||||
*/
|
||||
export interface TransitAgentInfoResponse {
|
||||
accountKind: "transit_agent";
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
isActive: boolean;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
company: null;
|
||||
profile: null;
|
||||
review: null;
|
||||
}
|
||||
|
||||
/** `GET /companies/getInfo` serves every portal audience. */
|
||||
export type AccountInfoResponse =
|
||||
| CompanyInfoResponse
|
||||
| ShippingLineInfoResponse
|
||||
| TransitAgentInfoResponse;
|
||||
|
||||
export const isShippingLineAccount = (
|
||||
info: AccountInfoResponse | null | undefined,
|
||||
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
|
||||
|
||||
export const isTransitAgentAccount = (
|
||||
info: AccountInfoResponse | null | undefined,
|
||||
): info is TransitAgentInfoResponse => info?.accountKind === "transit_agent";
|
||||
|
||||
/** A staged profile-edit review request (portal view). */
|
||||
export interface ChangeRequestResponse {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user