mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
feat(bookings): enhance contract viewer and backoffice UI consistency
This commit is contained in:
@@ -1,12 +1,10 @@
|
|||||||
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
|
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
|
||||||
|
|
||||||
export const bookingGlass = {
|
export const bookingGlass = {
|
||||||
card:
|
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
||||||
"border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
|
||||||
panel:
|
panel:
|
||||||
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
|
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
|
||||||
rail:
|
rail: "border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
|
||||||
"border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
|
|
||||||
iconWell:
|
iconWell:
|
||||||
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
|
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
|
||||||
iconWellHero:
|
iconWellHero:
|
||||||
@@ -22,9 +20,8 @@ export const bookingGlass = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const bookingSurface = {
|
export const bookingSurface = {
|
||||||
page:
|
page: "min-h-screen bg-background ",
|
||||||
"min-h-screen bg-gradient-to-b from-muted/30 via-background to-background",
|
pageInner: "mx-auto max-w-[1600px] space-y-5 ",
|
||||||
pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
|
|
||||||
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
|
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
|
||||||
heroGlow:
|
heroGlow:
|
||||||
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",
|
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ const FreightDashboardHeader = ({
|
|||||||
if (!isUserMenuOpen) return;
|
if (!isUserMenuOpen) return;
|
||||||
|
|
||||||
const handlePointerDown = (event: MouseEvent) => {
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
|
if (
|
||||||
|
userMenuRef.current &&
|
||||||
|
!userMenuRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
setIsUserMenuOpen(false);
|
setIsUserMenuOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -74,10 +77,14 @@ const FreightDashboardHeader = ({
|
|||||||
}, [isUserMenuOpen]);
|
}, [isUserMenuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-[82px] shrink-0 items-center justify-between gap-4 px-6">
|
<header className="flex h-20 shrink-0 items-center justify-between gap-4 px-6">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="truncate text-xl font-bold tracking-tight text-gray-900">{pageMeta.title}</h1>
|
<h1 className="truncate text-xl font-bold tracking-tight text-foreground">
|
||||||
<p className="mt-0.5 truncate text-sm text-gray-500">{pageMeta.subtitle}</p>
|
{pageMeta.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-0.5 truncate text-sm text-secondary-foreground">
|
||||||
|
{pageMeta.subtitle}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
@@ -85,14 +92,24 @@ const FreightDashboardHeader = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
aria-label={
|
||||||
|
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||||
|
}
|
||||||
className={iconButtonClass}
|
className={iconButtonClass}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
{theme === "dark" ? (
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button type="button" aria-label="Change language" className={iconButtonClass}>
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Change language"
|
||||||
|
className={iconButtonClass}
|
||||||
|
>
|
||||||
<Languages className="h-5 w-5" />
|
<Languages className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -101,7 +118,11 @@ const FreightDashboardHeader = ({
|
|||||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" aria-label="Notifications" className={iconButtonClass}>
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Notifications"
|
||||||
|
className={iconButtonClass}
|
||||||
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -136,8 +157,12 @@ const FreightDashboardHeader = ({
|
|||||||
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg"
|
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg"
|
||||||
>
|
>
|
||||||
<div className="border-b border-gray-100 px-4 py-3">
|
<div className="border-b border-gray-100 px-4 py-3">
|
||||||
<p className="text-sm font-semibold text-gray-900">{userName}</p>
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
{userEmail ? <p className="text-xs text-gray-500">{userEmail}</p> : null}
|
{userName}
|
||||||
|
</p>
|
||||||
|
{userEmail ? (
|
||||||
|
<p className="text-xs text-gray-500">{userEmail}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="#profile"
|
href="#profile"
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ function getInitialTheme(): Theme {
|
|||||||
if (typeof window === "undefined") return "light";
|
if (typeof window === "undefined") return "light";
|
||||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
if (stored === "dark" || stored === "light") return stored;
|
if (stored === "dark" || stored === "light") return stored;
|
||||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreightDashboardLayoutProps {
|
export interface FreightDashboardLayoutProps {
|
||||||
@@ -29,7 +31,7 @@ export interface FreightDashboardLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const panelClass =
|
const panelClass =
|
||||||
"rounded-2xl border border-gray-200/80 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.06)]";
|
"rounded-lg border border-gray-200/80 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.06)]";
|
||||||
|
|
||||||
const FreightDashboardLayout = ({
|
const FreightDashboardLayout = ({
|
||||||
sidebarSections,
|
sidebarSections,
|
||||||
@@ -59,7 +61,8 @@ const FreightDashboardLayout = ({
|
|||||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
}, [theme, enableThemeToggle]);
|
}, [theme, enableThemeToggle]);
|
||||||
|
|
||||||
const toggleTheme = () => setTheme((current) => (current === "dark" ? "light" : "dark"));
|
const toggleTheme = () =>
|
||||||
|
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,17 +74,17 @@ const FreightDashboardLayout = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex h-[100dvh] overflow-hidden bg-[#eceef2] p-3 antialiased md:p-4"
|
className="flex h-[100dvh] overflow-hidden bg-[#eceef2] p-2 antialiased"
|
||||||
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||||
>
|
>
|
||||||
<div className="flex h-full min-h-0 w-full gap-3 md:gap-3">
|
<div className="flex h-full min-h-0 w-full gap-2">
|
||||||
<FreightSidebar
|
<FreightSidebar
|
||||||
sections={sidebarSections}
|
sections={sidebarSections}
|
||||||
activeHref={activeHref}
|
activeHref={activeHref}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col gap-3 md:gap-3">
|
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col gap-2">
|
||||||
<div className={`shrink-0 ${panelClass}`}>
|
<div className={`shrink-0 ${panelClass}`}>
|
||||||
<FreightDashboardHeader
|
<FreightDashboardHeader
|
||||||
pageMeta={pageMeta}
|
pageMeta={pageMeta}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
|
type MouseEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -20,21 +26,28 @@ const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
|
|||||||
items.flatMap((item) => {
|
items.flatMap((item) => {
|
||||||
const hrefs: string[] = [];
|
const hrefs: string[] = [];
|
||||||
if (item.href) hrefs.push(item.href.toLowerCase());
|
if (item.href) hrefs.push(item.href.toLowerCase());
|
||||||
if (item.children?.length) hrefs.push(...collectSidebarHrefs(item.children));
|
if (item.children?.length)
|
||||||
|
hrefs.push(...collectSidebarHrefs(item.children));
|
||||||
return hrefs;
|
return hrefs;
|
||||||
});
|
});
|
||||||
|
|
||||||
const flattenSectionItems = (sections: SidebarSection[]) =>
|
const flattenSectionItems = (sections: SidebarSection[]) =>
|
||||||
sections.flatMap((section) => section.items);
|
sections.flatMap((section) => section.items);
|
||||||
|
|
||||||
const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProps) => {
|
const FreightSidebar = ({
|
||||||
|
sections,
|
||||||
|
activeHref,
|
||||||
|
onNavigate,
|
||||||
|
}: FreightSidebarProps) => {
|
||||||
const items = useMemo(() => flattenSectionItems(sections), [sections]);
|
const items = useMemo(() => flattenSectionItems(sections), [sections]);
|
||||||
const activePath = activeHref?.toLowerCase() ?? "";
|
const activePath = activeHref?.toLowerCase() ?? "";
|
||||||
|
|
||||||
const isHrefActive = useCallback(
|
const isHrefActive = useCallback(
|
||||||
(href: string) => {
|
(href: string) => {
|
||||||
const normalized = href.toLowerCase();
|
const normalized = href.toLowerCase();
|
||||||
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
return (
|
||||||
|
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[activePath],
|
[activePath],
|
||||||
);
|
);
|
||||||
@@ -72,7 +85,8 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
|||||||
return acc;
|
return acc;
|
||||||
}, [activePath, branchContainsActive, isHrefActive, items]);
|
}, [activePath, branchContainsActive, isHrefActive, items]);
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded);
|
const [expanded, setExpanded] =
|
||||||
|
useState<Record<string, boolean>>(defaultExpanded);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
||||||
@@ -108,7 +122,11 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
|||||||
: "text-gray-900",
|
: "text-gray-900",
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderNavBranch = (children: SidebarItem[], depth: number, parentKey: string) =>
|
const renderNavBranch = (
|
||||||
|
children: SidebarItem[],
|
||||||
|
depth: number,
|
||||||
|
parentKey: string,
|
||||||
|
) =>
|
||||||
children.map((child) => {
|
children.map((child) => {
|
||||||
const key = sidebarItemKey(child, parentKey);
|
const key = sidebarItemKey(child, parentKey);
|
||||||
const isGroup = Boolean(child.children?.length) && !child.href;
|
const isGroup = Boolean(child.children?.length) && !child.href;
|
||||||
@@ -181,7 +199,9 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
|||||||
|
|
||||||
const hasChildren = Boolean(item.children?.length);
|
const hasChildren = Boolean(item.children?.length);
|
||||||
const itemHref = item.href.toLowerCase();
|
const itemHref = item.href.toLowerCase();
|
||||||
const childActive = hasChildren ? branchContainsActive(item.children!) : false;
|
const childActive = hasChildren
|
||||||
|
? branchContainsActive(item.children!)
|
||||||
|
: false;
|
||||||
const isCurrentItem = hasChildren
|
const isCurrentItem = hasChildren
|
||||||
? activePath === itemHref
|
? activePath === itemHref
|
||||||
: isHrefActive(itemHref);
|
: isHrefActive(itemHref);
|
||||||
@@ -206,10 +226,12 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={(event) => navigateTo(event, item.href!)}
|
onClick={(event) => navigateTo(event, item.href!)}
|
||||||
aria-current={isCurrentItem ? "page" : undefined}
|
aria-current={isCurrentItem ? "page" : undefined}
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-base font-medium leading-snug"
|
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm leading-snug"
|
||||||
>
|
>
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<span className={iconClass(leafActive, isActive)}>{item.icon}</span>
|
<span className={iconClass(leafActive, isActive)}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -257,10 +279,12 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full max-h-full w-[340px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
<aside className="flex h-full max-h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-100 px-5 py-5">
|
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-100 px-5 py-5">
|
||||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto" />
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto" />
|
||||||
<span className="text-lg font-semibold tracking-tight text-gray-900">EDR Freight</span>
|
<span className="text-lg font-semibold tracking-tight text-gray-900">
|
||||||
|
EDR Freight
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-3 py-4">
|
<nav className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-3 py-4">
|
||||||
@@ -270,7 +294,7 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
|||||||
className={cn(
|
className={cn(
|
||||||
"px-3 pb-1 text-xs font-semibold uppercase tracking-wide",
|
"px-3 pb-1 text-xs font-semibold uppercase tracking-wide",
|
||||||
// Use a very light gray for ALL section titles, not just when mutedTitle is specified
|
// Use a very light gray for ALL section titles, not just when mutedTitle is specified
|
||||||
"text-gray-400"
|
"text-sidebar-secondary-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{section.title}
|
{section.title}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ export default function BookingContractPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
@@ -58,6 +59,12 @@ export default function BookingContractPage() {
|
|||||||
}
|
}
|
||||||
}, [id, data?.reference]);
|
}, [id, data?.reference]);
|
||||||
|
|
||||||
|
const handlePrint = useCallback(() => {
|
||||||
|
if (iframeRef.current?.contentWindow) {
|
||||||
|
iframeRef.current.contentWindow.print();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[40vh] items-center justify-center">
|
<div className="flex min-h-[40vh] items-center justify-center">
|
||||||
@@ -77,8 +84,6 @@ export default function BookingContractPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyHtml = extractBodyHtml(data.html);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
|
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
@@ -88,7 +93,7 @@ export default function BookingContractPage() {
|
|||||||
Back to booking
|
Back to booking
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => window.print()}>
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
<Printer className="mr-2 size-4" />
|
<Printer className="mr-2 size-4" />
|
||||||
Print
|
Print
|
||||||
</Button>
|
</Button>
|
||||||
@@ -105,9 +110,12 @@ export default function BookingContractPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article
|
<iframe
|
||||||
className="contract-document rounded-lg border bg-white p-6 shadow-sm print:shadow-none md:p-10"
|
ref={iframeRef}
|
||||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
srcDoc={data.html}
|
||||||
|
className="w-full rounded-lg border bg-white shadow-sm"
|
||||||
|
style={{ minHeight: "80vh" }}
|
||||||
|
title="Contract document"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,8 +166,3 @@ export default function BookingContractPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractBodyHtml(fullHtml: string): string {
|
|
||||||
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
||||||
return match ? match[1] : fullHtml;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -844,28 +844,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{(booking.status === "CONTRACT_READY" ||
|
{renderContractCard(booking, navigate)}
|
||||||
booking.status === "SIGNED_CUSTOMER" ||
|
|
||||||
booking.status === "FULLY_EXECUTED") && (
|
|
||||||
<Card className="border-primary/30 bg-primary/5">
|
|
||||||
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-foreground">Contract ready</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Review the agreement and apply your digital signature.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
|
||||||
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
|
||||||
>
|
|
||||||
<FileSignature className="size-4" />
|
|
||||||
View & sign contract
|
|
||||||
</button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -1218,6 +1197,78 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderContractCard(
|
||||||
|
booking: Freight.IBooking,
|
||||||
|
navigate: ReturnType<typeof useNavigate>,
|
||||||
|
) {
|
||||||
|
const s = booking.status;
|
||||||
|
if (
|
||||||
|
s !== "APPROVED_PENDING_SIGNATURE" &&
|
||||||
|
s !== "CONTRACT_READY" &&
|
||||||
|
s !== "SIGNED_CUSTOMER" &&
|
||||||
|
s !== "FULLY_EXECUTED"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Record<
|
||||||
|
string,
|
||||||
|
{ title: string; description: string; buttonLabel?: string }
|
||||||
|
> = {
|
||||||
|
APPROVED_PENDING_SIGNATURE: {
|
||||||
|
title: "Contract being prepared",
|
||||||
|
description:
|
||||||
|
"Your booking has been approved. The contract is being generated and will be available shortly.",
|
||||||
|
},
|
||||||
|
CONTRACT_READY: {
|
||||||
|
title: "Contract ready for signature",
|
||||||
|
description:
|
||||||
|
"Review the agreement and apply your digital signature.",
|
||||||
|
buttonLabel: "View & sign contract",
|
||||||
|
},
|
||||||
|
SIGNED_CUSTOMER: {
|
||||||
|
title: "You have signed the contract",
|
||||||
|
description:
|
||||||
|
"Your signature has been submitted. Awaiting staff signature to finalize.",
|
||||||
|
buttonLabel: "View contract",
|
||||||
|
},
|
||||||
|
FULLY_EXECUTED: {
|
||||||
|
title: "Contract fully executed",
|
||||||
|
description:
|
||||||
|
"The contract has been fully signed and executed by all parties.",
|
||||||
|
buttonLabel: "View contract",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = config[s];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"border-primary/30 bg-primary/5",
|
||||||
|
s === "FULLY_EXECUTED" && "border-emerald-300 bg-emerald-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">{c.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{c.description}</p>
|
||||||
|
</div>
|
||||||
|
{c.buttonLabel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||||
|
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
||||||
|
>
|
||||||
|
<FileSignature className="size-4" />
|
||||||
|
{c.buttonLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RouteEndpoint({
|
function RouteEndpoint({
|
||||||
label,
|
label,
|
||||||
station,
|
station,
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
|
||||||
UseQueryOptions,
|
|
||||||
QueryObserverOptions,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
|
||||||
@@ -23,11 +20,15 @@ function setCookie(name: string, value: string, days: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearAuthCookies() {
|
function clearAuthCookies() {
|
||||||
["auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id"].forEach(
|
[
|
||||||
(name) => {
|
"auth-token",
|
||||||
document.cookie = `${name}=; Max-Age=0; path=/`;
|
"refresh-token",
|
||||||
},
|
"auth-user",
|
||||||
);
|
"current-position-id",
|
||||||
|
"selected-position-id",
|
||||||
|
].forEach((name) => {
|
||||||
|
document.cookie = `${name}=; Max-Age=0; path=/`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach auth token to every request
|
// Attach auth token to every request
|
||||||
@@ -96,17 +97,13 @@ client.interceptors.response.use(
|
|||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
clearAuthCookies();
|
clearAuthCookies();
|
||||||
if (window.location.pathname !== "/login") {
|
|
||||||
window.location.href = "/login";
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await client.post<{ data: { token: string; refreshToken: string } }>(
|
const { data } = await client.post<{
|
||||||
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
|
data: { token: string; refreshToken: string };
|
||||||
{ refreshToken },
|
}>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken });
|
||||||
);
|
|
||||||
const { token, refreshToken: newRefreshToken } = data.data;
|
const { token, refreshToken: newRefreshToken } = data.data;
|
||||||
setCookie("auth-token", token, 7);
|
setCookie("auth-token", token, 7);
|
||||||
setCookie("refresh-token", newRefreshToken, 7);
|
setCookie("refresh-token", newRefreshToken, 7);
|
||||||
|
|||||||
Reference in New Issue
Block a user