mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge freight/develop into feature/trains-management
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
47
apps/edr-freight-web/backoffice/src/components/ui/badge.tsx
Normal file
47
apps/edr-freight-web/backoffice/src/components/ui/badge.tsx
Normal 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 };
|
||||
@@ -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';
|
||||
114
apps/edr-freight-web/backoffice/src/components/ui/table.tsx
Normal file
114
apps/edr-freight-web/backoffice/src/components/ui/table.tsx
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user