feat(bookings): enhance contract viewer and backoffice UI consistency

This commit is contained in:
ghost2023
2026-06-06 09:11:13 +03:00
parent 7a19a161e1
commit da0f1e4cc9
7 changed files with 185 additions and 85 deletions

View File

@@ -1,12 +1,10 @@
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
export const bookingGlass = {
card:
"border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
panel:
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
rail:
"border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
rail: "border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
iconWell:
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
iconWellHero:
@@ -22,9 +20,8 @@ export const bookingGlass = {
} as const;
export const bookingSurface = {
page:
"min-h-screen bg-gradient-to-b from-muted/30 via-background to-background",
pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
page: "min-h-screen bg-background ",
pageInner: "mx-auto max-w-[1600px] space-y-5 ",
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
heroGlow:
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",

View File

@@ -56,7 +56,10 @@ const FreightDashboardHeader = ({
if (!isUserMenuOpen) return;
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);
}
};
@@ -74,10 +77,14 @@ const FreightDashboardHeader = ({
}, [isUserMenuOpen]);
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">
<h1 className="truncate text-xl font-bold tracking-tight text-gray-900">{pageMeta.title}</h1>
<p className="mt-0.5 truncate text-sm text-gray-500">{pageMeta.subtitle}</p>
<h1 className="truncate text-xl font-bold tracking-tight text-foreground">
{pageMeta.title}
</h1>
<p className="mt-0.5 truncate text-sm text-secondary-foreground">
{pageMeta.subtitle}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
@@ -85,14 +92,24 @@ const FreightDashboardHeader = ({
<button
type="button"
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}
>
{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>
) : 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" />
</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" />
</button>
<button type="button" aria-label="Notifications" className={iconButtonClass}>
<button
type="button"
aria-label="Notifications"
className={iconButtonClass}
>
<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" />
</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"
>
<div className="border-b border-gray-100 px-4 py-3">
<p className="text-sm font-semibold text-gray-900">{userName}</p>
{userEmail ? <p className="text-xs text-gray-500">{userEmail}</p> : null}
<p className="text-sm font-semibold text-gray-900">
{userName}
</p>
{userEmail ? (
<p className="text-xs text-gray-500">{userEmail}</p>
) : null}
</div>
<a
href="#profile"

View File

@@ -12,7 +12,9 @@ function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
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 {
@@ -29,7 +31,7 @@ export interface FreightDashboardLayoutProps {
}
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 = ({
sidebarSections,
@@ -59,7 +61,8 @@ const FreightDashboardLayout = ({
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}, [theme, enableThemeToggle]);
const toggleTheme = () => setTheme((current) => (current === "dark" ? "light" : "dark"));
const toggleTheme = () =>
setTheme((current) => (current === "dark" ? "light" : "dark"));
return (
<>
@@ -71,17 +74,17 @@ const FreightDashboardLayout = ({
/>
<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)" }}
>
<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
sections={sidebarSections}
activeHref={activeHref}
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}`}>
<FreightDashboardHeader
pageMeta={pageMeta}

View File

@@ -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 { cn } from "@/lib/utils";
@@ -20,21 +26,28 @@ const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
items.flatMap((item) => {
const hrefs: string[] = [];
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;
});
const flattenSectionItems = (sections: SidebarSection[]) =>
sections.flatMap((section) => section.items);
const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProps) => {
const FreightSidebar = ({
sections,
activeHref,
onNavigate,
}: FreightSidebarProps) => {
const items = useMemo(() => flattenSectionItems(sections), [sections]);
const activePath = activeHref?.toLowerCase() ?? "";
const isHrefActive = useCallback(
(href: string) => {
const normalized = href.toLowerCase();
return activePath === normalized || activePath.startsWith(`${normalized}/`);
return (
activePath === normalized || activePath.startsWith(`${normalized}/`)
);
},
[activePath],
);
@@ -72,7 +85,8 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
return acc;
}, [activePath, branchContainsActive, isHrefActive, items]);
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded);
const [expanded, setExpanded] =
useState<Record<string, boolean>>(defaultExpanded);
useEffect(() => {
setExpanded((current) => ({ ...defaultExpanded, ...current }));
@@ -108,7 +122,11 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
: "text-gray-900",
);
const renderNavBranch = (children: SidebarItem[], depth: number, parentKey: string) =>
const renderNavBranch = (
children: SidebarItem[],
depth: number,
parentKey: string,
) =>
children.map((child) => {
const key = sidebarItemKey(child, parentKey);
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 itemHref = item.href.toLowerCase();
const childActive = hasChildren ? branchContainsActive(item.children!) : false;
const childActive = hasChildren
? branchContainsActive(item.children!)
: false;
const isCurrentItem = hasChildren
? activePath === itemHref
: isHrefActive(itemHref);
@@ -206,10 +226,12 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
href={item.href}
onClick={(event) => navigateTo(event, item.href!)}
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 ? (
<span className={iconClass(leafActive, isActive)}>{item.icon}</span>
<span className={iconClass(leafActive, isActive)}>
{item.icon}
</span>
) : null}
<span className="truncate">{item.label}</span>
</a>
@@ -257,10 +279,12 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
};
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">
<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>
<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(
"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
"text-gray-400"
"text-sidebar-secondary-foreground",
)}
>
{section.title}

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -21,6 +21,7 @@ export default function BookingContractPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
@@ -58,6 +59,12 @@ export default function BookingContractPage() {
}
}, [id, data?.reference]);
const handlePrint = useCallback(() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.print();
}
}, []);
if (isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
@@ -77,8 +84,6 @@ export default function BookingContractPage() {
);
}
const bodyHtml = extractBodyHtml(data.html);
return (
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
<div className="mx-auto max-w-4xl">
@@ -88,7 +93,7 @@ export default function BookingContractPage() {
Back to booking
</Button>
<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" />
Print
</Button>
@@ -105,9 +110,12 @@ export default function BookingContractPage() {
</div>
</div>
<article
className="contract-document rounded-lg border bg-white p-6 shadow-sm print:shadow-none md:p-10"
dangerouslySetInnerHTML={{ __html: bodyHtml }}
<iframe
ref={iframeRef}
srcDoc={data.html}
className="w-full rounded-lg border bg-white shadow-sm"
style={{ minHeight: "80vh" }}
title="Contract document"
/>
</div>
@@ -158,8 +166,3 @@ export default function BookingContractPage() {
</div>
);
}
function extractBodyHtml(fullHtml: string): string {
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
return match ? match[1] : fullHtml;
}

View File

@@ -844,28 +844,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
</CardHeader>
</Card>
{(booking.status === "CONTRACT_READY" ||
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 &amp; sign contract
</button>
</CardContent>
</Card>
)}
{renderContractCard(booking, navigate)}
<Card>
<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({
label,
station,

View File

@@ -1,7 +1,4 @@
import {
UseQueryOptions,
QueryObserverOptions,
} from "@tanstack/react-query";
import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -23,11 +20,15 @@ function setCookie(name: string, value: string, days: number) {
}
function clearAuthCookies() {
["auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id"].forEach(
(name) => {
document.cookie = `${name}=; Max-Age=0; path=/`;
},
);
[
"auth-token",
"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
@@ -96,17 +97,13 @@ client.interceptors.response.use(
if (!refreshToken) {
isRefreshing = false;
clearAuthCookies();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
return Promise.reject(error);
}
try {
const { data } = await client.post<{ data: { token: string; refreshToken: string } }>(
URL_CONSTANTS.AUTH.REFRESH_TOKEN,
{ refreshToken },
);
const { data } = await client.post<{
data: { token: string; refreshToken: string };
}>(URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken });
const { token, refreshToken: newRefreshToken } = data.data;
setCookie("auth-token", token, 7);
setCookie("refresh-token", newRefreshToken, 7);