resolve conflict

This commit is contained in:
hagiye
2026-06-05 14:17:48 +03:00
126 changed files with 7123 additions and 1388 deletions

View File

@@ -1,17 +1,35 @@
import { useMemo } from "react";
import { ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useAuth } from "@/auth/useAuth";
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import {
buildApproveActionForStep,
canActOnApprovalStep,
getNextPendingApprovalStep,
} from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
import { Badge } from "@edr/ui-common";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
import { Badge, Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>;
interface ApprovalStepsCardProps {
booking: BookingDetail;
mutations: Mutations;
}
/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */
export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
/** Approval chain with inline approve on the current pending step. */
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(
null,
);
const steps = useMemo(
() =>
[...(booking.approvalSteps ?? [])].sort(
@@ -21,77 +39,136 @@ export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
);
const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, steps);
const pendingAction = pendingStep
? buildApproveActionForStep(pendingStep)
: null;
const openApprove = (step: BookingApprovalStep) => {
setPendingStep(step);
setConfirmOpen(true);
};
const closeApprove = () => {
setConfirmOpen(false);
setPendingStep(null);
};
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ onSuccess: () => closeApprove() },
);
};
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 className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Approval chain
</h2>
<p className="text-xs text-muted-foreground">
{summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin")}
</p>
</div>
</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 className="px-5 py-5">
{steps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
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}
steps={steps}
user={user}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
onApprove={openApprove}
/>
))}
</ul>
)}
</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>
<BookingConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) closeApprove();
else setConfirmOpen(true);
}}
action={pendingAction}
reference={booking.reference}
inputValue=""
onInputChange={() => {}}
onConfirm={runApprove}
isPending={mutations.approveStep.isPending}
/>
</>
);
}
function StepRow({
step,
steps,
user,
isNext,
isPending,
onApprove,
}: {
step: BookingApprovalStep;
steps: BookingApprovalStep[];
user: ReturnType<typeof useAuth>["user"];
isNext: boolean;
isPending: boolean;
onApprove: (step: BookingApprovalStep) => void;
}) {
const canApprove = canActOnApprovalStep(user, step, steps);
const statusStyles =
step.status === "APPROVED"
? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
? "border-emerald-500/25 bg-emerald-500/10 text-black"
: step.status === "REJECTED"
? "bg-red-500/15 text-red-800 dark:text-red-300"
? "bg-red-500/10 text-red-800 dark:text-red-300"
: isNext
? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
: "bg-muted text-muted-foreground";
? "border-emerald-500/25 bg-emerald-500/10 text-black"
: "bg-muted/40 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",
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
)}
>
<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">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
isNext
? cn(bookingGlass.iconWellGreen, "text-black")
: "bg-muted/40 text-muted-foreground",
)}
>
{step.stepOrder}
</span>
<div className="min-w-0">
@@ -105,12 +182,26 @@ function StepRow({
)}
</div>
</div>
<Badge
variant="outline"
className={cn("shrink-0 text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>
<div className="flex shrink-0 items-center gap-2">
{canApprove && (
<Button
type="button"
size="sm"
className="h-8 gap-1.5 shadow-sm"
disabled={isPending}
onClick={() => onApprove(step)}
>
<Check className="size-3.5" />
Approve
</Button>
)}
<Badge
variant="outline"
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>
</div>
</li>
);
}

View File

