Merge freight/develop into feature/trains-management

This commit is contained in:
hagiye
2026-06-05 10:21:39 +03:00
179 changed files with 12700 additions and 3160 deletions

View File

@@ -0,0 +1,116 @@
import { useMemo } from "react";
import { ShieldCheck } from "lucide-react";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
import { Badge } from "@edr/ui-common";
import { cn } from "@/lib/utils";
interface ApprovalStepsCardProps {
booking: BookingDetail;
}
/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */
export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
const steps = useMemo(
() =>
[...(booking.approvalSteps ?? [])].sort(
(a, b) => a.stepOrder - b.stepOrder,
),
[booking.approvalSteps],
);
const nextPending = getNextPendingApprovalStep(steps);
return (
<div className="overflow-hidden rounded-xl border border-amber-200/80 bg-gradient-to-b from-amber-50/40 to-card shadow-sm dark:from-amber-950/20">
<div className="flex items-center gap-3 border-b border-amber-200/50 bg-amber-50/50 px-5 py-4 dark:bg-amber-950/30">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/15 text-amber-800 dark:text-amber-300">
<ShieldCheck className="size-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Approval chain
</h2>
<p className="text-xs text-muted-foreground">
Next:{" "}
{nextPending
? `${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin"}
</p>
</div>
</div>
<div className="px-5 py-5">
{steps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-6 text-center text-sm text-muted-foreground">
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
in staff actions to instantiate steps.
</p>
) : (
<ul className="space-y-2">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
/>
))}
</ul>
)}
</div>
</div>
);
}
function StepRow({
step,
isNext,
}: {
step: BookingApprovalStep;
isNext: boolean;
}) {
const statusStyles =
step.status === "APPROVED"
? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
: step.status === "REJECTED"
? "bg-red-500/15 text-red-800 dark:text-red-300"
: isNext
? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
: "bg-muted text-muted-foreground";
return (
<li
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
isNext
? "border-primary/30 bg-primary/[0.03] shadow-sm"
: "border-border/60 bg-card",
)}
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-bold text-muted-foreground">
{step.stepOrder}
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
{step.requiredRole}
</p>
{step.remarks && (
<p className="truncate text-xs text-muted-foreground">
{step.remarks}
</p>
)}
</div>
</div>
<Badge
variant="outline"
className={cn("shrink-0 text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>
</li>
);
}

View File

@@ -0,0 +1,233 @@
import { useNavigate } from "react-router-dom";
import {
ChevronRight,
ExternalLink,
Loader2,
MoreHorizontal,
Upload,
} from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import {
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@edr/ui-common";
interface BookingActionsMenuProps {
row: BookingListRow;
/** Compact table cell vs. larger detail toolbar */
variant?: "table" | "toolbar";
className?: string;
}
export function BookingActionsMenu({
row,
variant = "table",
className,
}: BookingActionsMenuProps) {
const navigate = useNavigate();
const context: BookingActionContext = {
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: row.reference,
};
const flow = useBookingActionDialog(row.id, context);
const { actions, pendingAction, mutations } = flow;
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const showUsdPaymentHint =
row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
const primary = actions.find((a) => a.primary) ?? actions[0];
if (!hasMenu && variant === "table") {
return (
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-primary"
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
aria-label="View booking"
>
<ChevronRight className="size-4" />
</Button>
);
}
return (
<>
<div
className={cn(
"flex items-center justify-end gap-1",
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
className,
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{variant === "table" && primary && (
<Button
size="sm"
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
disabled={mutations.isPending}
onClick={() =>
primary.id === "viewContract"
? goToContract()
: flow.openAction(primary)
}
>
<primary.icon className="size-3.5" />
{primary.shortLabel}
</Button>
)}
{variant === "toolbar" && actions.length > 0 ? (
<div className="flex w-full flex-wrap gap-2">
{actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.id}
size="sm"
variant={
action.variant === "destructive"
? "outline"
: action.primary
? "default"
: "outline"
}
className={cn(
"gap-2 shadow-sm",
action.variant === "destructive" &&
"border-red-200 text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30",
)}
disabled={mutations.isPending}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
>
<Icon className="size-4" />
{action.label}
</Button>
);
})}
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant === "table" ? "ghost" : "outline"}
size={variant === "table" ? "icon" : "sm"}
className={cn(
variant === "table" ? "size-8" : "gap-2",
"shrink-0",
)}
disabled={mutations.isPending}
aria-label="Booking actions"
>
{mutations.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-mono text-xs text-muted-foreground">
{row.reference}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{actions.map((action) => {
const Icon = action.icon;
return (
<DropdownMenuItem
key={action.id}
className={cn(
"gap-2 cursor-pointer",
action.variant === "destructive" && "text-red-700 focus:text-red-700",
)}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
>
<Icon className="size-4 opacity-70" />
<span>{action.label}</span>
</DropdownMenuItem>
);
})}
{showUsdPaymentHint && (
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
>
<Upload className="size-4 opacity-70" />
Upload payment proof
</DropdownMenuItem>
)}
{(actions.length > 0 || showUsdPaymentHint) && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
>
<ExternalLink className="size-4 opacity-70" />
Open full details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<BookingConfirmDialog
open={flow.dialogOpen}
onOpenChange={flow.setDialogOpen}
action={pendingAction}
reference={flow.mergedContext.reference}
inputValue={flow.inputValue}
onInputChange={flow.setInputValue}
onConfirm={flow.runAction}
isPending={mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
flow.detailLoading ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading approval steps
</p>
) : pendingAction?.id === "approve" &&
!flow.mergedContext.approvalSteps?.length ? (
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
No pending approval step found. Accept the submission on the detail
page first.
</p>
) : null
}
/>
</>
);
}

View File

@@ -0,0 +1,168 @@
import { useRef } from "react";
import { Download, Upload, Zap } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
import { bookingSurface } from "./booking-ui.styles";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import { Button } from "@edr/ui-common";
type Mutations = ReturnType<typeof useBookingMutations>;
interface BookingActionsToolbarProps {
booking: BookingDetail;
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + payment uploads + downloads. */
export function BookingActionsToolbar({
booking,
mutations,
}: BookingActionsToolbarProps) {
const fileRef = useRef<HTMLInputElement>(null);
const row = toBookingListRow(booking);
const { status, paymentCurrency } = booking;
const pending = mutations.isPending;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
if (
status === "REJECTED" ||
status === "CANCELLED" ||
status === "COMPLETED"
) {
return null;
}
if (status === "CHANGES_REQUESTED") {
return (
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
{booking.latestChangeRequestNote && (
<p className="rounded-lg border bg-muted/30 p-3 text-sm leading-relaxed">
{booking.latestChangeRequestNote}
</p>
)}
</PanelShell>
);
}
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
return (
<PanelShell
title="No staff actions"
description="Monitor until the customer or system advances status."
muted
/>
);
}
return (
<div className="space-y-4">
<PanelShell
title="Staff actions"
description="Confirm each step before it is applied."
>
<BookingActionsMenu row={row} variant="toolbar" />
</PanelShell>
{status === "FULLY_EXECUTED" && paymentCurrency === "USD" && (
<PanelShell title="Payment (USD)" description="Upload proof of payment.">
<input
ref={fileRef}
type="file"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) mutations.submitPaymentProof.mutate(file);
}}
/>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={pending}
className="gap-2"
onClick={() => fileRef.current?.click()}
>
<Upload className="size-4" />
Upload payment proof
</Button>
<Button
variant="outline"
className="gap-2"
onClick={() =>
downloadBlob(
() => mutations.downloadPaymentLetter(),
`payment-letter-${booking.reference}.txt`,
)
}
>
<Download className="size-4" />
Request letter
</Button>
</div>
</PanelShell>
)}
{status === "CONTRACT_READY" && (
<PanelShell title="Documents" description="Download generated contract.">
<Button
variant="outline"
className="gap-2"
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
`contract-${booking.reference}.txt`,
)
}
>
<Download className="size-4" />
Download contract
</Button>
</PanelShell>
)}
</div>
);
}
function PanelShell({
title,
description,
children,
muted,
}: {
title: string;
description: string;
children: React.ReactNode;
muted?: boolean;
}) {
return (
<div
className={
muted
? bookingSurface.sectionCard
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
}
>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Zap className="size-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="flex flex-col gap-3 px-5 py-5">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { Loader2 } from "lucide-react";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
import { cn } from "@/lib/utils";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Textarea,
} from "@edr/ui-common";
interface BookingConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
action: BookingActionDef | null;
reference?: string;
inputValue: string;
onInputChange: (value: string) => void;
onConfirm: () => void;
isPending: boolean;
confirmDisabled?: boolean;
extra?: React.ReactNode;
}
export function BookingConfirmDialog({
open,
onOpenChange,
action,
reference,
inputValue,
onInputChange,
onConfirm,
isPending,
confirmDisabled = false,
extra,
}: BookingConfirmDialogProps) {
if (!action || !action.confirmTitle) return null;
const Icon = action.icon;
const needsInput = Boolean(action.input);
const inputMissing = needsInput && !inputValue.trim();
const isDestructive = action.variant === "destructive";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
<div
className={cn(
"border-b px-6 py-5",
isDestructive
? "bg-gradient-to-br from-red-500/10 via-background to-background"
: "bg-gradient-to-br from-primary/8 via-background to-background",
)}
>
<DialogHeader className="gap-3 text-left">
<div className="flex items-start gap-3">
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl shadow-sm",
isDestructive
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-primary/15 text-primary",
)}
>
<Icon className="size-5" />
</div>
<div className="min-w-0 space-y-1 pt-0.5">
<DialogTitle className="text-base leading-snug">
{action.confirmTitle}
</DialogTitle>
{reference && (
<p className="font-mono text-xs font-semibold text-muted-foreground">
{reference}
</p>
)}
</div>
</div>
<DialogDescription className="text-left text-sm leading-relaxed">
{action.confirmDescription}
</DialogDescription>
</DialogHeader>
</div>
<div className="space-y-4 px-6 py-5">
{needsInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel}
<span className="text-red-600"> *</span>
</label>
<Textarea
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
placeholder={action.inputPlaceholder}
rows={4}
className="min-h-[100px] resize-y"
/>
</div>
)}
{extra}
</div>
<DialogFooter className="gap-2 border-t bg-muted/20 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
variant={isDestructive ? "destructive" : "default"}
disabled={isPending || inputMissing || confirmDisabled}
className="min-w-[7rem] gap-2"
onClick={onConfirm}
>
{isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Icon className="size-4" />
)}
{action.shortLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,86 @@
import { Banknote, Receipt } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { Separator } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount);
const modifiers = booking.cargoModifiers ?? [];
return (
<div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
<Banknote className="size-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Pricing & payment
</h2>
<p className="text-xs text-muted-foreground">Commercial terms</p>
</div>
</div>
<div className="space-y-4 px-5 py-5">
<div className="rounded-xl border border-primary/15 bg-gradient-to-br from-primary/[0.06] to-transparent p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Total amount
</p>
<p className="mt-1 font-mono text-2xl font-bold tracking-tight text-foreground">
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
</div>
<Row label="Payment status" value={booking.paymentStatus} />
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && (
<>
<Separator />
<p className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<Receipt className="size-3" />
Surcharges applied
</p>
<ul className="space-y-2">
{modifiers.map((m) => (
<li
key={m.id}
className="flex justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm"
>
<span className="text-muted-foreground">Modifier</span>
<span className="font-mono font-semibold tabular-nums">
{Number(m.calculatedAmount).toLocaleString()}
</span>
</li>
))}
</ul>
</>
)}
</div>
</div>
);
}
function Row({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={
mono
? "font-mono text-xs font-semibold text-foreground"
: "font-medium text-foreground"
}
>
{value}
</span>
</div>
);
}

View File

@@ -0,0 +1,21 @@
export function BookingPriorityBadge({ score }: { score: number }) {
if (score >= 1000) {
return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
Urgent
</span>
);
}
if (score >= 500) {
return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
High
</span>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
Normal
</span>
);
}

View File

@@ -0,0 +1,56 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface StatItem {
label: string;
value: number | string;
hint?: string;
icon: LucideIcon;
accent?: "default" | "amber" | "emerald" | "rose";
}
const accentStyles = {
default: "bg-primary/10 text-primary",
amber: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
emerald: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
rose: "bg-rose-500/10 text-rose-700 dark:text-rose-400",
};
export function BookingStatGrid({ items }: { items: StatItem[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => {
const Icon = item.icon;
const accent = item.accent ?? "default";
return (
<div
key={item.label}
className="group relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm transition-all duration-200 hover:border-primary/20 hover:shadow-md"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{item.value}
</p>
{item.hint && (
<p className="mt-1 text-xs text-muted-foreground">{item.hint}</p>
)}
</div>
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-105",
accentStyles[accent],
)}
>
<Icon className="size-5" strokeWidth={2} />
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Badge } from "@edr/ui-common";
import { cn } from "@/lib/utils";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
export function BookingStatusBadge({ status }: { status: string }) {
const style = BOOKING_STATUS_STYLES[status] ?? {
label: status,
color: "bg-muted text-muted-foreground border-border",
};
return (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider",
style.color,
)}
>
{style.label}
</Badge>
);
}

View File

@@ -0,0 +1,91 @@
import {
ClipboardCheck,
FileSignature,
FileText,
Inbox,
LayoutGrid,
ShieldCheck,
} from "lucide-react";
import {
BOOKING_LIST_TABS,
type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config";
import { cn } from "@/lib/utils";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid className="size-4" />,
SUBMITTED: <Inbox className="size-4" />,
PENDING_APPROVAL: <ClipboardCheck className="size-4" />,
APPROVED_PENDING_SIGNATURE: <FileSignature className="size-4" />,
SIGNED_CUSTOMER: <FileText className="size-4" />,
PAYMENT_VERIFICATION_IN_PROGRESS: <ShieldCheck className="size-4" />,
};
interface BookingStatusTabsProps {
active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void;
counts?: Partial<Record<BookingStatusTabKey, number>>;
}
export function BookingStatusTabs({
active,
onChange,
counts,
}: BookingStatusTabsProps) {
return (
<div className="rounded-xl border border-border bg-muted/30 p-1.5">
<div
className="flex gap-1 overflow-x-auto pb-0.5 scrollbar-thin"
role="tablist"
aria-label="Booking status filters"
>
{BOOKING_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onChange(tab.key)}
className={cn(
"flex min-w-[7.5rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3.5 py-2.5 text-left transition-all duration-200",
isActive
? "bg-background text-foreground shadow-sm ring-1 ring-border/80"
: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
)}
>
<span className="flex w-full items-center justify-between gap-2">
<span
className={cn(
"flex items-center gap-2 text-sm font-semibold",
isActive && "text-primary",
)}
>
{TAB_ICONS[tab.key]}
{tab.label}
</span>
{count !== undefined && count > 0 && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-bold tabular-nums",
isActive
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{count}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
);
}
export type { BookingStatusTabKey };

View File

@@ -0,0 +1,44 @@
import { Package, Search } from "lucide-react";
import { Button } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
interface BookingTableEmptyProps {
isError?: boolean;
hasSearch?: boolean;
onRetry?: () => void;
}
export function BookingTableEmpty({
isError,
hasSearch,
onRetry,
}: BookingTableEmptyProps) {
return (
<div className={bookingSurface.emptyState}>
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
{hasSearch ? <Search className="size-6" /> : <Package className="size-6" />}
</div>
<div className="max-w-sm space-y-1">
<h3 className="text-base font-semibold text-foreground">
{isError
? "Could not load bookings"
: hasSearch
? "No matches on this page"
: "No bookings with this status"}
</h3>
<p className="text-sm text-muted-foreground">
{isError
? "Check your connection and try again."
: hasSearch
? "Try a different reference or customer name."
: "New customer submissions will appear when status is Submitted."}
</p>
</div>
{isError && onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
import {
Check,
CheckCircle2,
FileSignature,
FileText,
Train,
Wallet,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
getWorkflowStageIndex,
WORKFLOW_STAGES,
} from "@/features/bookings/booking-status.config";
import { bookingSurface } from "./booking-ui.styles";
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
interface BookingWorkflowStepperProps {
status: string;
title: string;
description: string;
titleColor: string;
}
export function BookingWorkflowStepper({
status,
title,
description,
titleColor,
}: BookingWorkflowStepperProps) {
const currentStage = getWorkflowStageIndex(status);
const isTerminal = currentStage < 0;
return (
<div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Train className="size-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Workflow progress
</h2>
<p className="text-xs text-muted-foreground">
Customer submission through completion
</p>
</div>
</div>
<div className="space-y-8 px-5 py-6">
<div className="relative px-2">
<div className="absolute left-4 right-4 top-5 h-0.5 bg-border" />
<div
className="absolute left-4 top-5 h-0.5 bg-primary transition-all duration-700 ease-out"
style={{
width:
!isTerminal && currentStage >= 0
? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)`
: "0%",
}}
/>
<div className="relative flex justify-between">
{WORKFLOW_STAGES.map((stage, idx) => {
const Icon = STAGE_ICONS[idx] ?? FileText;
const isCompleted = !isTerminal && idx < currentStage;
const isActive = !isTerminal && idx === currentStage;
return (
<div
key={stage.label}
className="flex max-w-[4.5rem] flex-col items-center gap-2.5 sm:max-w-none"
>
<div
className={cn(
"flex size-10 items-center justify-center rounded-full border-2 bg-card transition-all duration-300",
isCompleted &&
"border-primary bg-primary text-primary-foreground shadow-sm",
isActive &&
"scale-110 border-primary bg-background text-primary shadow-md ring-4 ring-primary/15",
!isCompleted &&
!isActive &&
"border-border text-muted-foreground",
)}
>
{isCompleted ? (
<CheckCircle2 className="size-4" />
) : (
<Icon className="size-4" />
)}
</div>
<span
className={cn(
"text-center text-[10px] font-bold uppercase leading-tight tracking-wide",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
{stage.label}
</span>
</div>
);
})}
</div>
</div>
<div
className={cn(
"rounded-xl border px-5 py-4",
isTerminal
? "border-destructive/20 bg-destructive/5"
: "border-primary/15 bg-primary/[0.04]",
)}
>
<h4
className={cn("text-sm font-bold tracking-tight", titleColor)}
>
{title}
</h4>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from "react";
import { Eraser } from "lucide-react";
import { Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
interface ContractSignaturePadProps {
onChange: (dataUrl: string | null) => void;
className?: string;
}
export function ContractSignaturePad({
onChange,
className,
}: ContractSignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawing = useRef(false);
const [empty, setEmpty] = useState(true);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.strokeStyle = "#111";
ctx.lineWidth = 2;
ctx.lineCap = "round";
}, []);
const getPos = (e: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
if ("touches" in e) {
const t = e.touches[0];
return { x: t.clientX - rect.left, y: t.clientY - rect.top };
}
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const start = (e: React.MouseEvent | React.TouchEvent) => {
drawing.current = true;
const ctx = canvasRef.current?.getContext("2d");
const { x, y } = getPos(e);
ctx?.beginPath();
ctx?.moveTo(x, y);
};
const move = (e: React.MouseEvent | React.TouchEvent) => {
if (!drawing.current) return;
const ctx = canvasRef.current?.getContext("2d");
const { x, y } = getPos(e);
ctx?.lineTo(x, y);
ctx?.stroke();
setEmpty(false);
onChange(canvasRef.current?.toDataURL("image/png") ?? null);
};
const end = () => {
drawing.current = false;
};
const clear = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
setEmpty(true);
onChange(null);
};
return (
<div className={cn("space-y-2", className)}>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<canvas
ref={canvasRef}
className="h-36 w-full touch-none cursor-crosshair"
onMouseDown={start}
onMouseMove={move}
onMouseUp={end}
onMouseLeave={end}
onTouchStart={start}
onTouchMove={move}
onTouchEnd={end}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Draw your signature above
</p>
<Button type="button" variant="ghost" size="sm" className="gap-1" onClick={clear}>
<Eraser className="size-3.5" />
Clear
</Button>
</div>
{empty && (
<p className="text-xs text-amber-700">Signature is required before confirming.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
/** Shared surfaces for booking list & detail — aligned with rule-engine polish. */
export const bookingSurface = {
page: "min-h-screen bg-gradient-to-b from-muted/40 via-background to-background",
pageInner: "mx-auto max-w-[1600px] space-y-6 p-6 lg:p-8",
hero:
"relative overflow-hidden rounded-2xl border border-border/80 bg-card shadow-sm",
heroGlow:
"pointer-events-none absolute -right-20 -top-20 size-64 rounded-full bg-primary/10 blur-3xl",
panel:
"overflow-hidden rounded-xl border border-border bg-card shadow-sm",
panelToolbar:
"flex flex-wrap items-center justify-between gap-3 border-b border-border bg-muted/25 px-4 py-3.5 sm:px-5",
tableWrap: "px-0",
sectionCard:
"overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md",
sectionHeader:
"flex items-center gap-3 border-b border-border/60 bg-muted/20 px-5 py-4",
sectionBody: "px-5 py-5",
detailHero:
"relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-card via-card to-primary/[0.04] shadow-sm",
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
metricTile:
"rounded-lg border border-border/70 bg-background/80 px-4 py-3 shadow-xs",
emptyState:
"flex flex-col items-center justify-center gap-3 px-6 py-16 text-center",
} as const;
export const bookingInput = {
search:
"h-10 w-full rounded-lg border border-input bg-background pl-10 text-sm shadow-xs transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25 sm:max-w-xs",
} as const;

View File

@@ -0,0 +1,132 @@
import { useCallback, useState } from "react";
import {
getBookingActions,
getNextPendingApprovalStep,
type BookingActionContext,
type BookingActionDef,
} from "@/features/bookings/booking-actions.config";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
) {
const [pendingAction, setPendingAction] = useState<BookingActionDef | null>(null);
const [inputValue, setInputValue] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const needsApprovalSteps =
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
const { data: detail, isLoading: detailLoading } = useBookingDetail(
needsApprovalSteps ? bookingId : undefined,
);
const mergedContext: BookingActionContext = {
...context,
approvalSteps: detail?.approvalSteps ?? context.approvalSteps,
reference: detail?.reference ?? context.reference,
};
const mutations = useBookingMutations(bookingId);
const actions = getBookingActions(mergedContext);
const openAction = useCallback((action: BookingActionDef) => {
setPendingAction(action);
setInputValue("");
setDialogOpen(true);
}, []);
const closeDialog = useCallback(() => {
setDialogOpen(false);
setPendingAction(null);
setInputValue("");
}, []);
const runAction = useCallback(() => {
if (!pendingAction) return;
const onSuccess = () => closeDialog();
switch (pendingAction.id) {
case "accept":
mutations.staffAccept.mutate(undefined, { onSuccess });
break;
case "requestChanges":
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
break;
case "reject":
mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
break;
case "approve": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
mutations.approveStep.mutate(
{ stepId: step.id, requiredRole: step.requiredRole },
{ onSuccess },
);
break;
}
case "rejectApproval": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
mutations.rejectStep.mutate(
{ stepId: step.id, reason: inputValue.trim() },
{ onSuccess },
);
break;
}
case "generateContract":
mutations.generateContract.mutate(undefined, { onSuccess });
break;
case "viewContract":
break;
case "generatePnr":
mutations.generatePnr.mutate(undefined, { onSuccess });
break;
case "verifyPayment":
mutations.verifyPayment.mutate(undefined, { onSuccess });
break;
case "startTransit":
mutations.startTransit.mutate(undefined, { onSuccess });
break;
case "complete":
mutations.complete.mutate(undefined, { onSuccess });
break;
default:
break;
}
}, [
pendingAction,
inputValue,
mergedContext.approvalSteps,
mutations,
closeDialog,
]);
const confirmDisabled =
mutations.isPending ||
(needsApprovalSteps && detailLoading) ||
(pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(mergedContext.approvalSteps));
return {
actions,
pendingAction,
inputValue,
setInputValue,
dialogOpen,
setDialogOpen: (open: boolean) => {
if (!open) closeDialog();
else setDialogOpen(true);
},
openAction,
closeDialog,
runAction,
mutations,
confirmDisabled,
detailLoading,
mergedContext,
};
}

View File

@@ -3,6 +3,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { LoadCargoDialog } from './LoadCargoDialog';
import type { Cargo } from '@/services/cargoService';
export function CargoesTable({ containerId }: { containerId: string }) {
const { data: cargoes, refetch } = useCargoesByContainer(containerId);
@@ -24,7 +25,7 @@ export function CargoesTable({ containerId }: { containerId: string }) {
</TableRow>
</TableHeader>
<TableBody>
{cargoes.map(cargo => (
{cargoes.map((cargo: Cargo) => (
<TableRow key={cargo.id}>
<TableCell>{cargo.cargoReference}</TableCell>
<TableCell>{cargo.description || '-'}</TableCell>
@@ -41,4 +42,5 @@ export function CargoesTable({ containerId }: { containerId: string }) {
</TableBody>
</Table>
);
}
}

View File

@@ -1,7 +1,7 @@
import { useContainersByWagon, useUnassignContainer } from '@/hooks/useContainers';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
import type { Container } from '@/services/containerService';
export function ContainersTable({ wagonId }: { wagonId: string }) {
const { data: containers, refetch } = useContainersByWagon(wagonId);
@@ -10,31 +10,35 @@ export function ContainersTable({ wagonId }: { wagonId: string }) {
if (!containers?.length) return <div className="text-muted-foreground">No containers assigned.</div>;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Number</TableHead>
<TableHead>Type</TableHead>
<TableHead>Position</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{containers.map(container => (
<TableRow key={container.id}>
<TableCell>{container.containerNumber}</TableCell>
<TableCell>{container.containerTypeId}</TableCell>
<TableCell>{container.position}</TableCell>
<TableCell>{container.status}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(container.id).then(() => refetch())}>
<table className="w-full table-fixed">
<thead>
<tr>
<th className="text-left">Number</th>
<th className="text-left">Type</th>
<th className="text-left">Position</th>
<th className="text-left">Status</th>
<th className="text-left">Actions</th>
</tr>
</thead>
<tbody>
{containers.map((container: Container) => (
<tr key={container.id}>
<td className="py-2">{container.containerNumber}</td>
<td className="py-2">{container.containerTypeId}</td>
<td className="py-2">{container.position}</td>
<td className="py-2">{container.status}</td>
<td className="py-2">
<Button
variant="ghost"
size="icon"
onClick={() => unassign.mutateAsync(container.id).then(() => refetch())}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</td>
</tr>
))}
</TableBody>
</Table>
</tbody>
</table>
);
}

View File

@@ -61,5 +61,45 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
}
if (format === "entityLabel" && value && typeof value === "object") {
const entity = value as { label?: string; code?: string; cargoTypeName?: string };
const label =
entity.label?.trim() ||
entity.cargoTypeName?.trim() ||
entity.code?.trim();
return label ? (
<span>{label}</span>
) : (
<span className="text-muted-foreground"></span>
);
}
if (format === "rateLabel") {
if (!value || typeof value !== "object") {
return value ? (
<span className="font-mono text-xs text-muted-foreground">{String(value)}</span>
) : (
<span className="text-muted-foreground"></span>
);
}
const rate = value as {
rateType?: string;
currency?: string;
rateValue?: number;
rateUnit?: string;
};
const parts = [
rate.rateType?.replace(/_/g, " "),
rate.currency,
rate.rateValue != null ? String(rate.rateValue) : "",
rate.rateUnit?.replace(/_/g, " "),
].filter(Boolean);
return parts.length > 0 ? (
<span>{parts.join(" · ")}</span>
) : (
<span className="text-muted-foreground"></span>
);
}
return String(value);
};

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,8 @@
export * from './table';
export * from './badge';
export * from './button';
export * from './dialog';
export * from './input';
export * from './label';
export * from './textarea';
export * from './Breadcrumbs';

View File

@@ -0,0 +1,114 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b outline-ring/50", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors bg-background hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 bg-muted first-of-type:pl-4 last-of-type:pr-4 first-of-type: p-2 py-4 text-left align-middle font-medium whitespace-nowrap text-secondary-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle first-of-type:pl-4 last-of-type:pr-4 whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};