@@ -4,12 +4,14 @@ import {
ExternalLink,
Loader2,
MoreHorizontal,
Upload,
} from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isContractNavAction,
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
@@ -30,18 +32,23 @@ interface BookingActionsMenuProps {
/** Compact table cell vs. larger detail toolbar */
variant?: "table" | "toolbar";
className?: string;
/** Suppresses table row navigation after menu/dialog close (click-through). */
onSuppressRowClick?: () => void;
}
export function BookingActionsMenu({
row,
variant = "table",
className,
onSuppressRowClick,
}: BookingActionsMenuProps) {
const navigate = useNavigate();
const { user } = useAuth();
const context: BookingActionContext = {
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: row.reference,
approvalSteps: row.approvalSteps,
};
const flow = useBookingActionDialog(row.id, context);
@@ -50,9 +57,7 @@ export function BookingActionsMenu({
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const showUsdPaymentHint =
row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
const hasMenu = listRowHasActions(row, user);
const primary = actions.find((a) => a.primary) ?? actions[0];
@@ -73,8 +78,9 @@ export function BookingActionsMenu({
return (
<>
<div
data-stop-row-click
className={cn(
"flex items-center justify-end gap-1",
"flex w-full min-h-[2.5rem] items-center justify-end gap-1",
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
className,
)}
@@ -87,7 +93,7 @@ export function BookingActionsMenu({
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
disabled={mutations.isPending}
onClick={() =>
primary.id === "viewContract"
isContractNavAction(primary.id)
? goToContract()
: flow.openAction(primary)
}
@@ -119,7 +125,7 @@ export function BookingActionsMenu({
)}
disabled={mutations.isPending}
onClick={() =>
action.id === "viewContract"
isContractNavAction(action.id)
? goToContract()
: flow.openAction(action)
}
@@ -164,36 +170,29 @@ export function BookingActionsMenu({
"gap-2 cursor-pointer",
action.variant === "destructive" && "text-red-700 focus:text-red-700",
)}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
onSelect={(event) => {
event.preventDefault();
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else {
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 />
)}
{actions.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
onSelect={(event) => {
event.preventDefault();
onSuppressRowClick?.();
navigate(`/dashboard/booking-requests/${row.id}`);
}}
>
<ExternalLink className="size-4 opacity-70" />
Open full details
@@ -205,12 +204,20 @@ export function BookingActionsMenu({
<BookingConfirmDialog
open={flow.dialogOpen}
onOpenChange={flow.setDialogOpen}
onOpenChange={(open) => {
if (!open) onSuppressRowClick?.();
flow.setDialogOpen(open);
}}
action={pendingAction}
reference={flow.mergedContext.reference}
inputValue={flow.inputValue}
onInputChange={flow.setInputValue}
onConfirm={flow.runAction}
selectedFile={flow.selectedFile}
onFileChange={flow.setSelectedFile}
onConfirm={() => {
onSuppressRowClick?.();
flow.runAction();
}}
isPending={mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
@@ -220,10 +227,10 @@ export function BookingActionsMenu({
Loading approval steps
</p>
) : pendingAction?.id === "approve" &&
!flow.mergedContext.approvalSteps?.length ? (
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
<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.
No pending approval step. Refresh the page after staff accept, or
reject the booking.
</p>
) : null
}

View File

@@ -1,5 +1,4 @@
import { useRef } from "react";
import { Download, Upload, Zap } from "lucide-react";
import { Download, Zap } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -7,6 +6,7 @@ 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";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>;
@@ -15,15 +15,13 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + payment uploads + downloads. */
/** Detail-page actions: primary toolbar + 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 { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
@@ -47,7 +45,7 @@ export function BookingActionsToolbar({
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">
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
{booking.latestChangeRequestNote}
</p>
)}
@@ -74,50 +72,11 @@ export function BookingActionsToolbar({
<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"
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
@@ -146,16 +105,10 @@ function PanelShell({
muted?: boolean;
}) {
return (
<div
className={
muted
? bookingSurface.sectionCard
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
}
>
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
<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 className={bookingSurface.sectionIcon}>
<Zap className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>

View File

@@ -0,0 +1,29 @@
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
interface BookingApprovalProgressCellProps {
row: BookingListRow;
}
export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) {
const summary = formatApprovalProgress(row.status, row.approvalSteps);
return (
<div className="min-w-[8.5rem] py-1">
<p
className={cn(
"text-sm font-semibold",
summary.complete ? "text-emerald-700 dark:text-emerald-400" : "text-foreground",
)}
>
{summary.label}
</p>
{summary.detail ? (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{summary.detail}
</p>
) : null}
</div>
);
}

View File

@@ -20,6 +20,8 @@ interface BookingConfirmDialogProps {
reference?: string;
inputValue: string;
onInputChange: (value: string) => void;
selectedFile?: File | null;
onFileChange?: (file: File | null) => void;
onConfirm: () => void;
isPending: boolean;
confirmDisabled?: boolean;
@@ -33,6 +35,8 @@ export function BookingConfirmDialog({
reference,
inputValue,
onInputChange,
selectedFile = null,
onFileChange,
onConfirm,
isPending,
confirmDisabled = false,
@@ -41,13 +45,25 @@ export function BookingConfirmDialog({
if (!action || !action.confirmTitle) return null;
const Icon = action.icon;
const needsInput = Boolean(action.input);
const inputMissing = needsInput && !inputValue.trim();
const needsTextInput =
action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const inputMissing =
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive";
const preventClickThrough = (event: React.MouseEvent) => {
event.preventDefault();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
<DialogContent
className="gap-0 overflow-hidden p-0 sm:max-w-md"
showCloseButton={false}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<div
className={cn(
"border-b px-6 py-5",
@@ -86,7 +102,7 @@ export function BookingConfirmDialog({
</div>
<div className="space-y-4 px-6 py-5">
{needsInput && (
{needsTextInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel}
@@ -101,6 +117,27 @@ export function BookingConfirmDialog({
/>
</div>
)}
{needsFileInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel ?? "Bank slip file"}
<span className="text-red-600"> *</span>
</label>
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:font-semibold file:text-primary-foreground"
onChange={(e) =>
onFileChange?.(e.target.files?.[0] ?? null)
}
/>
{selectedFile && (
<p className="text-xs text-muted-foreground">
Selected: {selectedFile.name}
</p>
)}
</div>
)}
{extra}
</div>
@@ -109,6 +146,7 @@ export function BookingConfirmDialog({
type="button"
variant="outline"
disabled={isPending}
onMouseDown={preventClickThrough}
onClick={() => onOpenChange(false)}
>
Cancel
@@ -118,6 +156,7 @@ export function BookingConfirmDialog({
variant={isDestructive ? "destructive" : "default"}
disabled={isPending || inputMissing || confirmDisabled}
className="min-w-[7rem] gap-2"
onMouseDown={preventClickThrough}
onClick={onConfirm}
>
{isPending ? (

View File

@@ -2,7 +2,7 @@ import { Banknote, Receipt } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { Separator } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount);
@@ -11,8 +11,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
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 className={bookingSurface.sectionIcon}>
<Banknote className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
@@ -22,11 +22,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
</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">
<div className={bookingSurface.valueCard}>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total amount
</p>
<p className="mt-1 font-mono text-2xl font-bold tracking-tight text-foreground">
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
@@ -35,8 +35,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{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">
<Separator className="opacity-50" />
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Receipt className="size-3" />
Surcharges applied
</p>
@@ -44,7 +44,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{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"
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm"
>
<span className="text-muted-foreground">Modifier</span>
<span className="font-mono font-semibold tabular-nums">
@@ -70,7 +70,7 @@ function Row({
mono?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 text-sm">
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5 text-sm backdrop-blur-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={

View File

@@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
export interface StatItem {
@@ -9,43 +10,49 @@ export interface StatItem {
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",
const iconAccentStyles = {
default: "text-foreground/70",
amber: "text-amber-600 dark:text-amber-400",
emerald: "text-emerald-600 dark:text-emerald-400",
rose: "text-rose-600 dark:text-rose-400",
};
export function BookingStatGrid({ items }: { items: StatItem[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-3 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"
className={cn(
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md",
bookingGlass.card,
)}
>
<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">
<p className="text-[11px] 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">
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground">
{item.value}
</p>
{item.hint && (
<p className="mt-1 text-xs text-muted-foreground">{item.hint}</p>
<p className="mt-1 text-xs leading-relaxed 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],
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
bookingGlass.iconWellGreen,
iconAccentStyles[accent],
)}
>
<Icon className="size-5" strokeWidth={2} />
<Icon className="size-[18px]" strokeWidth={1.75} />
</div>
</div>
</div>

View File

@@ -1,27 +1,34 @@
import {
CheckCircle,
ClipboardCheck,
FileSignature,
FileText,
Inbox,
LayoutGrid,
ShieldCheck,
Train,
Wallet,
XCircle,
} from "lucide-react";
import {
BOOKING_LIST_TABS,
type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config";
import { bookingGlass } from "./booking-ui.styles";
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" />,
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
operations: <Train className="size-3.5" strokeWidth={1.75} />,
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />,
closed: <XCircle className="size-3.5" strokeWidth={1.75} />,
};
const activeTabText = "text-black";
interface BookingStatusTabsProps {
active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void;
@@ -34,9 +41,9 @@ export function BookingStatusTabs({
counts,
}: BookingStatusTabsProps) {
return (
<div className="rounded-xl border border-border bg-muted/30 p-1.5">
<div className={bookingGlass.tabRail}>
<div
className="flex gap-1 overflow-x-auto pb-0.5 scrollbar-thin"
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
aria-label="Booking status filters"
>
@@ -51,29 +58,38 @@ export function BookingStatusTabs({
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",
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 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",
? bookingGlass.activeTab
: "text-muted-foreground hover:bg-emerald-500/5 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",
"flex items-center gap-2 text-sm font-medium",
isActive ? activeTabText : "text-muted-foreground",
)}
>
{TAB_ICONS[tab.key]}
{tab.label}
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
isActive
? cn(bookingGlass.iconWellGreen, "text-black")
: "border border-transparent bg-muted/30",
)}
>
{TAB_ICONS[tab.key]}
</span>
<span className="whitespace-nowrap">{tab.label}</span>
</span>
{count !== undefined && count > 0 && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-bold tabular-nums",
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums",
isActive
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
? cn("bg-emerald-500/15", activeTabText)
: "bg-muted/50 text-muted-foreground",
)}
>
{count}

View File

@@ -1,6 +1,7 @@
import { Package, Search } from "lucide-react";
import { Button } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
interface BookingTableEmptyProps {
isError?: boolean;
@@ -15,7 +16,12 @@ export function BookingTableEmpty({
}: BookingTableEmptyProps) {
return (
<div className={bookingSurface.emptyState}>
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<div
className={cn(
"flex size-14 items-center justify-center rounded-2xl text-muted-foreground",
bookingGlass.iconWellGreen,
)}
>
{hasSearch ? <Search className="size-6" /> : <Package className="size-6" />}
</div>
<div className="max-w-sm space-y-1">

View File

@@ -12,7 +12,7 @@ import {
getWorkflowStageIndex,
WORKFLOW_STAGES,
} from "@/features/bookings/booking-status.config";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
@@ -35,8 +35,8 @@ export function BookingWorkflowStepper({
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 className={bookingSurface.sectionIcon}>
<Train className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
@@ -49,9 +49,9 @@ export function BookingWorkflowStepper({
</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 right-4 top-5 h-px bg-border/60" />
<div
className="absolute left-4 top-5 h-0.5 bg-primary transition-all duration-700 ease-out"
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out"
style={{
width:
!isTerminal && currentStage >= 0
@@ -71,14 +71,17 @@ export function BookingWorkflowStepper({
>
<div
className={cn(
"flex size-10 items-center justify-center rounded-full border-2 bg-card transition-all duration-300",
"flex size-10 items-center justify-center rounded-full border-2 bg-card/80 backdrop-blur-sm transition-all duration-300",
isCompleted &&
"border-primary bg-primary text-primary-foreground shadow-sm",
cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
isActive &&
"scale-110 border-primary bg-background text-primary shadow-md ring-4 ring-primary/15",
cn(
bookingGlass.activeTab,
"scale-105 border-emerald-500/30 text-black shadow-sm",
),
!isCompleted &&
!isActive &&
"border-border text-muted-foreground",
"border-border/60 text-muted-foreground",
)}
>
{isCompleted ? (
@@ -89,8 +92,8 @@ export function BookingWorkflowStepper({
</div>
<span
className={cn(
"text-center text-[10px] font-bold uppercase leading-tight tracking-wide",
isActive ? "text-primary" : "text-muted-foreground",
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
isActive ? "text-black" : "text-muted-foreground",
)}
>
{stage.label}
@@ -103,14 +106,17 @@ export function BookingWorkflowStepper({
<div
className={cn(
"rounded-xl border px-5 py-4",
"rounded-xl border px-5 py-4 backdrop-blur-sm",
isTerminal
? "border-destructive/20 bg-destructive/5"
: "border-primary/15 bg-primary/[0.04]",
: bookingGlass.activeTab,
)}
>
<h4
className={cn("text-sm font-bold tracking-tight", titleColor)}
className={cn(
"text-sm font-semibold tracking-tight",
isTerminal ? titleColor : "text-black",
)}
>
{title}
</h4>

View File

@@ -0,0 +1,32 @@
import { ArrowRight } from "lucide-react";
import type { BookingNextStep } from "@/types/booking";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
interface NextStepBannerProps {
nextStep: BookingNextStep;
className?: string;
}
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) {
return (
<div
className={cn(
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm",
bookingGlass.activeTab,
className,
)}
role="status"
>
<ArrowRight className="mt-0.5 size-4 shrink-0 text-black" aria-hidden />
<div className="min-w-0 space-y-0.5">
<p className="font-semibold text-black">
Next: {nextStep.action.replace(/_/g, " ")}
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
</p>
<p className="text-muted-foreground">{nextStep.description}</p>
</div>
</div>
);
}

View File

@@ -1,32 +1,63 @@
/** Shared surfaces for booking list & detail — aligned with rule-engine polish. */
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
export const bookingGlass = {
card:
"border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
panel:
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
rail:
"border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
iconWell:
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
iconWellHero:
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
activeTab:
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]",
iconWellGreen:
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400",
tabRail:
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
tableHeader:
"bg-muted/30 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/20",
} as const;
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",
page:
"min-h-screen bg-gradient-to-b from-muted/30 via-background to-background",
pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
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",
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",
heroSheen:
"pointer-events-none absolute inset-0 bg-gradient-to-br from-background/40 via-transparent to-muted/20",
panel: `overflow-hidden rounded-xl ${bookingGlass.panel}`,
panelToolbar: `flex flex-wrap items-center justify-between gap-3 border-b border-border/50 px-4 py-3.5 sm:px-5 ${bookingGlass.rail}`,
tableWrap: "px-0",
sectionCard:
"overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md",
sectionCard: `overflow-hidden rounded-xl transition-shadow duration-200 hover:shadow-md ${bookingGlass.card}`,
sectionHeader:
"flex items-center gap-3 border-b border-border/60 bg-muted/20 px-5 py-4",
"flex items-center gap-3 border-b border-border/50 bg-muted/15 px-5 py-4 backdrop-blur-sm",
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",
detailHero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
valueCard:
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]",
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",
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
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",
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs",
} as const;
export const bookingTable = {
headerCell:
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
rowHover:
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
} as const;

View File

@@ -6,6 +6,7 @@ import {
type BookingActionContext,
type BookingActionDef,
} from "@/features/bookings/booking-actions.config";
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
export function useBookingActionDialog(
@@ -14,13 +15,18 @@ export function useBookingActionDialog(
) {
const [pendingAction, setPendingAction] = useState<BookingActionDef | null>(null);
const [inputValue, setInputValue] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const needsApprovalSteps =
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
const needsApprovalContext =
context.status === "PENDING_APPROVAL" ||
context.status === "APPROVED_PENDING_SIGNATURE";
const { data: detail, isLoading: detailLoading } = useBookingDetail(
needsApprovalSteps ? bookingId : undefined,
needsApprovalSteps || needsApprovalContext ? bookingId : undefined,
);
const mergedContext: BookingActionContext = {
@@ -29,12 +35,14 @@ export function useBookingActionDialog(
reference: detail?.reference ?? context.reference,
};
const { user } = useAuth();
const mutations = useBookingMutations(bookingId);
const actions = getBookingActions(mergedContext);
const actions = getBookingActions(mergedContext, user);
const openAction = useCallback((action: BookingActionDef) => {
setPendingAction(action);
setInputValue("");
setSelectedFile(null);
setDialogOpen(true);
}, []);
@@ -42,6 +50,7 @@ export function useBookingActionDialog(
setDialogOpen(false);
setPendingAction(null);
setInputValue("");
setSelectedFile(null);
}, []);
const runAction = useCallback(() => {
@@ -82,11 +91,8 @@ export function useBookingActionDialog(
break;
case "viewContract":
break;
case "generatePnr":
mutations.generatePnr.mutate(undefined, { onSuccess });
break;
case "verifyPayment":
mutations.verifyPayment.mutate(undefined, { onSuccess });
case "payBooking":
mutations.payBooking.mutate(undefined, { onSuccess });
break;
case "startTransit":
mutations.startTransit.mutate(undefined, { onSuccess });
@@ -94,12 +100,16 @@ export function useBookingActionDialog(
case "complete":
mutations.complete.mutate(undefined, { onSuccess });
break;
case "cancel":
mutations.cancel.mutate(inputValue.trim(), { onSuccess });
break;
default:
break;
}
}, [
pendingAction,
inputValue,
selectedFile,
mergedContext.approvalSteps,
mutations,
closeDialog,
@@ -109,13 +119,18 @@ export function useBookingActionDialog(
mutations.isPending ||
(needsApprovalSteps && detailLoading) ||
(pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(mergedContext.approvalSteps));
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim());
return {
actions,
pendingAction,
inputValue,
setInputValue,
selectedFile,
setSelectedFile,
dialogOpen,
setDialogOpen: (open: boolean) => {
if (!open) closeDialog();

View File

@@ -21,8 +21,9 @@ export interface RuleEngineCardGridProps {
pageCount: number;
totalCount: number;
};
onEdit: (record: RuleEngineRecord) => void;
onDelete: (record: RuleEngineRecord) => void;
onEdit?: (record: RuleEngineRecord) => void;
onDelete?: (record: RuleEngineRecord) => void;
readOnly?: boolean;
onViewChain?: () => void;
onSubmitRate?: (id: string) => void;
onApproveRate?: (record: RuleEngineRecord) => void;
@@ -41,6 +42,7 @@ const RuleEngineCardGrid = ({
onViewChain,
onSubmitRate,
onApproveRate,
readOnly = false,
}: RuleEngineCardGridProps) => {
const presentation = resolveCardPresentation(config);
@@ -153,8 +155,9 @@ const RuleEngineCardGrid = ({
record={record}
config={config}
layout="compact"
onEdit={onEdit}
onDelete={onDelete}
readOnly={readOnly}
onEdit={onEdit ?? (() => {})}
onDelete={onDelete ?? (() => {})}
onViewChain={onViewChain}
onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate}

View File

@@ -26,6 +26,7 @@ export interface RuleEngineRecordActionsProps {
onSubmitRate?: (id: string) => void;
onApproveRate?: (record: RuleEngineRecord) => void;
layout?: "row" | "compact";
readOnly?: boolean;
}
const RuleEngineRecordActions = ({
@@ -37,6 +38,7 @@ const RuleEngineRecordActions = ({
onSubmitRate,
onApproveRate,
layout = "row",
readOnly = false,
}: RuleEngineRecordActionsProps) => {
const status = String(record.status ?? "");
const hasRateActions =
@@ -47,6 +49,21 @@ const RuleEngineRecordActions = ({
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
if (readOnly) {
return onViewChain ? (
<Button
type="button"
variant="ghost"
size="icon"
className={iconBtnClass}
onClick={onViewChain}
aria-label="View chain"
>
<Eye className="h-4 w-4" />
</Button>
) : null;
}
return (
<div className="flex items-center justify-end gap-0.5">
<Button

View File

@@ -10,7 +10,7 @@ export interface RuleEngineToolbarProps {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder: string;
onAdd: () => void;
onAdd?: () => void;
addLabel?: string;
viewMode: RuleEngineViewMode;
onViewModeChange: (mode: RuleEngineViewMode) => void;
@@ -81,10 +81,12 @@ const RuleEngineToolbar = ({
<Filter className="h-4 w-4" />
Filter
</Button>
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
<Plus className="h-4 w-4" />
{addLabel}
</Button>
{onAdd ? (
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
<Plus className="h-4 w-4" />
{addLabel}
</Button>
) : null}
</div>
</div>
);

View File

@@ -1,12 +1,13 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useWagons, useAssignWagonToTrain } from '@/hooks/useWagons';
import { useToast } from '@/hooks/use-toast';
import { Plus } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
export function AssignWagonDialog({ trainId }: { trainId: string }) {
const [open, setOpen] = useState(false);
@@ -16,7 +17,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
const assign = useAssignWagonToTrain();
const { toast } = useToast();
const available = wagons?.filter(w => w.status === 'AVAILABLE' || !w.trainId);
const available = wagons?.filter((w:any) => w.status === 'AVAILABLE' || !w.trainId);
const handleAssign = async () => {
if (!wagonId) return;
@@ -36,7 +37,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
<Select value={wagonId} onValueChange={setWagonId}>
<SelectTrigger><SelectValue placeholder="Select wagon" /></SelectTrigger>
<SelectContent>
{available?.map(w => <SelectItem key={w.id} value={w.id}>{w.wagonNumber}</SelectItem>)}
{available?.map((w:any) => <SelectItem key={w.id} value={w.id}>{w.wagonNumber}</SelectItem>)}
</SelectContent>
</Select>
</div>

View File

@@ -2,7 +2,7 @@ import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/us
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Trash2, GripVertical } from 'lucide-react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
export function WagonsTable({ trainId }: { trainId: string }) {
const { data: wagons, refetch } = useWagonsByTrain(trainId);
@@ -14,50 +14,51 @@ export function WagonsTable({ trainId }: { trainId: string }) {
const items = Array.from(wagons || []);
const [removed] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, removed);
reorder.mutate({ trainId, wagonIds: items.map(w => w.id) });
reorder.mutate({ trainId, wagonIds: items.map((w:any) => w.id) });
};
if (!wagons?.length) return <div className="text-muted-foreground">No wagons assigned.</div>;
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="wagons">
{(provided) => (
<Table {...provided.droppableProps} ref={provided.innerRef}>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead>Number</TableHead>
<TableHead>Type</TableHead>
<TableHead>Sequence</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wagons.map((wagon, idx) => (
<Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
{(provided) => (
<TableRow ref={provided.innerRef} {...provided.draggableProps}>
<TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
<TableCell>{wagon.wagonNumber}</TableCell>
<TableCell>{wagon.wagonTypeId}</TableCell>
<TableCell>{wagon.sequenceNumber}</TableCell>
<TableCell>{wagon.status}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
)}
</Draggable>
))}
{provided.placeholder}
</TableBody>
</Table>
)}
</Droppable>
</DragDropContext>
<div></div>
// <DragDropContext onDragEnd={onDragEnd}>
// <Droppable droppableId="wagons">
// {(provided) => (
// <Table {...provided.droppableProps} ref={provided.innerRef}>
// <TableHeader>
// <TableRow>
// <TableHead className="w-10"></TableHead>
// <TableHead>Number</TableHead>
// <TableHead>Type</TableHead>
// <TableHead>Sequence</TableHead>
// <TableHead>Status</TableHead>
// <TableHead>Actions</TableHead>
// </TableRow>
// </TableHeader>
// <TableBody>
// {wagons.map((wagon, idx) => (
// <Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
// {(provided) => (
// <TableRow ref={provided.innerRef} {...provided.draggableProps}>
// <TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
// <TableCell>{wagon.wagonNumber}</TableCell>
// <TableCell>{wagon.wagonTypeId}</TableCell>
// <TableCell>{wagon.sequenceNumber}</TableCell>
// <TableCell>{wagon.status}</TableCell>
// <TableCell>
// <Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
// <Trash2 className="h-4 w-4" />
// </Button>
// </TableCell>
// </TableRow>
// )}
// </Draggable>
// ))}
// {provided.placeholder}
// </TableBody>
// </Table>
// )}
// </Droppable>
// </DragDropContext>
);
}