merge conflict fix

This commit is contained in:
hagiye
2026-06-24 12:31:59 +03:00
494 changed files with 21885 additions and 14336 deletions

View File

@@ -1,117 +0,0 @@
// pages/admin/rateMatrix/RateMatrixApproval.tsx
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { LoadingScreen } from '@/ui/LoadingScreen';
import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
import { queryKeys } from '../../../constants/QUERY_KEYS';
import { API_URLS } from '@/constants/URL_CONSTANTS';
//import { MATRIX_STATUS } from '@/constants/rateMatrixConstants';
import { toast } from 'sonner';
import { Navigate } from 'react-router-dom';
export default function RateMatrixApprovalPage() {
const { isChiefExecutive } = useRateMatrixAuth();
const queryClient = useQueryClient();
const pendingMatricesQueryKey = [...queryKeys.rateMatrix.all, 'pending-approval'];
const { data: pendingMatrices, isLoading } = useQuery({
queryKey: pendingMatricesQueryKey,
queryFn: async () => {
const response = await fetch(`${API_URLS.RATE_MATRIX.LIST}?status=pending_approval`);
return response.json();
},
});
const authorizeMutation = useMutation({
mutationFn: async ({ matrixId, signature }: { matrixId: string; signature: string }) => {
const response = await fetch(API_URLS.RATE_MATRIX.AUTHORIZE(matrixId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ digitalSignature: signature }),
});
if (!response.ok) throw new Error('Authorization failed');
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: pendingMatricesQueryKey });
toast.success('Rate matrix authorized successfully!');
},
onError: () => {
toast.error('Failed to authorize rate matrix');
},
});
if (!isChiefExecutive) {
return <Navigate to="/unauthorized" replace />;
}
if (isLoading) return <LoadingScreen />;
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Pending Rate Matrix Approvals</h1>
<div className="space-y-6">
{pendingMatrices?.map((matrix: any) => (
<Card key={matrix.id}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{matrix.matrixName}</span>
<Badge variant="secondary">{matrix.status}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-semibold">Effective Date</p>
<p>{matrix.effectiveDate}</p>
</div>
<div>
<p className="text-sm font-semibold">Submitted By</p>
<p>{matrix.createdBy}</p>
</div>
</div>
<div>
<p className="text-sm font-semibold mb-2">Rate Types Included:</p>
<div className="flex flex-wrap gap-2">
{matrix.rateEntries?.map((entry: any) => (
<Badge key={entry.id} variant="outline">
{entry.rateType}
</Badge>
))}
</div>
</div>
<div className="flex gap-4">
<Button
onClick={() => {
// Implement digital signature collection
const signature = prompt('Enter digital signature:');
if (signature) {
authorizeMutation.mutate({
matrixId: matrix.id,
signature
});
}
}}
disabled={authorizeMutation.isPending}
>
Authorize & Release
</Button>
<Button variant="outline">
Request Changes
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -1,25 +0,0 @@
// pages/admin/rateMatrix/RateMatrixRegistration.tsx
import React from 'react';
import { RateMatrixForm } from '@/components/baselineRatematrix/RateMatrixForm';
import { useRateMatrixAuth } from '@/auth/useAuth';
import { Navigate } from 'react-router-dom';
// Local lightweight fallback for LoadingScreen to avoid import errors
const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<div>{message}</div>
</div>
);
export default function RateMatrixRegistrationPage() {
const { isDirector, isLoading } = useRateMatrixAuth();
if (isLoading) {
return <LoadingScreen message="Checking permissions..." />;
}
if (!isDirector) {
return <Navigate to="/unauthorized" replace />;
}
return <RateMatrixForm />;
}

View File

@@ -1,6 +1,15 @@
import { type FormEvent, useState } from "react";
import { parsePhoneNumberFromString } from "libphonenumber-js";
import { Eye, EyeOff, Mail, Smartphone, UserRound, ArrowUpRight, Globe, ChevronDown } from "lucide-react";
import {
Eye,
EyeOff,
Mail,
Smartphone,
UserRound,
ArrowUpRight,
Globe,
ChevronDown,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
@@ -13,10 +22,25 @@ const loginModes: Array<{
icon: typeof Mail;
placeholder: string;
}> = [
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
{ value: "username", label: "Username", icon: UserRound, placeholder: "username" },
];
{
value: "email",
label: "Email",
icon: Mail,
placeholder: "name@company.com",
},
{
value: "phone",
label: "Phone",
icon: Smartphone,
placeholder: "09XXXXXXXX",
},
{
value: "username",
label: "Username",
icon: UserRound,
placeholder: "username",
},
];
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/;
@@ -43,7 +67,9 @@ const normalizeIdentifier = (mode: LoginMode, value: string) => {
}
if (!usernamePattern.test(trimmed)) {
throw new Error("Username must be 3-32 characters and use letters, numbers, ., _, or -.");
throw new Error(
"Username must be 3-32 characters and use letters, numbers, ., _, or -.",
);
}
return trimmed;
@@ -59,14 +85,24 @@ const primaryButtonClass =
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
const LeftPanelDecor = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
<svg
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
viewBox="0 0 400 400"
fill="none"
>
{[0, 1, 2, 3, 4, 5].map((ring) => (
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
<circle
key={ring}
cx="200"
cy="200"
r={60 + ring * 36}
stroke="currentColor"
strokeWidth="1"
/>
))}
</svg>
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
@@ -74,12 +110,23 @@ const LeftPanelDecor = () => (
);
const RightPanelDecor = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
<svg className="absolute inset-0 h-full w-full text-gray-200/40" xmlns="http://www.w3.org/2000/svg">
<svg
className="absolute inset-0 h-full w-full text-gray-200/40"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern id="login-grid" width="28" height="28" patternUnits="userSpaceOnUse">
<pattern
id="login-grid"
width="28"
height="28"
patternUnits="userSpaceOnUse"
>
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
</pattern>
</defs>
@@ -99,7 +146,11 @@ const LeftPanel = () => (
<LeftPanelDecor />
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
<img src={EDR_LOGO} alt="EDR Freight" className="h-7 w-auto brightness-0 invert sm:h-9" />
<img
src={EDR_LOGO}
alt="EDR Freight"
className="h-7 w-auto brightness-0 invert sm:h-9"
/>
<a
href="#"
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
@@ -118,8 +169,8 @@ const LeftPanel = () => (
</span>
</div>
<p className="text-sm leading-relaxed text-white/85">
Sign in to manage bookings, track cargo, and run logistics operations on the
Ethio Djibouti Railway freight platform.
Sign in to manage bookings, track cargo, and run logistics operations
on the Ethio Djibouti Railway freight platform.
</p>
</div>
</div>
@@ -138,13 +189,22 @@ const FormFooter = () => (
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
<span className="shrink-0">© 2026 EDR Freight</span>
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
<a
href="#"
className="font-semibold text-gray-700 transition-colors hover:text-primary"
>
Terms & Conditions
</a>
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
<a
href="#"
className="font-semibold text-gray-700 transition-colors hover:text-primary"
>
Privacy Policy
</a>
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
<a
href="#"
className="font-semibold text-gray-700 transition-colors hover:text-primary"
>
Help & Support
</a>
</div>
@@ -176,7 +236,7 @@ const LoginPage = () => {
setNormalizedIdentifier(normalized);
const result = await login({ email: normalized, password });
console.log(result)
console.log(result);
if (result.mfaRequired) {
setNeedsMfa(true);
return;
@@ -212,15 +272,20 @@ const LoginPage = () => {
</div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">Get Started</h1>
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Get Started
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Log in to access the freight backoffice & explore all logistics resources.
Log in to access the freight backoffice & explore all logistics
resources.
</p>
</div>
<div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">Sign in method</label>
<label className="text-sm font-medium text-gray-800">
Sign in method
</label>
<div className="relative">
<select
value={mode}
@@ -267,32 +332,26 @@ const LoginPage = () => {
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
</div>
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded-md border-gray-300 text-primary transition-colors focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
/>
<span className="text-sm leading-snug text-gray-600">
I agree to EDR Freight{" "}
<a href="#" className="font-semibold text-primary hover:underline">
Terms & Conditions
</a>
.
</span>
</label>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error}
</div>
) : null}
<button type="submit" disabled={submitting} className={primaryButtonClass}>
<button
type="submit"
disabled={submitting}
className={primaryButtonClass}
>
{submitting ? "Signing in..." : "Sign In"}
</button>
@@ -318,8 +377,10 @@ const LoginPage = () => {
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a verification code to{" "}
<span className="font-medium text-gray-700">{normalizedIdentifier}</span>. Enter it below
to complete sign in.
<span className="font-medium text-gray-700">
{normalizedIdentifier}
</span>
. Enter it below to complete sign in.
</p>
</div>
@@ -354,7 +415,11 @@ const LoginPage = () => {
>
Back
</button>
<button type="submit" disabled={submitting} className={`${primaryButtonClass} min-w-0 flex-1`}>
<button
type="submit"
disabled={submitting}
className={`${primaryButtonClass} min-w-0 flex-1`}
>
{submitting ? "Verifying..." : "Verify"}
</button>
</div>

View File

@@ -104,7 +104,6 @@ const BookingDetailPage = () => {
const approvedCount = approvalSteps.filter(
(s) => s.status === "APPROVED",
).length;
const totalSteps = approvalSteps.length;
return (
<div style={detailStyles.page}>

View File

@@ -12,6 +12,7 @@ import {
Box,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
@@ -33,7 +34,10 @@ import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
@@ -48,7 +52,13 @@ const SIGNATURE_FILE_CODES = new Set([
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
const {
data: booking,
isLoading,
isError,
refetch,
isFetching,
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
@@ -61,7 +71,7 @@ export default function BookingRequestDetailPage() {
if (isLoading) {
return (
<Box style={detailStyles.page}>
<PageContainer>
<Center mih="60vh">
<Stack align="center" gap="md">
<Loader color="gray" />
@@ -70,15 +80,21 @@ export default function BookingRequestDetailPage() {
</Text>
</Stack>
</Center>
</Box>
</PageContainer>
);
}
if (isError || !booking) {
return (
<Box style={detailStyles.page}>
<PageContainer>
<Container size="sm" py="xl">
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
<Paper
radius="md"
withBorder
p="xl"
ta="center"
style={detailStyles.card}
>
<Center>
<Box
style={{
@@ -111,7 +127,7 @@ export default function BookingRequestDetailPage() {
</Button>
</Paper>
</Container>
</Box>
</PageContainer>
);
}
@@ -127,87 +143,93 @@ export default function BookingRequestDetailPage() {
booking.status === "APPROVED_PENDING_SIGNATURE";
return (
<Box style={detailStyles.page}>
<Container size="xxl" py="lg">
<Breadcrumbs
items={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
<PageContainer>
<Breadcrumbs
items={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
/>
<Stack gap="lg">
<BookingRequestHero
booking={booking}
customerLabel={row.customerLabel}
onBack={() => navigate("/dashboard/booking-requests")}
onRefresh={() => refetch()}
isFetching={isFetching}
/>
<Stack gap="lg" mt="sm">
<BookingRequestHero
booking={booking}
customerLabel={row.customerLabel}
onBack={() => navigate("/dashboard/booking-requests")}
onRefresh={() => refetch()}
isFetching={isFetching}
/>
<BookingWorkflowStepper
status={booking.status}
title={statusMeta.title}
description={statusMeta.description}
titleColor={statusMeta.color}
/>
<BookingWorkflowStepper
status={booking.status}
title={statusMeta.title}
description={statusMeta.description}
titleColor={statusMeta.color}
/>
{booking.status === "PENDING_CONSOLIDATION" && (
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
{booking.status === "PENDING_CONSOLIDATION" && (
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
<Grid gutter="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
<Grid gap="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={handleDownloadFile}
/>
</Stack>
</Grid.Col>
onDownload={handleDownloadFile}
/>
</Stack>
</Grid.Col>
{/* RIGHT — sticky action / summary rail */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} />
<BookingActionsToolbar booking={booking} mutations={mutations} />
{showContractButton && (
<Button
fullWidth
color="green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
}
>
View & sign contract
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
</Stack>
</Container>
</Box>
{/* RIGHT — sticky action / summary rail */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
/>
<BookingActionsToolbar
booking={booking}
mutations={mutations}
/>
{showContractButton && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View & sign contract
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}

View File

@@ -1,32 +1,45 @@
import {
ActionIcon,
Box,
Button,
Card,
Group,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import {
AlertTriangle,
ArrowRight,
Calendar,
CheckCircle2,
Clock,
LayoutList,
Package,
Plus,
RefreshCw,
Search,
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react";
import {
Container,
Stack,
Group,
Text,
Card,
TextInput,
ActionIcon,
Tabs,
} from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
BookingStatusTabs,
type BookingStatusTabKey,
} from "@/components/bookings/BookingStatusTabs";
import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import {
@@ -36,13 +49,12 @@ import {
} from "@/hooks/bookings/useBookings";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
import {
Badge,
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Badge,
type ColumnDef,
} from "@edr/ui-common";
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
@@ -51,13 +63,25 @@ function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
return match.statuses.join(",");
}
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
type OperationsSubTab = "ready" | "scheduled";
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("all");
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
@@ -251,7 +275,7 @@ export default function BookingRequestsPage() {
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{row.original.scheduledDate}
{formatDate(row.original.scheduledDate)}
</span>
),
},
@@ -262,29 +286,9 @@ export default function BookingRequestsPage() {
<BookingPriorityBadge score={row.original.priorityScore} />
),
},
{
id: "amount",
header: () => (
<span className={bookingTable.headerCell}>Amount</span>
),
cell: ({ row }) => {
const b = row.original;
return (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{b.paymentCurrency}{" "}
{b.totalAmount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</span>
);
},
},
{
id: "actions",
size: 140,
header: () => (
<span className={bookingTable.headerCell}>Actions</span>
),
cell: ({ row }) => (
<BookingActionsMenu
row={row.original}
@@ -296,18 +300,60 @@ export default function BookingRequestsPage() {
];
return (
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Booking requests"
subtitle="Review, approve, and schedule freight booking requests."
action={
<>
<Button
color="edr-green"
leftSection={<Plus size={18} />}
onClick={() => navigate("/dashboard/booking-requests/new")}
>
Create booking
</Button>
<Button
variant="default"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={handleRefresh}
>
Refresh
</Button>
</>
}
/>
<Stack gap="lg" mt="md">
<BookingRequestsHeader
metrics={metrics}
tabs={tabCounts}
<KpiStrip
loading={summaryLoading}
isFetching={isFetching}
onCreate={() => navigate("/dashboard/booking-requests/new")}
onRefresh={handleRefresh}
items={[
{
label: "In queue",
value: metrics?.inQueue ?? 0,
icon: LayoutList,
color: "edr-green",
},
{
label: "Needs action",
value: metrics?.needsAction ?? 0,
icon: Clock,
color: "yellow",
},
{
label: "Urgent",
value: metrics?.urgent ?? 0,
icon: AlertTriangle,
color: "red",
},
{
label: "Completed",
value: tabCounts?.completed ?? 0,
icon: CheckCircle2,
color: "edr-green",
},
]}
/>
<BookingStatusTabs
@@ -319,83 +365,81 @@ export default function BookingRequestsPage() {
counts={tabCounts}
/>
<Card
p="md"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
{isOperationsTab ? (
<Stack gap="md">
<Tabs
value={operationsSubTab}
onChange={(value) =>
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
}
>
<Tabs.List>
<Tabs.Tab value="ready">Ready to Load</Tabs.Tab>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs.List>
</Tabs>
{isError ? (
<BookingTableEmpty
isError
hasSearch={false}
onRetry={handleRefresh}
/>
) : operationsSubTab === "ready" ? (
<OperationsBookingQueue
bookings={rows}
isLoading={isLoading}
onAllocate={handleAllocateFromQueue}
/>
) : (
<OperationsScheduledBookings
bookings={rows}
isLoading={isLoading}
/>
)}
</Stack>
<Box px="md" pb="md">
<Stack gap="md">
<Tabs
value={operationsSubTab}
onChange={(value) =>
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
}
>
<Tabs.List>
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs.List>
</Tabs>
{isError ? (
<BookingTableEmpty
isError
hasSearch={false}
onRetry={handleRefresh}
/>
) : operationsSubTab === "ready" ? (
<OperationsBookingQueue
bookings={rows}
isLoading={isLoading}
onAllocate={handleAllocateFromQueue}
/>
) : (
<OperationsScheduledBookings
bookings={rows}
isLoading={isLoading}
/>
)}
</Stack>
</Box>
) : showEmpty ? (
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
onRetry={handleRefresh}
/>
<Box px="md" pb="md">
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
onRetry={handleRefresh}
/>
</Box>
) : (
<div style={{ overflowX: "auto" }}>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
@@ -413,16 +457,10 @@ export default function BookingRequestsPage() {
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</div>
</Box>
)}
</Stack>
</Card>
@@ -440,7 +478,6 @@ export default function BookingRequestsPage() {
initialBookingIds={allocateIds}
/>
) : null}
</Container>
</div>
</PageContainer>
);
}

View File

@@ -1,7 +1,3 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import {
ActionIcon,
Badge,
@@ -23,6 +19,8 @@ import {
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
AlertTriangle,
ArrowLeft,
@@ -40,14 +38,16 @@ import {
Trash2,
Weight,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
import { api } from "@/auth/http";
import { unwrap } from "@/utils/endpoint";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { URL_CONSTANTS } from "@/constants/URLS";
import { api as appApi } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { unwrap } from "@/utils/endpoint";
interface CompanyOption {
id: string;
@@ -147,7 +147,7 @@ function FormSection({
icon: Icon,
title,
subtitle,
accent = "green",
accent = "edr-green",
right,
children,
}: {
@@ -234,9 +234,11 @@ export default function NewBookingPage() {
// Day-level pool: fetch only the days that have a departure on the route (no
// train, no capacity). The batch engine assigns the train after booking.
const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
originYardId,
destinationYardId,
const { data: availableDays, isLoading: daysLoading } = useQuery(
appApi.trainScheduling.availableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: Boolean(originYardId && destinationYardId),
}),
);
const dayOptions = (availableDays ?? []).map((day) => ({
value: day,
@@ -392,11 +394,11 @@ export default function NewBookingPage() {
</Button>
</Group>
<Grid gutter="lg" mt="lg">
<Grid gap="lg" mt="lg">
{/* LEFT — form */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<FormSection icon={Layers} title="Booking type" subtitle="Who is booking and what kind of freight" accent="green">
<FormSection icon={Layers} title="Booking type" subtitle="Who is booking and what kind of freight" accent="edr-green">
<Stack gap="md">
<Switch
label="Government booking"
@@ -453,7 +455,6 @@ export default function NewBookingPage() {
value={originYardId}
onChange={(v) => {
setOriginYardId(v);
setTrainScheduleId(null);
}}
searchable
disabled={isLoading}
@@ -661,7 +662,7 @@ export default function NewBookingPage() {
placeholder="Select bulk cargo type"
data={cargoData}
value={cargoTypeId}
onChange={setCargoTypeId}
onChange={(value) => setCargoTypeId(value as string | null)}
searchable
disabled={isLoading}
/>
@@ -742,7 +743,7 @@ export default function NewBookingPage() {
<Box style={{ position: "sticky", top: 16 }}>
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group gap="sm" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="green">
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
<Weight size={17} />
</ThemeIcon>
<Text fw={700}>Summary</Text>

View File

@@ -1,12 +1,584 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import {
ActionIcon,
Box,
Button,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Stack,
Tabs,
Text,
} from "@mantine/core";
import {
ArrowLeft,
ArrowRight,
Banknote,
Download,
FileText,
IdCard,
LayoutGrid,
Package,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
const CustomerDetailPage = () => {
import {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
TableCard,
formatBytes,
formatDate,
formatMoney,
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type {
CompanyProfile,
CustomerBooking,
CustomerDocument,
CustomerPayment,
} from "@/types/customer";
import { DataTable, type ColumnDef } from "@edr/ui-common";
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
<FeaturePlaceholder
title="Customer Detail"
description="View customer profile details, active shipments, and internal account notes."
/>
<Stack gap={2}>
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
{label}
</Text>
<Text size="sm" c="edr-text">
{value && value.trim() ? value : "—"}
</Text>
</Stack>
);
};
}
export default CustomerDetailPage;
function tableStatus(query: { isLoading: boolean; isError: boolean }) {
return query.isLoading ? "loading" : query.isError ? "error" : "success";
}
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const bookingsQuery = useQuery(
api.customers.bookings.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const documentsQuery = useQuery(
api.customers.documents.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const paymentsQuery = useQuery(
api.customers.payments.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
const totalPaid = useMemo(
() =>
payments
.filter((p) => p.status === "success")
.reduce((sum, p) => sum + p.amount, 0),
[payments],
);
const paidCurrency = payments[0]?.currency ?? "ETB";
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
() => [
{
id: "type",
header: "Role",
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
},
{
id: "reference",
header: "Reference",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
),
},
{
id: "businessLicense",
header: "Business license",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.businessLicense || "—"}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => <ProfileStatusBadge status={row.original.status} />,
},
{
id: "createdAt",
header: "Registered",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
{
id: "actions",
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<ProfileApprovalActions
profileId={row.original.id}
status={row.original.status}
/>
),
},
],
[],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
() => [
{
id: "reference",
header: "Booking",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
),
},
{
id: "route",
header: "Route",
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" c="edr-text">
{b.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-edr-muted" />
<Text size="sm" c="edr-text">
{b.destinationLabel}
</Text>
</Group>
);
},
},
{
id: "type",
header: "Type",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.tradeDirection)} ·{" "}
{humanize(row.original.freightType)}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(row.original.totalAmount, row.original.currency)}
</Text>
),
},
{
id: "createdAt",
header: "Created",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
],
[],
);
const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
() => [
{
id: "name",
header: "Document",
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<FileText size={16} className="shrink-0 text-edr-muted" />
<Text size="sm" c="edr-text" truncate>
{row.original.name}
</Text>
</Group>
),
},
{
id: "code",
header: "Type",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.code)}
</Text>
),
},
{
id: "size",
header: "Size",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatBytes(row.original.size)}
</Text>
),
},
{
id: "uploadedAt",
header: "Uploaded",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.uploadedAt)}
</Text>
),
},
{
id: "actions",
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<ActionIcon
component="a"
href={row.original.url ?? "#"}
variant="subtle"
color="gray"
aria-label="Download"
data-stop-row-click
>
<Download size={16} />
</ActionIcon>
),
},
],
[],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
() => [
{
id: "reference",
header: "Payment",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
),
},
{
id: "booking",
header: "Booking",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.bookingReference}
</Text>
),
},
{
id: "method",
header: "Method",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.method)}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => <PaymentStatusBadge status={row.original.status} />,
},
{
id: "paidAt",
header: "Paid",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.paidAt)}
</Text>
),
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(row.original.amount, row.original.currency)}
</Text>
),
},
],
[],
);
if (isLoading) {
return (
<Center mih="60vh">
<Loader />
</Center>
);
}
if (!company) {
return (
<Container size="sm" py="xl">
<Stack align="center" gap="md">
<Text fw={700}>Customer not found</Text>
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/customers")}
>
Back to customers
</Button>
</Stack>
</Container>
);
}
return (
<PageContainer>
<PageHeader
breadcrumbs={[
{ label: "Customers", href: "/dashboard/customers" },
{ label: company.name },
]}
backTo="/dashboard/customers"
title={company.name}
subtitle={`TIN ${company.tin}${
company.country ? ` · ${company.country}` : ""
}`}
meta={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} />
</Group>
}
/>
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="bookings" leftSection={<Package size={16} />}>
Bookings
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={16} />}>
Documents
</Tabs.Tab>
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
Payments
</Tabs.Tab>
</Tabs.List>
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<KpiStrip
items={[
{
label: "Profiles",
value: company.companyProfiles.length,
icon: IdCard,
color: "edr-green",
},
{
label: "Pending approval",
value: company.companyProfiles.filter(
(p) => p.status === "pending",
).length,
icon: IdCard,
color: "yellow",
},
{
label: "Bookings",
value: bookings.length,
icon: Package,
color: "blue",
},
{
label: "Total paid",
value: formatMoney(totalPaid, paidCurrency),
icon: Banknote,
color: "edr-green",
},
]}
/>
<Card>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Company information
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="TIN" value={company.tin} />
<InfoField label="VAT number" value={company.vatNumber} />
<InfoField label="FAN number" value={company.fanNumber} />
<InfoField label="Country" value={company.country} />
<InfoField label="Address" value={company.address} />
<InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} />
<InfoField label="Phone" value={company.phone} />
<Box />
<InfoField
label="Contact person"
value={company.contactPersonName}
/>
<InfoField
label="Contact phone"
value={company.contactPersonPhone}
/>
<Box />
<InfoField
label="General manager"
value={company.generalManagerName}
/>
<InfoField
label="GM email"
value={company.generalManagerEmail}
/>
<InfoField
label="GM phone"
value={company.generalManagerPhone}
/>
</SimpleGrid>
</Stack>
</Card>
<Card>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600} c="edr-text">
Role profiles
</Text>
<ProfileChips profiles={company.companyProfiles} />
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={860}>
<DataTable
columns={profileColumns}
data={company.companyProfiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
</Box>
</Stack>
</Card>
</Stack>
</Tabs.Panel>
{/* BOOKINGS */}
<Tabs.Panel value="bookings" pt="lg">
<TableCard minWidth={900}>
<DataTable
columns={bookingColumns}
data={bookings}
status={tableStatus(bookingsQuery)}
emptyMessage="No bookings for this customer."
containerClassName="border-0 shadow-none bg-transparent"
error={
bookingsQuery.isError
? {
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
</Tabs.Panel>
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<TableCard minWidth={760}>
<DataTable
columns={documentColumns}
data={documents}
status={tableStatus(documentsQuery)}
emptyMessage="No documents uploaded."
containerClassName="border-0 shadow-none bg-transparent"
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
</Tabs.Panel>
{/* PAYMENTS */}
<Tabs.Panel value="payments" pt="lg">
<TableCard minWidth={880}>
<DataTable
columns={paymentColumns}
data={payments}
status={tableStatus(paymentsQuery)}
emptyMessage="No payments recorded."
containerClassName="border-0 shadow-none bg-transparent"
error={
paymentsQuery.isError
? {
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
</Tabs.Panel>
</Tabs>
</PageContainer>
);
}

View File

@@ -1,12 +1,266 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import {
ActionIcon,
Box,
Card,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
CheckCircle2,
Clock,
Mail,
Phone,
RefreshCw,
Search,
ShieldOff,
Users,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
const CustomersPage = () => {
return (
<FeaturePlaceholder
title="Customers"
description="Review and maintain customer records, service status, and operational context."
/>
import {
CompanyStatusBadge,
CompanyTypeBadge,
ProfileChips,
formatDate,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company } from "@/types/customer";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
export default function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
);
};
export default CustomersPage;
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.customers.list.queryOptions({ input: { filter } }),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Company>[] = useMemo(
() => [
{
id: "company",
header: "Company",
cell: ({ row }) => {
const c = row.original;
return (
<Group gap="sm" wrap="nowrap">
<Box
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
style={{
background: "var(--mantine-color-edr-green-1)",
color: "var(--mantine-color-edr-green-7)",
}}
>
<Building2 size={18} strokeWidth={1.9} />
</Box>
<div style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={600} c="edr-text" truncate>
{c.name}
</Text>
<CompanyTypeBadge type={c.type} />
</Group>
<Text size="xs" c="dimmed">
TIN {c.tin}
{c.country ? ` · ${c.country}` : ""}
</Text>
</div>
</Group>
);
},
},
{
id: "profiles",
header: "Profiles",
cell: ({ row }) => <ProfileChips profiles={row.original.companyProfiles} />,
},
{
id: "status",
header: "Status",
cell: ({ row }) => <CompanyStatusBadge status={row.original.status} />,
},
{
id: "contact",
header: "Contact",
cell: ({ row }) => {
const c = row.original;
return (
<Stack gap={2}>
{c.contactPersonName ? (
<Text size="sm" c="edr-text">
{c.contactPersonName}
</Text>
) : null}
{c.phone ? (
<Text
size="xs"
c="dimmed"
className="inline-flex items-center gap-1"
>
<Phone size={12} /> {c.phone}
</Text>
) : null}
{c.email ? (
<Text
size="xs"
c="dimmed"
className="inline-flex items-center gap-1"
truncate
>
<Mail size={12} /> {c.email}
</Text>
) : null}
</Stack>
);
},
},
{
id: "created",
header: "Registered",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
],
[],
);
return (
<PageContainer>
<PageHeader
title="Customers"
subtitle="Companies registered for freight services, with their role profiles."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<KpiStrip
items={[
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
{
label: "Blacklisted",
value: stats?.blacklisted ?? "—",
icon: ShieldOff,
color: "red",
},
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by company, TIN, email or profile reference…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={980}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
debouncedQuery
? "No companies match your search."
: "No companies yet."
}
error={
isError
? {
message: "Failed to load customers.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -93,7 +93,7 @@ const OverviewPage = () => {
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
if (!summary?.kpis) return 0;
const group = summary.kpis[tab.kpiKey] as Record<string, number>;
const group = summary.kpis[tab.kpiKey] as unknown as Record<string, number>;
return group[tab.metricKey] ?? 0;
};
@@ -132,7 +132,7 @@ const OverviewPage = () => {
value={activeTab}
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
variant="pills"
color="green"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
@@ -151,12 +151,7 @@ const OverviewPage = () => {
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
styles={
isActive
? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } }
: undefined
}
color={isActive ? "edr-green" : "gray"}
>
{getTabBadge(tab)}
</Badge>

View File

@@ -140,30 +140,38 @@ const PositionTypesPage = () => {
const [loadingOrganizations, setLoadingOrganizations] = useState(true);
const [loadingUnits, setLoadingUnits] = useState(false);
const [loadingPositionTypes, setLoadingPositionTypes] = useState(false);
const [loadingPermissionsCatalog, setLoadingPermissionsCatalog] = useState(false);
const [loadingPermissionsCatalog, setLoadingPermissionsCatalog] =
useState(false);
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedPositionType, setSelectedPositionType] = useState<PositionTypeRecord | null>(null);
const [positionTypePermissions, setPositionTypePermissions] = useState<PermissionRecord[]>([]);
const [selectedPositionType, setSelectedPositionType] =
useState<PositionTypeRecord | null>(null);
const [allPermissions, setAllPermissions] = useState<PermissionRecord[]>([]);
const [permissionsLoading, setPermissionsLoading] = useState(false);
const [permissionsError, setPermissionsError] = useState<string | null>(null);
const [permissionSearch, setPermissionSearch] = useState("");
const [selectedPermissionIds, setSelectedPermissionIds] = useState<string[]>([]);
const [selectedPermissionIds, setSelectedPermissionIds] = useState<string[]>(
[],
);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [createPermissionSearch, setCreatePermissionSearch] = useState("");
const [createPermissionIds, setCreatePermissionIds] = useState<string[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
const [editForm, setEditForm] = useState<PositionTypeEditFormState>(emptyEditForm);
const [editForm, setEditForm] =
useState<PositionTypeEditFormState>(emptyEditForm);
const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin"));
const isSuperAdmin = Boolean(
user?.roles?.some((role) => role.key === "super_admin"),
);
const allowedOrgIds = useMemo(
() =>
new Set(
(user?.employee ?? [])
.map((employee) => employee.organizationId)
.filter((organizationId): organizationId is string => Boolean(organizationId)),
.filter((organizationId): organizationId is string =>
Boolean(organizationId),
),
),
[user?.employee],
);
@@ -173,14 +181,21 @@ const PositionTypesPage = () => {
return organizations;
}
return organizations.filter((organization) => allowedOrgIds.has(organization.id));
return organizations.filter((organization) =>
allowedOrgIds.has(organization.id),
);
}, [allowedOrgIds, isSuperAdmin, organizations]);
const selectedOrganization =
visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null;
visibleOrganizations.find(
(organization) => organization.id === selectedOrgId,
) ?? null;
const selectedUnit = units.find((unit) => unit.id === selectedUnitId) ?? null;
const availableCopySources = useMemo(
() => positionTypes.filter((positionType) => positionType.id !== selectedPositionType?.id),
() =>
positionTypes.filter(
(positionType) => positionType.id !== selectedPositionType?.id,
),
[positionTypes, selectedPositionType?.id],
);
const filteredPermissions = useMemo(() => {
@@ -191,8 +206,13 @@ const PositionTypesPage = () => {
return true;
}
const label = getLocaleLabel(permission.name, permission.key).toLowerCase();
return label.includes(query) || permission.key.toLowerCase().includes(query);
const label = getLocaleLabel(
permission.name,
permission.key,
).toLowerCase();
return (
label.includes(query) || permission.key.toLowerCase().includes(query)
);
});
}, [allPermissions, permissionSearch]);
const filteredCreatePermissions = useMemo(() => {
@@ -203,18 +223,29 @@ const PositionTypesPage = () => {
return true;
}
const label = getLocaleLabel(permission.name, permission.key).toLowerCase();
return label.includes(query) || permission.key.toLowerCase().includes(query);
const label = getLocaleLabel(
permission.name,
permission.key,
).toLowerCase();
return (
label.includes(query) || permission.key.toLowerCase().includes(query)
);
});
}, [allPermissions, createPermissionSearch]);
const allFilteredPermissionIds = filteredPermissions.map((permission) => permission.id);
const allFilteredCreatePermissionIds = filteredCreatePermissions.map((permission) => permission.id);
const allFilteredPermissionIds = filteredPermissions.map(
(permission) => permission.id,
);
const allFilteredCreatePermissionIds = filteredCreatePermissions.map(
(permission) => permission.id,
);
const areAllFilteredPermissionsSelected =
allFilteredPermissionIds.length > 0 &&
allFilteredPermissionIds.every((id) => selectedPermissionIds.includes(id));
const areAllFilteredCreatePermissionsSelected =
allFilteredCreatePermissionIds.length > 0 &&
allFilteredCreatePermissionIds.every((id) => createPermissionIds.includes(id));
allFilteredCreatePermissionIds.every((id) =>
createPermissionIds.includes(id),
);
const loadPositionTypes = async (unitId: string) => {
const response = await api.get<ListResponse<PositionTypeRecord>>(
@@ -247,7 +278,8 @@ const PositionTypesPage = () => {
setErrorMessage(null);
try {
const response = await api.get<ListResponse<OrganizationRecord>>("/organizations");
const response =
await api.get<ListResponse<OrganizationRecord>>("/organizations");
if (!isMounted) {
return;
@@ -261,7 +293,7 @@ const PositionTypesPage = () => {
setErrorMessage(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to load organizations."
? (error.response?.data?.message ?? "Unable to load organizations.")
: "Unable to load organizations.",
);
} finally {
@@ -285,12 +317,15 @@ const PositionTypesPage = () => {
setLoadingPermissionsCatalog(true);
try {
const response = await api.get<ListResponse<PermissionRecord>>("/permissions", {
params: {
skip: 0,
take: 2000,
const response = await api.get<ListResponse<PermissionRecord>>(
"/permissions",
{
params: {
skip: 0,
take: 2000,
},
},
});
);
if (!isMounted) {
return;
@@ -326,7 +361,12 @@ const PositionTypesPage = () => {
return;
}
if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) {
if (
selectedOrgId &&
visibleOrganizations.some(
(organization) => organization.id === selectedOrgId,
)
) {
return;
}
@@ -350,7 +390,9 @@ const PositionTypesPage = () => {
setPositionTypes([]);
try {
const response = await api.get<ListResponse<UnitRecord>>(`/units/list/${selectedOrgId}`);
const response = await api.get<ListResponse<UnitRecord>>(
`/units/list/${selectedOrgId}`,
);
const items = getItems(response.data);
if (!isMounted) {
@@ -367,7 +409,7 @@ const PositionTypesPage = () => {
setUnits([]);
setErrorMessage(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to load units."
? (error.response?.data?.message ?? "Unable to load units.")
: "Unable to load units.",
);
} finally {
@@ -412,7 +454,8 @@ const PositionTypesPage = () => {
setPositionTypes([]);
setErrorMessage(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to load position types."
? (error.response?.data?.message ??
"Unable to load position types.")
: "Unable to load position types.",
);
} finally {
@@ -431,7 +474,6 @@ const PositionTypesPage = () => {
useEffect(() => {
if (!selectedPositionType) {
setPositionTypePermissions([]);
setSelectedPermissionIds([]);
setEditForm(emptyEditForm);
setPermissionsError(null);
@@ -453,23 +495,24 @@ const PositionTypesPage = () => {
setPermissionsError(null);
try {
const items = await loadPermissionsForPositionType(selectedPositionType.id);
const items = await loadPermissionsForPositionType(
selectedPositionType.id,
);
if (!isMounted) {
return;
}
setPositionTypePermissions(items);
setSelectedPermissionIds(items.map((permission) => permission.id));
} catch (error) {
if (!isMounted) {
return;
}
setPositionTypePermissions([]);
setPermissionsError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to load position type permissions."
? (error.response?.data?.message ??
"Unable to load position type permissions.")
: "Unable to load position type permissions.",
);
} finally {
@@ -499,13 +542,16 @@ const PositionTypesPage = () => {
setPositionTypes(items);
if (selectedPositionType) {
const nextSelected = items.find((item) => item.id === selectedPositionType.id) ?? selectedPositionType;
const nextSelected =
items.find((item) => item.id === selectedPositionType.id) ??
selectedPositionType;
setSelectedPositionType(nextSelected);
}
} catch (error) {
setErrorMessage(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to refresh position types."
? (error.response?.data?.message ??
"Unable to refresh position types.")
: "Unable to refresh position types.",
);
} finally {
@@ -522,7 +568,10 @@ const PositionTypesPage = () => {
};
const handleSelectCopySource = async (positionTypeId: string) => {
setCreateForm((current) => ({ ...current, copyPermissionFromId: positionTypeId }));
setCreateForm((current) => ({
...current,
copyPermissionFromId: positionTypeId,
}));
if (!positionTypeId) {
setCreatePermissionIds([]);
@@ -530,18 +579,23 @@ const PositionTypesPage = () => {
}
try {
const copiedPermissions = await loadPermissionsForPositionType(positionTypeId);
setCreatePermissionIds(copiedPermissions.map((permission) => permission.id));
const copiedPermissions =
await loadPermissionsForPositionType(positionTypeId);
setCreatePermissionIds(
copiedPermissions.map((permission) => permission.id),
);
} catch (error) {
setCreateError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to copy permissions."
? (error.response?.data?.message ?? "Unable to copy permissions.")
: "Unable to copy permissions.",
);
}
};
const handleCreatePositionType = async (event: React.FormEvent<HTMLFormElement>) => {
const handleCreatePositionType = async (
event: React.FormEvent<HTMLFormElement>,
) => {
event.preventDefault();
if (!selectedUnitId) {
@@ -576,7 +630,7 @@ const PositionTypesPage = () => {
} catch (error) {
setCreateError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to create position type."
? (error.response?.data?.message ?? "Unable to create position type.")
: "Unable to create position type.",
);
} finally {
@@ -611,21 +665,26 @@ const PositionTypesPage = () => {
const [refreshedPermissions, refreshedPositionTypes] = await Promise.all([
loadPermissionsForPositionType(selectedPositionType.id),
selectedUnitId ? loadPositionTypes(selectedUnitId) : Promise.resolve(positionTypes),
selectedUnitId
? loadPositionTypes(selectedUnitId)
: Promise.resolve(positionTypes),
]);
setPositionTypePermissions(refreshedPermissions);
setSelectedPermissionIds(refreshedPermissions.map((permission) => permission.id));
setSelectedPermissionIds(
refreshedPermissions.map((permission) => permission.id),
);
setPositionTypes(refreshedPositionTypes);
const refreshedSelected = refreshedPositionTypes.find((item) => item.id === selectedPositionType.id);
const refreshedSelected = refreshedPositionTypes.find(
(item) => item.id === selectedPositionType.id,
);
if (refreshedSelected) {
setSelectedPositionType(refreshedSelected);
}
} catch (error) {
setPermissionsError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to update position type."
? (error.response?.data?.message ?? "Unable to update position type.")
: "Unable to update position type.",
);
} finally {
@@ -633,34 +692,6 @@ const PositionTypesPage = () => {
}
};
const handleSavePermissions = async () => {
if (!selectedPositionType) {
return;
}
setSubmitting(true);
setPermissionsError(null);
try {
await api.post("/position-type-permissions/assign-seconds-for-first", {
firstId: selectedPositionType.id,
secondIds: selectedPermissionIds,
});
const items = await loadPermissionsForPositionType(selectedPositionType.id);
setPositionTypePermissions(items);
setSelectedPermissionIds(items.map((permission) => permission.id));
} catch (error) {
setPermissionsError(
isAxiosError(error)
? error.response?.data?.message ?? "Unable to update position type permissions."
: "Unable to update position type permissions.",
);
} finally {
setSubmitting(false);
}
};
return (
<section className="p-6">
<div className="space-y-6 rounded-2xl border border-border bg-card p-8 shadow-sm">
@@ -670,9 +701,12 @@ const PositionTypesPage = () => {
</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold text-foreground">Position Type</h1>
<h1 className="text-3xl font-semibold text-foreground">
Position Type
</h1>
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
Browse position types for a selected organization unit, add new ones, and manage their permissions.
Browse position types for a selected organization unit, add new
ones, and manage their permissions.
</p>
</div>
<div className="flex items-center gap-2">
@@ -712,7 +746,13 @@ const PositionTypesPage = () => {
disabled={loadingOrganizations || !visibleOrganizations.length}
>
<SelectTrigger className="w-full rounded-xl bg-background">
<SelectValue placeholder={loadingOrganizations ? "Loading organizations..." : "Select organization"} />
<SelectValue
placeholder={
loadingOrganizations
? "Loading organizations..."
: "Select organization"
}
/>
</SelectTrigger>
<SelectContent>
{visibleOrganizations.map((organization) => (
@@ -734,7 +774,11 @@ const PositionTypesPage = () => {
disabled={!selectedOrgId || loadingUnits || !units.length}
>
<SelectTrigger className="w-full rounded-xl bg-background">
<SelectValue placeholder={loadingUnits ? "Loading units..." : "Select unit"} />
<SelectValue
placeholder={
loadingUnits ? "Loading units..." : "Select unit"
}
/>
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
@@ -800,7 +844,9 @@ const PositionTypesPage = () => {
<td className="px-4 py-3 font-medium text-foreground">
{getLocaleLabel(positionType.name, positionType.key)}
</td>
<td className="px-4 py-3 font-mono text-muted-foreground">{positionType.key}</td>
<td className="px-4 py-3 font-mono text-muted-foreground">
{positionType.key}
</td>
<td className="px-4 py-3 text-muted-foreground">
{positionType.isSystem ? "System" : "Unit"}
</td>
@@ -833,7 +879,10 @@ const PositionTypesPage = () => {
<DialogHeader>
<DialogTitle>
{selectedPositionType
? getLocaleLabel(selectedPositionType.name, selectedPositionType.key)
? getLocaleLabel(
selectedPositionType.name,
selectedPositionType.key,
)
: "Position type details"}
</DialogTitle>
<DialogDescription>
@@ -851,7 +900,10 @@ const PositionTypesPage = () => {
Position type
</p>
<p className="mt-2 text-sm font-semibold text-foreground">
{getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}
{getLocaleLabel(
selectedPositionType.name,
selectedPositionType.key,
)}
</p>
</div>
<div>
@@ -883,23 +935,33 @@ const PositionTypesPage = () => {
<div className="space-y-3">
<div className="grid gap-4 rounded-2xl border border-border bg-muted/40 p-4 sm:grid-cols-2">
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">English name</span>
<span className="font-medium text-foreground">
English name
</span>
<input
className={inputClassName}
value={editForm.nameEn}
onChange={(event) =>
setEditForm((current) => ({ ...current, nameEn: event.target.value }))
setEditForm((current) => ({
...current,
nameEn: event.target.value,
}))
}
disabled={selectedPositionType.isSystem}
/>
</label>
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">Amharic name</span>
<span className="font-medium text-foreground">
Amharic name
</span>
<input
className={inputClassName}
value={editForm.nameAm}
onChange={(event) =>
setEditForm((current) => ({ ...current, nameAm: event.target.value }))
setEditForm((current) => ({
...current,
nameAm: event.target.value,
}))
}
disabled={selectedPositionType.isSystem}
/>
@@ -910,20 +972,26 @@ const PositionTypesPage = () => {
className={inputClassName}
value={editForm.key}
onChange={(event) =>
setEditForm((current) => ({ ...current, key: event.target.value }))
setEditForm((current) => ({
...current,
key: event.target.value,
}))
}
disabled={selectedPositionType.isSystem}
/>
</label>
{selectedPositionType.isSystem ? (
<p className="text-xs text-muted-foreground sm:col-span-2">
System position types keep their name and key, but you can still manage permissions here.
System position types keep their name and key, but you can
still manage permissions here.
</p>
) : null}
</div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-foreground">Permissions</h2>
<h2 className="text-sm font-semibold text-foreground">
Permissions
</h2>
<div className="rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{selectedPermissionIds.length} permissions selected
</div>
@@ -942,7 +1010,9 @@ const PositionTypesPage = () => {
<input
className={inputClassName}
value={permissionSearch}
onChange={(event) => setPermissionSearch(event.target.value)}
onChange={(event) =>
setPermissionSearch(event.target.value)
}
placeholder="Search permissions by name or key"
/>
@@ -960,7 +1030,9 @@ const PositionTypesPage = () => {
);
}}
/>
<span className="font-medium text-foreground">Select all</span>
<span className="font-medium text-foreground">
Select all
</span>
</label>
{loadingPermissionsCatalog ? (
@@ -976,20 +1048,29 @@ const PositionTypesPage = () => {
>
<input
type="checkbox"
checked={selectedPermissionIds.includes(permission.id)}
checked={selectedPermissionIds.includes(
permission.id,
)}
onChange={(event) => {
setSelectedPermissionIds((current) =>
event.target.checked
? [...current, permission.id]
: current.filter((item) => item !== permission.id),
: current.filter(
(item) => item !== permission.id,
),
);
}}
/>
<div className="min-w-0 leading-4">
<div className="truncate font-medium text-foreground">
{getLocaleLabel(permission.name, permission.key)}
{getLocaleLabel(
permission.name,
permission.key,
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{permission.key}
</div>
<div className="truncate text-xs text-muted-foreground">{permission.key}</div>
</div>
</label>
))}
@@ -1030,30 +1111,44 @@ const PositionTypesPage = () => {
<DialogHeader>
<DialogTitle>Create position type</DialogTitle>
<DialogDescription>
Add a new position type for the selected unit and optionally copy permissions from an existing one.
Add a new position type for the selected unit and optionally copy
permissions from an existing one.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={(event) => void handleCreatePositionType(event)}>
<form
className="space-y-4"
onSubmit={(event) => void handleCreatePositionType(event)}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">English name</span>
<span className="font-medium text-foreground">
English name
</span>
<input
className={inputClassName}
value={createForm.nameEn}
onChange={(event) =>
setCreateForm((current) => ({ ...current, nameEn: event.target.value }))
setCreateForm((current) => ({
...current,
nameEn: event.target.value,
}))
}
/>
</label>
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">Amharic name</span>
<span className="font-medium text-foreground">
Amharic name
</span>
<input
className={inputClassName}
value={createForm.nameAm}
onChange={(event) =>
setCreateForm((current) => ({ ...current, nameAm: event.target.value }))
setCreateForm((current) => ({
...current,
nameAm: event.target.value,
}))
}
/>
</label>
@@ -1064,13 +1159,18 @@ const PositionTypesPage = () => {
className={inputClassName}
value={createForm.key}
onChange={(event) =>
setCreateForm((current) => ({ ...current, key: event.target.value }))
setCreateForm((current) => ({
...current,
key: event.target.value,
}))
}
/>
</label>
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">Copy permissions from</span>
<span className="font-medium text-foreground">
Copy permissions from
</span>
<Select
value={createForm.copyPermissionFromId || undefined}
onValueChange={(value) => void handleSelectCopySource(value)}
@@ -1091,7 +1191,9 @@ const PositionTypesPage = () => {
<div className="space-y-3 rounded-2xl border border-border bg-background/60 p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-foreground">Permissions</h2>
<h2 className="text-sm font-semibold text-foreground">
Permissions
</h2>
<div className="rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{createPermissionIds.length} selected
</div>
@@ -1100,7 +1202,9 @@ const PositionTypesPage = () => {
<input
className={inputClassName}
value={createPermissionSearch}
onChange={(event) => setCreatePermissionSearch(event.target.value)}
onChange={(event) =>
setCreatePermissionSearch(event.target.value)
}
placeholder="Search permissions by name or key"
/>
@@ -1139,15 +1243,19 @@ const PositionTypesPage = () => {
setCreatePermissionIds((current) =>
event.target.checked
? [...current, permission.id]
: current.filter((item) => item !== permission.id),
);
}}
: current.filter(
(item) => item !== permission.id,
),
);
}}
/>
<div className="min-w-0 leading-4">
<div className="truncate font-medium text-foreground">
{getLocaleLabel(permission.name, permission.key)}
</div>
<div className="truncate text-xs text-muted-foreground">{permission.key}</div>
<div className="truncate text-xs text-muted-foreground">
{permission.key}
</div>
</div>
</label>
))}
@@ -1163,7 +1271,8 @@ const PositionTypesPage = () => {
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700 dark:border-sky-950 dark:bg-sky-950/30 dark:text-sky-300">
<div className="flex items-center gap-2">
<CopyPlus className="h-4 w-4" />
The new position type will inherit permissions from the selected source.
The new position type will inherit permissions from the
selected source.
</div>
</div>
) : null}

View File

@@ -2,7 +2,6 @@ import type { DesignConfig } from "@tria-plc/iamui";
import {
FREIGHT_BRAND,
FREIGHT_BRAND_DARK,
FREIGHT_BRAND_LIGHT,
freightBrand,
} from "@/theme/freight-brand";
@@ -48,7 +47,7 @@ export const iamConfig: DesignConfig = {
},
layout: {
userManagementView: "classic",
showTopBar: true,
showTopBar: true as any,
sidebarWidth: "280px",
sidebarCollapsedWidth: "80px",
headerHeight: "80px",

View File

@@ -15,16 +15,9 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { FileUploadEntity } from "@edr/types/freight";
import { useCreateFileUploadSetting, useUpdateFileUploadSetting } from "@/hooks/useFileUploadSettings";
// import type {
// FileUploadEntity,
// FileUploadSetting,
// } from "@/types/fileUploadSettings";
// import {
// useCreateFileUploadSetting,
// useUpdateFileUploadSetting,
// } from "@/hooks/useFileUploadSettings";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
import { FileUploadSetting } from "@/types/fileUploadSettings";
export interface EditFileUploadSettingDialogProps {
mode?: "create" | "edit";
@@ -32,19 +25,6 @@ export interface EditFileUploadSettingDialogProps {
children: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
// const ENTITIES: FileUploadEntity[] = [
// "customer",
// "booking",
// "consignment",
// "shipment",
// "invoice",
// "train",
// "other",
// ];
export default function EditFileUploadSettingDialog({
mode = "create",
setting,
@@ -61,8 +41,12 @@ export default function EditFileUploadSettingDialog({
const [description, setDescription] = useState(setting?.description ?? "");
const [error, setError] = useState<string | null>(null);
const createMutation = useCreateFileUploadSetting();
const updateMutation = useUpdateFileUploadSetting();
const createMutation = useMutation(
api.fileUploadSettings.create.mutationOptions(),
);
const updateMutation = useMutation(
api.fileUploadSettings.update.mutationOptions(),
);
const pending = createMutation.isPending || updateMutation.isPending;
const reset = () => {

View File

@@ -1,36 +1,49 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
Filter,
ActionIcon,
Badge,
Box,
Button,
Card,
Code,
Group,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
FileUp,
HardDrive,
Layers,
Loader2,
Paperclip,
Pencil,
Plus,
Search,
Settings,
Trash2,
X,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
import { DataTable, type ColumnDef } from "@edr/ui-common";
// import Breadcrumbs from "@/components/Breadcrumbs";
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { getMinFiles } from "@/types/fileUploadSettings";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings";
export default function FileUploadSettingsPage() {
const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useQuery(
const { data, isLoading, isError, error, refetch } = useQuery(
api.fileUploadSettings.list.queryOptions(),
);
const deleteMutation = useDeleteFileUploadSetting();
const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions());
const fileUploadSettings = useMemo(
() => (Array.isArray(data) ? data : []),
@@ -66,397 +79,298 @@ export default function FileUploadSettingsPage() {
0,
);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "File Upload Settings" },
]}
/>
const columns = useMemo<ColumnDef<FileUploadSetting>[]>(() => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "setting",
header: "Setting",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const s = row.original;
return (
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<FileUp size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0, maxWidth: 260 }}>
<Text size="sm" fw={600} lh={1.2} truncate>
{s.label}
</Text>
<Text size="xs" c="dimmed" truncate>
{s.description ?? "No description"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "code",
header: "Code",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <Code>{row.original.code}</Code>,
},
{
id: "entity",
header: "Entity",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge variant="light" color="gray" tt="capitalize">
{row.original.entity ?? "—"}
</Badge>
),
},
{
id: "fields",
header: "Fields",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Paperclip size={15} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
{row.original.fields.length}
</Text>
</Group>
),
},
{
id: "requiredMulti",
header: "Required / Multi",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const required = row.original.fields.filter((f) => f.isRequired).length;
const multi = row.original.fields.filter((f) => f.isMultiple).length;
return (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="edr-green">
{required} required
</Badge>
<Badge variant="light" color="gray">
{multi} multi
</Badge>
</Group>
);
},
},
{
id: "maxSize",
header: "Max size",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const maxSize = Math.max(
0,
...row.original.fields.map((f) => f.maxSizeMb),
);
return (
<Group gap={6} wrap="nowrap">
<HardDrive size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm">{maxSize ? `${maxSize} MB` : "—"}</Text>
</Group>
);
},
},
{
id: "actions",
header: "Actions",
meta: {
headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`,
},
cell: ({ row }) => {
const setting = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ManageFileUploadFieldsDialog setting={setting}>
<Button
variant="default"
size="xs"
leftSection={<Paperclip size={14} />}
>
Fields
</Button>
</ManageFileUploadFieldsDialog>
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
File Upload Settings
</h1>
<p className="mt-1 text-sm text-slate-500">
Define the file inputs every form in the platform should render
required/optional, single/multiple, allowed types and size.
</p>
</div>
<EditFileUploadSettingDialog mode="edit" setting={setting}>
<ActionIcon variant="default" aria-label="Edit setting">
<Pencil size={16} />
</ActionIcon>
</EditFileUploadSettingDialog>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by code, label, or file key..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
/>
</div>
<EditFileUploadSettingDialog mode="create">
<button
type="button"
className="inline-flex w-35 items-center justify-center gap-2 rounded-md bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() => deleteMutation.mutate({ id: setting.id })}
>
<Plus className="h-4 w-4" />
New Setting
</button>
</EditFileUploadSettingDialog>
</div>
</div>
<ActionIcon
variant="default"
color="red"
disabled={deleteMutation.isPending}
aria-label="Delete setting"
>
<Trash2 size={16} />
</ActionIcon>
</DeleteFileUploadSettingDialog>
</Group>
);
},
},
];
}, [deleteMutation]);
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title="Settings"
value={String(fileUploadSettings.length)}
icon={<Settings className="h-5 w-5" />}
/>
<StatCard
title="Total Fields"
value={String(totalFields)}
icon={<Paperclip className="h-5 w-5" />}
/>
<StatCard
title="Required"
value={String(requiredFields)}
icon={<FileUp className="h-5 w-5" />}
/>
<StatCard
title="Multi-file"
value={String(multiFields)}
icon={<Layers className="h-5 w-5" />}
/>
</div>
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
{/* Table */}
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Registered File Upload Groups
</h2>
<p className="text-sm text-slate-500">
Every group a form can reference by code.
</p>
</div>
return (
<PageContainer>
<PageHeader
title="File upload settings"
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
action={
<EditFileUploadSettingDialog mode="create">
<Button leftSection={<Plus size={18} />}>New setting</Button>
</EditFileUploadSettingDialog>
}
/>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<KpiStrip
loading={isLoading}
items={[
{ label: "Settings", value: fileUploadSettings.length, icon: Settings },
{ label: "Total fields", value: totalFields, icon: Paperclip },
{ label: "Required", value: requiredFields, icon: FileUp },
{ label: "Multi-file", value: multiFields, icon: Layers },
]}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[1100px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Setting</th>
<th className="px-6 py-4 font-medium">Code</th>
<th className="px-6 py-4 font-medium">Entity</th>
<th className="px-6 py-4 font-medium">Fields</th>
<th className="px-6 py-4 font-medium">Required / Multi</th>
<th className="px-6 py-4 font-medium">Max Size</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<TextInput
placeholder="Search by code, label, or file key…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
variant="transparent"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ maxWidth: 420 }}
/>
</Box>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#10B981]" />
<p className="mt-2 text-sm text-slate-500">
Loading file upload settings
</p>
</td>
</tr>
) : isError ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<AlertCircle className="mx-auto h-6 w-6 text-red-500" />
<p className="mt-2 text-sm text-red-600">
Failed to load settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</p>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-6 py-12 text-center text-sm text-slate-500"
>
{fileUploadSettings.length === 0
? "No file upload settings yet. Click \"New Setting\" to add one."
: "No file upload settings match your search."}
</td>
</tr>
) : (
filtered.map((setting) => {
const required = setting.fields.filter(
(f: any) => f.isRequired,
).length;
const multi = setting.fields.filter(
(f: any) => f.isMultiple,
).length;
const maxSize = Math.max(
0,
...setting.fields.map((f) => f.maxSizeMb),
);
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={filtered}
status={tableStatus}
error={
isError
? {
message: "Failed to load settings.",
description:
error instanceof Error ? error.message : "Unknown error.",
onRetry: () => void refetch(),
}
: undefined
}
emptyMessage={
query.trim()
? "No file upload settings match your search."
: 'No file upload settings yet. Click "New setting" to add one.'
}
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
/>
</Box>
</Stack>
</Card>
return (
<tr
key={setting.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
<FileUp className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{setting.label}
</p>
<p className="text-xs text-slate-500">
{setting.description ?? "No description"}
</p>
</div>
</div>
</td>
<BehaviorReferenceCard />
</PageContainer>
);
}
<td className="px-6 py-4">
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{setting.code}
</span>
</td>
/**
* Static reference: how `min_files` / `max_files` are derived from the
* Required × Multiple toggles. Documentation aid for whoever wires uploaders.
*/
function BehaviorReferenceCard() {
const rows: { required: boolean; multiple: boolean; min: string; max: string }[] =
[
{ required: false, multiple: false, min: "0", max: "1" },
{ required: true, multiple: false, min: "1", max: "1" },
{ required: false, multiple: true, min: "0", max: "field.maxFiles" },
{ required: true, multiple: true, min: "1", max: "field.maxFiles" },
];
<td className="px-6 py-4">
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600">
{setting.entity ?? "—"}
</span>
</td>
return (
<Card>
<Stack gap="xs">
<Text fw={600}>Required × Multiple behavior</Text>
<Text size="sm" c="dimmed">
Min and max file counts are derived from these two toggles. The
&quot;Max files&quot; you set on a field is only used when{" "}
<Text span fw={600}>
Multiple
</Text>{" "}
is on.
</Text>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-slate-700">
<Paperclip className="h-4 w-4 text-[#10B981]" />
<span className="font-medium">
{setting.fields.length}
</span>
</div>
</td>
<Table mt="sm" striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Required</Table.Th>
<Table.Th>Multiple</Table.Th>
<Table.Th>min_files</Table.Th>
<Table.Th>max_files</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={`${r.required}-${r.multiple}`}>
<Table.Td>
<Badge variant="light" color={r.required ? "edr-green" : "gray"}>
{r.required ? "Required" : "Optional"}
</Badge>
</Table.Td>
<Table.Td>
<Badge variant="light" color={r.multiple ? "edr-green" : "gray"}>
{r.multiple ? "Multiple" : "Single"}
</Badge>
</Table.Td>
<Table.Td>
<Code>{r.min}</Code>
</Table.Td>
<Table.Td>
<Code>{r.max}</Code>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex flex-wrap items-center gap-1">
<Chip>{required} required</Chip>
<Chip muted>{multi} multi</Chip>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-1.5">
<HardDrive className="h-4 w-4 text-slate-400" />
{maxSize ? `${maxSize} MB` : "—"}
</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<ManageFileUploadFieldsDialog setting={setting}>
<button
type="button"
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Paperclip className="h-3.5 w-3.5" />
Fields
</button>
</ManageFileUploadFieldsDialog>
<EditFileUploadSettingDialog
mode="edit"
setting={setting}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Pencil className="h-4 w-4" />
</button>
</EditFileUploadSettingDialog>
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() =>
deleteMutation.mutate(setting.id)
}
>
<button
type="button"
disabled={deleteMutation.isPending}
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteFileUploadSettingDialog>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Behavior reference card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Required × Multiple behavior
</h2>
<p className="mt-1 text-sm text-slate-500">
Min and max file counts are derived from these two toggles. The
"Max Files" you set on a field is only used when{" "}
<span className="font-medium">Multiple</span> is on.
</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Required</th>
<th className="py-2 font-medium">Multiple</th>
<th className="py-2 font-medium">min_files</th>
<th className="py-2 font-medium">max_files</th>
</tr>
</thead>
<tbody>
<BehaviorRow
required={false}
multiple={false}
min="0"
max="1"
/>
<BehaviorRow
required={true}
multiple={false}
min="1"
max="1"
/>
<BehaviorRow
required={false}
multiple={true}
min="0"
max="field.maxFiles"
/>
<BehaviorRow
required={true}
multiple={true}
min="1"
max="field.maxFiles"
/>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-slate-500">
Helpers <span className="font-mono">getMinFiles</span> and{" "}
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
<span className="font-mono">@/types/fileUploadSettings</span> use
them when wiring real uploaders. Example: a field with{" "}
<span className="font-mono">isRequired=false</span>,{" "}
<span className="font-mono">isMultiple=true</span>,{" "}
<span className="font-mono">maxFiles=5</span> gives{" "}
<span className="font-mono">{getMinFiles({
id: "demo",
fileKey: "demo",
fileLabel: "demo",
<Text size="xs" c="dimmed">
Helpers <Code>getMinFiles</Code> and <Code>getEffectiveMaxFiles</Code>{" "}
live in <Code>@/types/fileUploadSettings</Code> use them when wiring
real uploaders. Example: a field with <Code>isRequired=false</Code>,{" "}
<Code>isMultiple=true</Code>, <Code>maxFiles=5</Code> gives min{" "}
<Code>
{getMinFiles({
isRequired: false,
isMultiple: true,
maxFiles: 5,
allowedExtensions: [],
maxSizeMb: 1,
})}</span>
5.
</p>
</div>
</div>
</div>
);
}
function BehaviorRow({
required,
multiple,
min,
max,
}: {
required: boolean;
multiple: boolean;
min: string;
max: string;
}) {
return (
<tr className="border-t border-slate-100">
<td className="py-2.5">
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
</td>
<td className="py-2.5">
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
</td>
<td className="py-2.5 font-mono text-slate-700">{min}</td>
<td className="py-2.5 font-mono text-slate-700">{max}</td>
</tr>
);
}
function Chip({
children,
muted = false,
}: {
children: React.ReactNode;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
}
>
{children}
</span>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>
</div>
})}
</Code>{" "}
5.
</Text>
</Stack>
</Card>
);
}

View File

@@ -19,7 +19,8 @@ import type {
FileUploadSetting,
} from "@/types/fileUploadSettings";
import { getMinFiles } from "@/types/fileUploadSettings";
import { useReplaceFileUploadFields } from "@/hooks/useFileUploadSettings";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
export interface ManageFileUploadFieldsDialogProps {
setting: FileUploadSetting;
@@ -77,7 +78,9 @@ export default function ManageFileUploadFieldsDialog({
const [fields, setFields] = useState<DraftField[]>(seed);
const replaceMutation = useReplaceFileUploadFields();
const replaceMutation = useMutation(
api.fileUploadSettings.replaceFields.mutationOptions(),
);
const update = (i: number, patch: Partial<DraftField>) =>
setFields((prev) =>
@@ -145,7 +148,7 @@ export default function ManageFileUploadFieldsDialog({
}));
replaceMutation.mutate(
{ settingId: setting.id, fields: payload },
{ id: setting.id, fields: payload },
{
onSuccess: () => setOpen(false),
onError: (err) =>

View File

@@ -1,12 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
AlertCircle,
Boxes,
CheckCircle2,
Eye,
Filter,
ListOrdered,
Loader2,
MoreHorizontal,
Pencil,
Plus,
@@ -16,32 +14,31 @@ import {
Sparkles,
Trash2,
} from "lucide-react";
import {
ActionIcon,
Badge,
Button,
Card,
Code,
Group,
Menu,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useDeleteDropdownSetting } from "@/hooks/useDropdownSettings";
import type { DropdownSetting } from "@/types/dropdownSettings";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type ActiveDialog = "edit" | "options" | "delete";
@@ -50,45 +47,22 @@ export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeSetting, setActiveSetting] = useState<DropdownSetting | null>(
null,
);
const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => {
// Defer past the DropdownMenu's close cycle. Radix's modal lock can leave
// `pointer-events: none` on <body> when a menu closes and a dialog opens
// in the same frame — wait two RAFs and then explicitly reset the body
// style so the dialog interior is interactive.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveSetting(setting);
setActiveDialog(dialog);
});
});
setActiveSetting(setting);
setActiveDialog(dialog);
};
const closeDialog = () => {
setActiveDialog(null);
// Keep activeSetting briefly so dialog content doesn't flash empty during
// the close animation; cleared on next open.
};
// Belt-and-suspenders for the Radix pointer-events leak: any time the active
// dialog changes, schedule a body-style cleanup after the next paint.
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const closeDialog = () => setActiveDialog(null);
const { data, isLoading, isError, error } = useQuery(
api.dropdownSettings.list.queryOptions(),
);
const deleteMutation = useDeleteDropdownSetting();
const deleteMutation = useMutation(api.dropdownSettings.remove.mutationOptions());
const dropdownSettings = useMemo<DropdownSetting[]>(
() => (Array.isArray(data) ? data : []),
@@ -138,41 +112,38 @@ export default function DropdownSettingsPage() {
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Settings />
</div>
<div>
<p className="font-medium text-slate-900">{s.label}</p>
<p className="text-xs text-slate-500">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" size={40} radius="xl">
<Settings size={18} />
</ThemeIcon>
<div style={{ minWidth: 0 }}>
<Text fw={500} c="edr-text" truncate>
{s.label}
</Text>
<Text size="xs" c="edr-muted" truncate>
{s.description ?? "No description"}
</p>
</Text>
</div>
</div>
</Group>
);
},
},
{
id: "code",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.code}
</span>
),
cell: ({ row }) => <Code>{row.original.code}</Code>,
},
{
id: "options",
header: "Options",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Boxes />
<span className="font-medium">{s.children?.length ?? 0}</span>
</div>
);
},
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Boxes size={16} className="text-edr-muted" />
<Text size="sm" fw={500} c="edr-text">
{row.original.children?.length ?? 0}
</Text>
</Group>
),
},
{
id: "behavior",
@@ -180,15 +151,21 @@ export default function DropdownSettingsPage() {
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex flex-wrap gap-1">
{s.multiple ? (
<BehaviorChip label="Multi" />
) : (
<BehaviorChip label="Single" muted />
)}
{s.meta?.searchable ? <BehaviorChip label="Searchable" /> : null}
{s.meta?.clearable ? <BehaviorChip label="Clearable" /> : null}
</div>
<Group gap={4} wrap="wrap">
<Badge variant="light" color={s.multiple ? "edr-green" : "gray"}>
{s.multiple ? "Multi" : "Single"}
</Badge>
{s.meta?.searchable ? (
<Badge variant="light" color="edr-green">
Searchable
</Badge>
) : null}
{s.meta?.clearable ? (
<Badge variant="light" color="edr-green">
Clearable
</Badge>
) : null}
</Group>
);
},
},
@@ -196,24 +173,27 @@ export default function DropdownSettingsPage() {
id: "permissions",
header: "Permissions",
cell: ({ row }) => {
const s = row.original;
const perms = s.meta?.permissions ?? [];
const perms = row.original.meta?.permissions ?? [];
if (perms.length === 0) {
return (
<Text size="xs" c="dimmed">
</Text>
);
}
return (
<div className="flex flex-wrap items-center gap-1">
{perms.length === 0 ? (
<span className="text-xs text-slate-400"></span>
) : (
perms.map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Shield />
{p}
</span>
))
)}
</div>
<Group gap={4} wrap="wrap">
{perms.map((p) => (
<Badge
key={p}
variant="light"
color="edr-green"
leftSection={<Shield size={11} />}
>
{p}
</Badge>
))}
</Group>
);
},
},
@@ -223,178 +203,136 @@ export default function DropdownSettingsPage() {
cell: ({ row }) => {
const setting = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("options", setting)}
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal shadow="md" width={180}>
<Menu.Target>
<ActionIcon variant="default" aria-label="Row actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Eye size={15} />}>View</Menu.Item>
<Menu.Item
leftSection={<CheckCircle2 size={15} />}
onClick={() => openDialogFor("options", setting)}
>
<CheckCircle2 />
Options
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("edit", setting)}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Pencil size={15} />}
onClick={() => openDialogFor("edit", setting)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", setting)}
variant="destructive"
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Trash2 size={15} />}
color="red"
onClick={() => openDialogFor("delete", setting)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "Dropdown Settings" },
]}
/>
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Dropdown Settings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage every dynamic dropdown across the platform labels,
options, ordering, and permissions.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search by code, label, description..."
className="pl-8!"
/>
</div>
<EditDropdownSettingDialog mode="create">
<Button>
<Plus />
New Setting
</Button>
</EditDropdownSettingDialog>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Settings"
value={dropdownSettings.length}
icon={<Settings />}
/>
<StatCard
label="Total Options"
value={totalOptions}
icon={<Boxes />}
/>
<StatCard
label="Multi-select"
value={multipleCount}
icon={<ListOrdered />}
/>
<StatCard
label="Searchable"
value={searchableCount}
icon={<Sparkles />}
/>
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load dropdown settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Registered Dropdowns</CardTitle>
<CardDescription>
Every dynamic dropdown the platform reads from.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
<PageContainer>
<PageHeader
title="Dropdown Settings"
subtitle="Manage every dynamic dropdown across the platform — labels, options, ordering, and permissions."
action={
<>
<TextInput
w={{ base: "100%", sm: 280 }}
leftSection={<Search size={16} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
placeholder="Search by code, label, description…"
/>
<Button
leftSection={<Plus size={16} />}
onClick={() => setCreateOpen(true)}
>
New Setting
</Button>
</CardHeader>
</>
}
/>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading dropdown settings
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
<KpiStrip
loading={isLoading}
items={[
{ label: "Settings", value: dropdownSettings.length, icon: Settings },
{ label: "Total Options", value: totalOptions, icon: Boxes },
{ label: "Multi-select", value: multipleCount, icon: ListOrdered },
{ label: "Searchable", value: searchableCount, icon: Sparkles },
]}
/>
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
reliably after a menu item is selected. */}
<Card p={0}>
<Group
justify="space-between"
p="md"
className="border-b border-edr-border"
>
<div>
<Text fw={600} c="edr-text">
Registered Dropdowns
</Text>
<Text size="sm" c="dimmed">
Every dynamic dropdown the platform reads from.
</Text>
</div>
<Button variant="default" size="sm" leftSection={<Filter size={16} />}>
Filter
</Button>
</Group>
<DataTable
columns={columns}
data={paginatedData}
status={status}
error={
isError
? {
message: "Failed to load dropdown settings.",
description:
error instanceof Error ? error.message : undefined,
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none"
footer={DataTableFooter}
/>
</Card>
{/* Create — fully controlled, no shadcn trigger child. */}
<EditDropdownSettingDialog
mode="create"
open={createOpen}
onOpenChange={setCreateOpen}
/>
{/* Controlled row-action dialogs. */}
{activeSetting ? (
<>
<EditDropdownSettingDialog
@@ -414,56 +352,12 @@ export default function DropdownSettingsPage() {
key={`delete-${activeSetting.id}`}
settingLabel={activeSetting.label}
settingCode={activeSetting.code}
onConfirm={() => deleteMutation.mutate(activeSetting.id)}
onConfirm={() => deleteMutation.mutate({ id: activeSetting.id })}
open={activeDialog === "delete"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
</>
) : null}
</div>
);
}
function StatCard({
label,
value,
icon,
}: {
label: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
</CardContent>
</Card>
);
}
function BehaviorChip({
label,
muted = false,
}: {
label: string;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
}
>
{label}
</span>
</PageContainer>
);
}

View File

@@ -20,10 +20,9 @@ import type {
DropdownSetting,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import {
useCreateDropdownSetting,
useUpdateDropdownSetting,
} from "@/hooks/useDropdownSettings";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
export interface EditDropdownSettingDialogProps {
mode?: "create" | "edit";
@@ -76,8 +75,8 @@ export default function EditDropdownSettingDialog({
);
const [error, setError] = useState<string | null>(null);
const createMutation = useCreateDropdownSetting();
const updateMutation = useUpdateDropdownSetting();
const createMutation = useMutation(api.dropdownSettings.create.mutationOptions());
const updateMutation = useMutation(api.dropdownSettings.update.mutationOptions());
const pending = createMutation.isPending || updateMutation.isPending;
const reset = () => {

View File

@@ -18,7 +18,8 @@ import type {
CreateDropdownOptionDto,
DropdownSetting,
} from "@/types/dropdownSettings";
import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
export interface ManageDropdownOptionsDialogProps {
setting: DropdownSetting;
@@ -84,7 +85,9 @@ export default function ManageDropdownOptionsDialog({
const [options, setOptions] = useState<DraftOption[]>(seed);
const replaceMutation = useReplaceDropdownOptions();
const replaceMutation = useMutation(
api.dropdownSettings.replaceOptions.mutationOptions(),
);
const update = (i: number, patch: Partial<DraftOption>) =>
setOptions((prev) =>
@@ -147,7 +150,7 @@ export default function ManageDropdownOptionsDialog({
});
replaceMutation.mutate(
{ settingId: setting.id, options: payload },
{ id: setting.id, options: payload },
{
onSuccess: () => setOpen(false),
onError: (err) =>

View File

@@ -1,70 +1,49 @@
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { api } from '@/services/api';
import {
ActionIcon,
Badge as MantineBadge,
Box,
Button as MantineButton,
Group,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
Select as MantineSelect,
SimpleGrid,
Stack,
Table as MantineTable,
Text,
TextInput,
Title,
ActionIcon,
Box,
Group,
Badge as MantineBadge,
Button as MantineButton,
Select as MantineSelect,
Table as MantineTable,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCargoTypes } from '@/hooks/use-cargo-types';
import { useContainerTypes } from '@/hooks/use-container-types';
import {
useCreateWagonType,
useDeleteWagonType,
useUpdateWagonType,
useWagonTypes,
} from '@/hooks/use-wagon-types';
import { useToast } from '@/hooks/use-toast';
import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes';
import {
useContainers,
useCreateContainer,
useDeleteContainer,
useUpdateContainer,
} from '@/hooks/useContainers';
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
import { useRouteYards } from '@/hooks/useRoutes';
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
import {
useCreateLocomotive,
useDecommissionLocomotive,
useLocomotives,
useUpdateLocomotive,
} from '@/hooks/useLocomotives';
import type { Cargo } from '@/services/cargoService';
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
import type { Container } from '@/services/containerService';
import type { Locomotive } from '@/services/locomotives.service';
import type { Train } from '@/services/trains.service';
import type { Wagon } from '@/services/wagon.service';
import type { WagonType } from '@/services/wagon-types.service';
import type { Wagon } from '@/services/wagon.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
type FormValue = string | number | boolean | string[];
@@ -352,7 +331,7 @@ function FleetCrudPage<T extends { id: string }>({
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={String(column.key)}>
{column.render ? column.render(item) : String((item as Record<string, unknown>)[column.key] ?? '-')}
{column.render ? column.render(item) : String((item as Record<string, unknown>)[String(column.key)] ?? '-')}
</TableCell>
))}
<TableCell>
@@ -431,7 +410,7 @@ function FleetCrudPage<T extends { id: string }>({
...current,
[field.key]: selectedValue,
...(field.onValueChange?.(selectedValue, current) ?? {}),
}))
}) as Record<string, FormValue>)
}
>
<SelectTrigger id={field.key}>
@@ -501,17 +480,11 @@ function FleetCrudPage<T extends { id: string }>({
const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'}</Badge>;
const activeBadge = (isActive?: boolean) => (
<Badge variant={isActive === false ? 'secondary' : 'outline'}>
{isActive === false ? 'Inactive' : 'Active'}
</Badge>
);
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
options.find((option) => option.value === value)?.label ?? value ?? '-';
export function TrainMasterDataPage() {
const query = useTrains();
const query = useQuery(api.trains.list.queryOptions());
return (
<FleetCrudPage<Train>
title="Trains"
@@ -519,9 +492,9 @@ export function TrainMasterDataPage() {
addLabel="Add Train"
data={query.data}
isLoading={query.isLoading}
create={useCreateTrain()}
update={useUpdateTrain()}
remove={useDeleteTrain()}
create={useMutation(api.trains.create.mutationOptions())}
update={useMutation(api.trains.update.mutationOptions())}
remove={useMutation(api.trains.remove.mutationOptions())}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ key: 'code', label: 'Code' },
@@ -546,10 +519,10 @@ export function TrainMasterDataPage() {
}
export function WagonTypesCrudPage() {
const query = useWagonTypes();
const create = useCreateWagonType();
const update = useUpdateWagonType();
const remove = useDeleteWagonType();
const query = useQuery(api.wagonTypes.list.queryOptions());
const create = useMutation(api.wagonTypes.create.mutationOptions());
const update = useMutation(api.wagonTypes.update.mutationOptions());
const remove = useMutation(api.wagonTypes.remove.mutationOptions());
const { toast } = useToast();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
@@ -751,7 +724,7 @@ export function WagonTypesCrudPage() {
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
<MantineTable.Td>
<MantineBadge color={type.isActive === false ? 'gray' : 'green'} variant="light">
<MantineBadge color={type.isActive === false ? 'gray' : 'edr-green'} variant="light">
{type.isActive === false ? 'Inactive' : 'Active'}
</MantineBadge>
</MantineTable.Td>
@@ -896,9 +869,9 @@ export function WagonTypesCrudPage() {
}
export function WagonsCrudPage() {
const query = useWagons();
const { data: wagonTypes = [] } = useWagonTypes();
const { data: yards = [] } = useRouteYards();
const query = useQuery(api.wagons.list.queryOptions({ input: {} }));
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const wagonTypeOptions = wagonTypes.map((type: any) => ({
value: type.id,
label: `${type.code} - ${type.name}`,
@@ -914,9 +887,9 @@ export function WagonsCrudPage() {
addLabel="Add Wagon"
data={query.data}
isLoading={query.isLoading}
create={useCreateWagon()}
update={useUpdateWagon()}
remove={useDeleteWagon()}
create={useMutation(api.wagons.create.mutationOptions())}
update={useMutation(api.wagons.update.mutationOptions())}
remove={useMutation(api.wagons.remove.mutationOptions())}
searchText={(wagon) => [
wagon.wagonNumber,
wagon.wagonTypeId,
@@ -984,9 +957,11 @@ export function WagonsCrudPage() {
}
export function ContainersCrudPage() {
const query = useContainers();
const { data: containerTypes = [] } = useContainerTypes();
const { data: wagons = [] } = useWagons();
const query = useQuery(api.containers.list.queryOptions());
const { data: containerTypes = [] } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
const { data: wagons = [] } = useQuery(api.wagons.list.queryOptions({ input: {} }));
const containerTypeOptions = containerTypes.map((type: any) => ({
value: type.id,
label: type.label ?? type.name ?? type.code,
@@ -1002,9 +977,9 @@ export function ContainersCrudPage() {
addLabel="Add Container"
data={query.data}
isLoading={query.isLoading}
create={useCreateContainer()}
update={useUpdateContainer()}
remove={useDeleteContainer()}
create={useMutation(api.containers.create.mutationOptions())}
update={useMutation(api.containers.update.mutationOptions())}
remove={useMutation(api.containers.remove.mutationOptions())}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
@@ -1041,9 +1016,11 @@ export function ContainersCrudPage() {
}
export function CargoesCrudPage() {
const query = useCargoes();
const { data: cargoTypes = [] } = useCargoTypes();
const { data: containers = [] } = useContainers();
const query = useQuery(api.cargoes.list.queryOptions());
const { data: cargoTypes = [] } = useQuery(
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
);
const { data: containers = [] } = useQuery(api.containers.list.queryOptions());
const cargoTypeOptions = cargoTypes.map((type: any) => ({
value: type.id,
label: type.cargoTypeName ?? type.cargo_type_name ?? type.name ?? type.code,
@@ -1059,9 +1036,9 @@ export function CargoesCrudPage() {
addLabel="Add Cargo"
data={query.data}
isLoading={query.isLoading}
create={useCreateCargo()}
update={useUpdateCargo()}
remove={useDeleteCargo()}
create={useMutation(api.cargoes.create.mutationOptions())}
update={useMutation(api.cargoes.update.mutationOptions())}
remove={useMutation(api.cargoes.remove.mutationOptions())}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
@@ -1110,7 +1087,7 @@ export function CargoesCrudPage() {
}
export function LocomotivesCrudPage() {
const query = useLocomotives();
const query = useQuery(api.locomotives.list.queryOptions());
return (
<FleetCrudPage<Locomotive>
@@ -1120,9 +1097,9 @@ export function LocomotivesCrudPage() {
addLabel="Add Locomotive"
data={query.data}
isLoading={query.isLoading}
create={useCreateLocomotive()}
update={useUpdateLocomotive()}
remove={useDecommissionLocomotive()}
create={useMutation(api.locomotives.create.mutationOptions())}
update={useMutation(api.locomotives.update.mutationOptions())}
remove={useMutation(api.locomotives.decommission.mutationOptions())}
removeActionLabel="Decommission"
removeConfirmMessage="Decommission this locomotive?"
removeSuccessMessage="Locomotive decommissioned"

View File

@@ -1,8 +1,12 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
import { Badge } from "@mantine/core";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
@@ -11,23 +15,15 @@ import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useCargoTypes } from "@/hooks/use-cargo-types";
import { useContainerTypes } from "@/hooks/use-container-types";
import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useWagons } from "@/hooks/useWagons";
import type { FleetListFilters } from "@/services/fleet/fleet.service";
import {
FLEET_SELECT_NONE,
getFleetResource,
getFleetSlugFromPath,
type FleetFormFieldDef,
type FleetResourceSlug,
FLEET_SELECT_NONE,
getFleetResource,
getFleetSlugFromPath,
type FleetFormFieldDef,
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
@@ -45,6 +41,8 @@ const FleetResourcePage = () => {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
@@ -64,15 +62,34 @@ const FleetResourcePage = () => {
return filters;
}, [slug, listFilterValues, search]);
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters);
const { create, update, remove } = useFleetMutations(slug);
const { data: allRows = [], isLoading, isError, error } = useQuery(
api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
);
const create = useMutation(api.fleet.create.mutationOptions());
const update = useMutation(api.fleet.update.mutationOptions());
const remove = useMutation(api.fleet.remove.mutationOptions());
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const { data: containerTypes = [], isLoading: containerTypesLoading } = useContainerTypes();
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
const { data: containers = [], isLoading: containersLoading } = useContainers();
const { data: yards = [], isLoading: yardsLoading } = useRouteYards();
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
api.wagonTypes.list.queryOptions(),
);
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery(
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
);
const { data: wagons = [], isLoading: wagonsLoading } = useQuery(
api.wagons.list.queryOptions({ input: {} }),
);
const { data: containers = [], isLoading: containersLoading } = useQuery(
api.containers.list.queryOptions(),
);
const { data: yards = [], isLoading: yardsLoading } = useQuery(
api.routes.yards.queryOptions(),
);
const { data: drivers = [] } = useQuery(
api.fleet.list.queryOptions({ input: { slug: "drivers" } }),
);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
@@ -90,6 +107,13 @@ const FleetResourcePage = () => {
const statusFilterOptions = useMemo(() => {
if (!hasStatusColumn || usesServerListFilters) return [];
if (slug === "vehicles" || slug === "drivers") {
return [
{ value: "ALL", label: "All statuses" },
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
];
}
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
@@ -99,7 +123,7 @@ const FleetResourcePage = () => {
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn, usesServerListFilters]);
}, [allRows, hasStatusColumn, usesServerListFilters, slug]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
@@ -215,33 +239,35 @@ const FleetResourcePage = () => {
const base: ColumnDef<FleetRecord>[] = config.columns.map((col) => ({
id: col.id,
accessorKey: col.accessorKey,
header: col.header,
size: col.size || 150,
minSize: col.size ? Math.max(col.size - 20, 80) : 80,
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
formatFleetCell(
(row.original as unknown as Record<string, unknown>)[col.accessorKey],
col.format,
col.accessorKey,
),
cell: ({ row }) => {
const value = (row.original as unknown as Record<string, unknown>)[col.accessorKey];
console.log(`${col.accessorKey}:`, value, 'format:', col.format);
return formatFleetCell(value, col.format, col.accessorKey);
},
}));
base.push({
id: "actions",
header: "Actions",
size: 140,
size: 160,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<FleetRecordActions
record={row.original}
config={config}
layout="compact"
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
onAssignDriver={setAssigningDriver}
/>
</div>
),
@@ -259,10 +285,10 @@ const FleetResourcePage = () => {
const handleFormSubmit = async (values: Record<string, unknown>) => {
try {
if (editing && "id" in editing) {
await update.mutateAsync({ id: String(editing.id), data: values });
await update.mutateAsync({ slug, id: String(editing.id), data: values });
toast({ title: `${config.entityLabel} updated` });
} else {
await create.mutateAsync(values);
await create.mutateAsync({ slug, data: values });
toast({ title: `${config.entityLabel} created` });
}
setFormOpen(false);
@@ -278,7 +304,7 @@ const FleetResourcePage = () => {
const handleRemove = async () => {
if (!removeTarget || !("id" in removeTarget)) return;
try {
await remove.mutateAsync(String(removeTarget.id));
await remove.mutateAsync({ slug, id: String(removeTarget.id) });
toast({
title: config.removeSuccessMessage ?? `${config.entityLabel} removed`,
});
@@ -291,22 +317,56 @@ const FleetResourcePage = () => {
}
};
const handleAssignDriver = async () => {
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
try {
const selectedDriverRecord = (drivers as unknown as Array<Record<string, unknown>>).find(
(d) => String(d.id) === selectedDriver
);
if (!selectedDriverRecord) return;
const driverName = `${selectedDriverRecord.firstName} ${selectedDriverRecord.lastName}`;
await update.mutateAsync({
slug,
id: String(assigningDriver.id),
data: {
assignedDriverId: selectedDriver,
assignedDriverName: driverName,
},
});
toast({ title: "Driver assigned successfully" });
setAssigningDriver(null);
setSelectedDriver("");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Assignment failed";
toast({ title: "Assignment failed", description: String(message), variant: "destructive" });
}
};
const itemLabel = config.label.toLowerCase();
return (
<Stack gap="lg" style={{ maxWidth: "100%" }}>
<Box>
<Stack gap="xs">
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: config.label }]} />
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-end">
<div>
<Text size="lg" fw={700} c="dark">
{config.label}
</Text>
<Text size="sm" c="dimmed">
<Title order={2}>{config.label}</Title>
<Text c="dimmed" size="sm">
{config.subtitle}
</Text>
</div>
</Stack>
</Box>
<Button leftSection={<Plus size={16} />} onClick={() => {
setEditing(null);
setFormOpen(true);
}}>
{config.addLabel}
</Button>
</Group>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
@@ -316,11 +376,6 @@ const FleetResourcePage = () => {
onSearchChange={setSearch}
searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch}
addLabel={config.addLabel}
onAdd={() => {
setEditing(null);
setFormOpen(true);
}}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
@@ -376,9 +431,12 @@ const FleetResourcePage = () => {
</Box>
{viewMode === "table" ? (
<Box style={{ overflowX: "auto", width: "100%", minWidth: 0 }}>
<div style={{ minWidth: "max-content" }}>
<DataTable
<Box style={{
overflowX: "auto",
width: "100%",
WebkitOverflowScrolling: "touch",
}}>
<DataTable
columns={columns}
data={pagedRows}
status={tableStatus}
@@ -412,7 +470,6 @@ const FleetResourcePage = () => {
/>
)}
/>
</div>
</Box>
) : (
<FleetCardGrid
@@ -433,6 +490,7 @@ const FleetResourcePage = () => {
)}
</Stack>
</Card>
</Stack>
<FleetFormDialog
open={formOpen}
@@ -471,7 +529,53 @@ const FleetResourcePage = () => {
</Group>
</Stack>
</Modal>
</Stack>
<Modal
opened={Boolean(assigningDriver)}
onClose={() => {
setAssigningDriver(null);
setSelectedDriver("");
}}
title={<Text fw={600}>Assign Driver</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
{assigningDriver && "id" in assigningDriver ?
`Assign a driver to vehicle: ${(assigningDriver as unknown as Record<string, unknown>).plateNumber}`
: "Select a driver to assign"}
</Text>
<Select
label="Driver"
placeholder="Select a driver"
searchable
clearable
value={selectedDriver}
onChange={(value) => setSelectedDriver(value || "")}
data={(drivers as unknown as Array<Record<string, unknown>>).map((driver) => ({
value: String(driver.id || ""),
label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`,
}))}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => {
setAssigningDriver(null);
setSelectedDriver("");
}}>
Cancel
</Button>
<Button
onClick={handleAssignDriver}
disabled={!selectedDriver}
loading={update.isPending}
>
Assign
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
};

View File

@@ -1,5 +1,5 @@
import { FormEvent, useMemo, useState } from "react";
import { Edit, Eye, Trash2 } from "lucide-react";
import { Ban, CircleCheck, Edit, Eye, Plus, Route as RouteIcon, Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import {
ActionIcon,
@@ -17,17 +17,14 @@ import {
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import {
useCreateRoute,
useDeactivateRoute,
useRouteYards,
useRoutes,
useUpdateRoute,
} from "@/hooks/useRoutes";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { RouteRecord, YardRef } from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
@@ -70,11 +67,11 @@ export default function RoutesPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { toast } = useToast();
const routesQuery = useRoutes();
const yardsQuery = useRouteYards();
const createMutation = useCreateRoute();
const updateMutation = useUpdateRoute();
const deactivateMutation = useDeactivateRoute();
const routesQuery = useQuery(api.routes.list.queryOptions());
const yardsQuery = useQuery(api.routes.yards.queryOptions());
const createMutation = useMutation(api.routes.create.mutationOptions());
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
@@ -101,6 +98,9 @@ export default function RoutesPage() {
return filteredRoutes.slice(start, start + pagination.pageSize);
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
const allRoutes = routesQuery.data ?? [];
const activeCount = allRoutes.filter((route) => route.isActive).length;
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((yard) => ({
@@ -241,7 +241,7 @@ export default function RoutesPage() {
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={row.original.isActive ? "green" : "gray"} variant="light" size="sm">
<Badge color={row.original.isActive ? "edr-green" : "gray"} variant="light" size="sm">
{row.original.isActive ? "Active" : "Inactive"}
</Badge>
),
@@ -279,16 +279,38 @@ export default function RoutesPage() {
}, [deactivateMutation.isPending]);
return (
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<PageContainer>
<PageHeader
title="Routes"
subtitle="Define rail corridors and their ordered yard stops used by train scheduling."
action={
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
Add route
</Button>
}
/>
<KpiStrip
loading={routesQuery.isLoading}
items={[
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
{ label: "Active", value: activeCount, icon: CircleCheck, color: "edr-green" },
{
label: "Inactive",
value: allRoutes.length - activeCount,
icon: Ban,
color: "gray",
},
]}
/>
<Card radius="lg" padding={0} withBorder>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search routes…"
addLabel="Add Route"
onAdd={openCreate}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
@@ -338,7 +360,7 @@ export default function RoutesPage() {
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{route.name}</Text>
<Badge color={route.isActive ? "green" : "gray"} variant="light" size="sm">
<Badge color={route.isActive ? "edr-green" : "gray"} variant="light" size="sm">
{route.isActive ? "Active" : "Inactive"}
</Badge>
</Group>
@@ -431,7 +453,7 @@ export default function RoutesPage() {
<Button variant="default" type="button" onClick={resetForm}>
Cancel
</Button>
<Button color="green" type="submit" loading={isSaving}>
<Button color="edr-green" type="submit" loading={isSaving}>
Save
</Button>
</Group>
@@ -484,6 +506,6 @@ export default function RoutesPage() {
</Stack>
) : null}
</Modal>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,72 @@
import type { FleetResourceConfig } from "./resources";
import { VEHICLE_TYPE_OPTIONS } from "./vehicles";
const DRIVER_STATUS_OPTIONS = [
{ label: "Active", value: "ACTIVE" },
{ label: "Inactive", value: "INACTIVE" },
{ label: "Suspended", value: "SUSPENDED" },
{ label: "On leave", value: "ON_LEAVE" },
];
export const driversConfig: FleetResourceConfig = {
slug: "drivers",
label: "Drivers",
subtitle: "Manage driver records and licenses",
basePath: "/dashboard/drivers",
addLabel: "Add Driver",
entityLabel: "Driver",
searchPlaceholder: "Search drivers…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "firstName",
cardCodeKey: "licenseNumber",
cardSubtitleKey: "status",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: DRIVER_STATUS_OPTIONS,
},
],
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
columns: [
{ id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 },
{ id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 },
{ id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 },
{ id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
],
formFields: [
{ name: "licenseNumber", label: "License Number", type: "text", required: true },
{ name: "firstName", label: "First Name", type: "text", required: true },
{ name: "lastName", label: "Last Name", type: "text", required: true },
{ name: "email", label: "Email", type: "email", required: true },
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true },
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true },
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true },
{ name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
{ name: "address", label: "Address", type: "textarea" },
{ name: "emergencyContact", label: "Emergency Contact", type: "text" },
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
licenseNumber: "",
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
dateOfBirth: "",
licenseExpiryDate: "",
status: "ACTIVE",
vehicleTypesAuthorized: [],
address: "",
emergencyContact: "",
notes: "",
},
};
export { DRIVER_STATUS_OPTIONS };

View File

@@ -1,5 +1,10 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
import { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles";
import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers";
// Re-export options for backward compatibility
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS, DRIVER_STATUS_OPTIONS };
export type FleetResourceSlug =
| "locomotives"
@@ -99,36 +104,6 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Retired", value: Freight.WagonStatus.Retired },
];
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
{ label: "Car", value: "CAR" },
{ label: "Bus", value: "BUS" },
{ label: "Trailer", value: "TRAILER" },
{ label: "Tanker", value: "TANKER" },
{ label: "Flatbed", value: "FLATBED" },
];
const FUEL_TYPE_OPTIONS = [
{ label: "Petrol", value: "PETROL" },
{ label: "Diesel", value: "DIESEL" },
{ label: "Electric", value: "ELECTRIC" },
{ label: "Hybrid", value: "HYBRID" },
];
const VEHICLE_STATUS_OPTIONS = [
{ label: "Active", value: "ACTIVE" },
{ label: "Maintenance", value: "MAINTENANCE" },
{ label: "Retired", value: "RETIRED" },
{ label: "Out of service", value: "OUT_OF_SERVICE" },
];
const DRIVER_STATUS_OPTIONS = [
{ label: "Active", value: "ACTIVE" },
{ label: "Inactive", value: "INACTIVE" },
{ label: "Suspended", value: "SUSPENDED" },
{ label: "On leave", value: "ON_LEAVE" },
];
export const FLEET_RESOURCES: FleetResourceConfig[] = [
@@ -378,122 +353,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
status: "PENDING",
},
},
{
slug: "vehicles",
label: "Vehicles",
subtitle: "Manage vehicle master data for fleet operations",
basePath: "/dashboard/vehicles",
addLabel: "Add Vehicle",
entityLabel: "Vehicle",
searchPlaceholder: "Search vehicles…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "plateNumber",
cardCodeKey: "plateNumber",
cardSubtitleKey: "status",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: VEHICLE_STATUS_OPTIONS,
},
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
columns: [
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
{ id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", size: 140 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 140 },
{ id: "model", header: "Model", accessorKey: "model", size: 120 },
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 100 },
{ id: "year", header: "Year", accessorKey: "year", format: "number", size: 80 },
{ id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", size: 110 },
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
],
formFields: [
{ name: "plateNumber", label: "Plate Number", type: "text", required: true },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },
{ name: "year", label: "Year", type: "number", required: true },
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
{ name: "capacity", label: "Capacity", type: "number", required: true },
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
],
emptyValues: {
plateNumber: "",
vehicleType: "TRUCK",
manufacturer: "",
model: "",
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
status: "ACTIVE",
description: "",
},
},
{
slug: "drivers",
label: "Drivers",
subtitle: "Manage driver records and licenses",
basePath: "/dashboard/drivers",
addLabel: "Add Driver",
entityLabel: "Driver",
searchPlaceholder: "Search drivers…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "firstName",
cardCodeKey: "licenseNumber",
cardSubtitleKey: "status",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: DRIVER_STATUS_OPTIONS,
},
],
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
columns: [
{ id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 },
{ id: "firstName", header: "First Name", accessorKey: "firstName", size: 120 },
{ id: "lastName", header: "Last Name", accessorKey: "lastName", size: 120 },
{ id: "email", header: "Email", accessorKey: "email", size: 180 },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", size: 120 },
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
],
formFields: [
{ name: "licenseNumber", label: "License Number", type: "text", required: true },
{ name: "firstName", label: "First Name", type: "text", required: true },
{ name: "lastName", label: "Last Name", type: "text", required: true },
{ name: "email", label: "Email", type: "email", required: true },
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true },
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true },
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true },
{ name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
{ name: "address", label: "Address", type: "textarea" },
{ name: "emergencyContact", label: "Emergency Contact", type: "text" },
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
licenseNumber: "",
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
dateOfBirth: "",
licenseExpiryDate: "",
status: "ACTIVE",
vehicleTypesAuthorized: [],
address: "",
emergencyContact: "",
notes: "",
},
},
vehiclesConfig,
driversConfig,
];
export const getFleetResource = (slug: string): FleetResourceConfig | undefined =>

View File

@@ -0,0 +1,85 @@
import type { FleetResourceConfig } from "./resources";
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
{ label: "Car", value: "CAR" },
{ label: "Bus", value: "BUS" },
{ label: "Trailer", value: "TRAILER" },
{ label: "Tanker", value: "TANKER" },
{ label: "Flatbed", value: "FLATBED" },
];
const FUEL_TYPE_OPTIONS = [
{ label: "Petrol", value: "PETROL" },
{ label: "Diesel", value: "DIESEL" },
{ label: "Electric", value: "ELECTRIC" },
{ label: "Hybrid", value: "HYBRID" },
];
const VEHICLE_STATUS_OPTIONS = [
{ label: "Active", value: "ACTIVE" },
{ label: "Maintenance", value: "MAINTENANCE" },
{ label: "Retired", value: "RETIRED" },
{ label: "Out of service", value: "OUT_OF_SERVICE" },
];
export const vehiclesConfig: FleetResourceConfig = {
slug: "vehicles",
label: "Vehicles",
subtitle: "Manage vehicle master data for fleet operations",
basePath: "/dashboard/vehicles",
addLabel: "Add Vehicle",
entityLabel: "Vehicle",
searchPlaceholder: "Search vehicles…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "plateNumber",
cardCodeKey: "plateNumber",
cardSubtitleKey: "status",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: VEHICLE_STATUS_OPTIONS,
},
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
columns: [
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
{ id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 },
{ id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 },
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 100 },
{ id: "year", header: "Year", accessorKey: "year", format: "number", size: 80 },
{ id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 },
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 },
{ id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
],
formFields: [
{ name: "plateNumber", label: "Plate Number", type: "text", required: true },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },
{ name: "year", label: "Year", type: "number", required: true },
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
{ name: "capacity", label: "Capacity", type: "number", required: true },
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
],
emptyValues: {
plateNumber: "",
vehicleType: "TRUCK",
manufacturer: "",
model: "",
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
status: "ACTIVE",
description: "",
},
};
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };

View File

@@ -0,0 +1,901 @@
import { type ReactNode, useMemo, useState } from "react";
import {
ArrowRight,
Eye,
MoreHorizontal,
Printer,
RefreshCw,
Truck,
} from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Menu,
Modal,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast";
type FirstMileStatus = "UNASSIGNED" | "ASSIGNED";
type PickupStatus = "PAYMENT_PENDING" | "READY_FOR_PICKUP" | "PICKED_UP";
interface FirstMileJob {
id: string;
bookingRef: string;
customer: string;
pickup: string;
cargo: string;
status: FirstMileStatus;
pickupStatus: PickupStatus;
assignedVehicle: string | null;
// Booking info shown in the Assign / View Detail modals.
serviceType: string;
weight: string;
price: number;
destinationYard: string;
contactName: string;
contactPhone: string;
requestedDate: string;
}
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const PICKUP_STATUS_META: Record<
PickupStatus,
{ label: string; color: string }
> = {
PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" },
READY_FOR_PICKUP: { label: "Ready for Pickup", color: "blue" },
PICKED_UP: { label: "Picked Up", color: "green" },
};
// Forward-only lifecycle: Payment Pending → Ready for Pickup → Picked Up.
const NEXT_PICKUP_STATUS: Partial<Record<PickupStatus, PickupStatus>> = {
PAYMENT_PENDING: "READY_FOR_PICKUP",
READY_FOR_PICKUP: "PICKED_UP",
};
// Single filter covering both the pickup lifecycle and assignment state.
type StatusFilter =
| "ALL"
| PickupStatus
| FirstMileStatus;
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "ALL", label: "All statuses" },
{ value: "PAYMENT_PENDING", label: "Payment Pending" },
{ value: "READY_FOR_PICKUP", label: "Ready for Pickup" },
{ value: "PICKED_UP", label: "Picked Up" },
{ value: "ASSIGNED", label: "Assigned" },
{ value: "UNASSIGNED", label: "Unassigned" },
];
// Placeholder data — replace with a real first-mile service once the API exists.
const PLACEHOLDER_JOBS: FirstMileJob[] = [
{
id: "1",
bookingRef: "BK-10242",
customer: "Awash Trading PLC",
pickup: "Kera Warehouse, Addis Ababa",
cargo: "20ft container · Electronics",
status: "UNASSIGNED",
pickupStatus: "PAYMENT_PENDING",
assignedVehicle: null,
serviceType: "Door-to-terminal (First Mile)",
weight: "12.4 t",
price: 4200,
destinationYard: "Indode Dry Port",
contactName: "Selam Bekele",
contactPhone: "+251 911 234 567",
requestedDate: "2026-06-22",
},
{
id: "2",
bookingRef: "BK-10239",
customer: "Dire Logistics",
pickup: "Factory Gate 4, Dire Dawa",
cargo: "Bulk · 18t Cement",
status: "ASSIGNED",
pickupStatus: "READY_FOR_PICKUP",
assignedVehicle: "Isuzu FVR (3-AA-45821)",
serviceType: "Door-to-terminal (First Mile)",
weight: "18.0 t",
price: 3000,
destinationYard: "Dire Dawa Terminal",
contactName: "Yonas Tadesse",
contactPhone: "+251 912 887 010",
requestedDate: "2026-06-21",
},
{
id: "3",
bookingRef: "BK-10235",
customer: "Horizon Imports",
pickup: "Lebu Industrial Park, Addis Ababa",
cargo: "40ft container · Machinery",
status: "UNASSIGNED",
pickupStatus: "PICKED_UP",
assignedVehicle: null,
serviceType: "Door-to-terminal (First Mile)",
weight: "24.7 t",
price: 6500,
destinationYard: "Mojo Dry Port",
contactName: "Hanna Girma",
contactPhone: "+251 913 445 221",
requestedDate: "2026-06-23",
},
];
// Placeholder vehicle options — replace with the vehicles service.
const VEHICLE_OPTIONS = [
{ value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" },
{ value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" },
{ value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" },
];
const InfoRow = ({ label, value }: { label: string; value: string }) => (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="sm">{value}</Text>
</Stack>
);
const BookingInfo = ({ job }: { job: FirstMileJob }) => (
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{job.bookingRef}</Text>
<Group gap="xs">
<Badge
color={PICKUP_STATUS_META[job.pickupStatus].color}
variant="light"
size="sm"
>
{PICKUP_STATUS_META[job.pickupStatus].label}
</Badge>
<Badge
color={job.status === "ASSIGNED" ? "green" : "orange"}
variant="light"
size="sm"
>
{job.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
</Badge>
</Group>
</Group>
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Customer" value={job.customer} />
<InfoRow label="Service type" value={job.serviceType} />
<InfoRow label="Pickup location" value={job.pickup} />
<InfoRow label="Destination yard" value={job.destinationYard} />
<InfoRow label="Cargo" value={job.cargo} />
<InfoRow label="Weight" value={job.weight} />
<InfoRow label="Price" value={formatPrice(job.price)} />
<InfoRow label="Contact" value={job.contactName} />
<InfoRow label="Phone" value={job.contactPhone} />
<InfoRow label="Requested date" value={job.requestedDate} />
<InfoRow label="Assigned vehicle" value={job.assignedVehicle ?? "—"} />
</SimpleGrid>
</Stack>
</Card>
);
const tripSlipRows = (job: FirstMileJob): [string, string][] => [
["Customer", job.customer],
["Service", job.serviceType],
["Pickup location", job.pickup],
["Destination yard", job.destinationYard],
["Cargo", job.cargo],
["Weight", job.weight],
["Price", formatPrice(job.price)],
["Vehicle", job.assignedVehicle ?? "Unassigned"],
["Contact", `${job.contactName} · ${job.contactPhone}`],
["Requested date", job.requestedDate],
["Pickup status", PICKUP_STATUS_META[job.pickupStatus].label],
];
const SampleStamp = () => (
<Box style={{ height: 96, display: "flex", alignItems: "center" }}>
<Box
style={{
width: 96,
height: 96,
borderRadius: "50%",
border: "2px solid var(--mantine-color-teal-7)",
transform: "rotate(-12deg)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
style={{
width: 82,
height: 82,
borderRadius: "50%",
border: "1px solid var(--mantine-color-teal-7)",
color: "var(--mantine-color-teal-7)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
lineHeight: 1.1,
}}
>
<Text size="9px" fw={700} style={{ letterSpacing: 1 }}>
EDR FREIGHT
</Text>
<Text size="sm" fw={800}>
APPROVED
</Text>
<Text size="8px" fw={600}>
OPERATIONS
</Text>
</Box>
</Box>
</Box>
);
const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => (
<Stack gap="sm" style={{ flex: 1, position: "relative", minHeight: stamp ? 130 : undefined }}>
<Text fw={600} size="sm">
{title}
</Text>
<Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed">
Name:
</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group>
<Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed">
Signature:
</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group>
{stamp && (
<Box
style={{
position: "absolute",
right: 4,
top: 22,
opacity: 0.85,
pointerEvents: "none",
}}
>
{stamp}
</Box>
)}
</Stack>
);
const TripSlipDocument = ({ job }: { job: FirstMileJob }) => (
<Stack gap="md">
<Stack gap={2} align="center">
<Text fw={700}>EDR Freight</Text>
<Text size="sm" c="dimmed" tt="uppercase" fw={600}>
First Mile Trip Slip
</Text>
</Stack>
<Group justify="space-between">
<Text size="sm" fw={600}>
{job.bookingRef}
</Text>
<Text size="sm" c="dimmed">
{job.requestedDate}
</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="xs">
{tripSlipRows(job).map(([label, value]) => (
<InfoRow key={label} label={label} value={value} />
))}
</SimpleGrid>
<Divider label="Acknowledgement" labelPosition="center" />
<Group align="flex-start" gap="xl" wrap="nowrap">
<SignatureBlock title="Driver" />
<SignatureBlock title="Operator" stamp={<SampleStamp />} />
</Group>
</Stack>
);
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const buildTripSlipHtml = (job: FirstMileJob) => {
const rows = tripSlipRows(job)
.map(
([label, value]) =>
`<tr><td class="lbl">${escapeHtml(label)}</td><td>${escapeHtml(value)}</td></tr>`,
)
.join("");
const signature = (title: string, withStamp: boolean) => `
<div class="sign-col">
<div class="sign-title">${title}</div>
<div class="sign-field"><span>Name:</span><span class="line"></span></div>
<div class="sign-field"><span>Signature:</span><span class="line"></span></div>
${
withStamp
? '<div class="stamp"><div class="ring"><div class="ring-inner"><span>EDR FREIGHT</span><strong>APPROVED</strong><span>OPERATIONS</span></div></div></div>'
: ""
}
</div>`;
return `<!doctype html><html><head><meta charset="utf-8" />
<title>Trip Slip ${escapeHtml(job.bookingRef)}</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; }
.head { text-align: center; margin-bottom: 16px; }
.head h1 { font-size: 18px; margin: 0; }
.head p { font-size: 12px; letter-spacing: 1px; text-transform: uppercase; color: #555; margin: 2px 0 0; }
.meta { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0 20px; }
td { padding: 5px 6px; border-bottom: 1px solid #eee; vertical-align: top; }
td.lbl { color: #666; text-transform: uppercase; font-size: 11px; font-weight: 700; width: 40%; }
.ack { text-align: center; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #777; margin: 16px 0 8px; }
.signs { display: flex; gap: 32px; }
.sign-col { flex: 1; position: relative; min-height: 130px; }
.sign-title { font-weight: 700; font-size: 13px; margin-bottom: 12px; }
.sign-field { display: flex; gap: 6px; align-items: flex-end; font-size: 12px; color: #666; margin-bottom: 10px; }
.sign-field .line { flex: 1; border-bottom: 1px solid #888; height: 16px; }
.stamp { position: absolute; right: 4px; top: 22px; opacity: 0.85; }
.ring { width: 96px; height: 96px; border-radius: 50%; border: 2px solid #0c7a57; transform: rotate(-12deg); display: flex; align-items: center; justify-content: center; }
.ring-inner { width: 82px; height: 82px; border-radius: 50%; border: 1px solid #0c7a57; color: #0c7a57; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; line-height: 1.1; }
.ring-inner span { font-size: 9px; font-weight: 700; letter-spacing: 1px; }
.ring-inner strong { font-size: 13px; font-weight: 800; }
</style></head>
<body onload="window.print()">
<div class="head"><h1>EDR Freight</h1><p>First Mile Trip Slip</p></div>
<div class="meta"><span>${escapeHtml(job.bookingRef)}</span><span>${escapeHtml(job.requestedDate)}</span></div>
<table>${rows}</table>
<div class="ack">Acknowledgement</div>
<div class="signs">${signature("Driver", false)}${signature("Operator", true)}</div>
</body></html>`;
};
const FirstMilePage = () => {
const { toast } = useToast();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [jobs, setJobs] = useState<FirstMileJob[]>(PLACEHOLDER_JOBS);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
const [bulkMode, setBulkMode] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [tripSlipOpen, setTripSlipOpen] = useState(false);
const [tripSlipJob, setTripSlipJob] = useState<FirstMileJob | null>(null);
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
const activeJob = useMemo(
() => jobs.find((job) => job.id === activeJobId) ?? null,
[jobs, activeJobId],
);
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
);
const matchesStatusFilter = (job: FirstMileJob) => {
switch (statusFilter) {
case "ALL":
return true;
case "ASSIGNED":
case "UNASSIGNED":
return job.status === statusFilter;
default:
return job.pickupStatus === statusFilter;
}
};
const statusCounts = useMemo(() => {
const counts: Record<StatusFilter, number> = {
ALL: jobs.length,
PAYMENT_PENDING: 0,
READY_FOR_PICKUP: 0,
PICKED_UP: 0,
ASSIGNED: 0,
UNASSIGNED: 0,
};
for (const job of jobs) {
counts[job.pickupStatus] += 1;
counts[job.status] += 1;
}
return counts;
}, [jobs]);
const filteredJobs = useMemo(() => {
const term = search.trim().toLowerCase();
return jobs.filter((job) => {
if (!matchesStatusFilter(job)) return false;
if (!term) return true;
return [job.bookingRef, job.customer, job.pickup, job.cargo]
.join(" ")
.toLowerCase()
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobs, search, statusFilter]);
const pageCount = Math.max(1, Math.ceil(filteredJobs.length / pagination.pageSize));
const pagedJobs = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredJobs.slice(start, start + pagination.pageSize);
}, [filteredJobs, pagination.pageIndex, pagination.pageSize]);
const openAssign = (jobId: string | null) => {
const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null;
setBulkMode(false);
setActiveJobId(resolved);
setVehicleValue(null);
setAssignOpen(true);
};
const openBulkAssign = () => {
setBulkMode(true);
setActiveJobId(null);
setVehicleValue(null);
setAssignOpen(true);
};
const openDetail = (jobId: string) => {
setActiveJobId(jobId);
setDetailOpen(true);
};
const closeAssign = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveJobId(null);
setVehicleValue(null);
};
const closeDetail = () => {
setDetailOpen(false);
setActiveJobId(null);
};
const handleAssign = () => {
if (!vehicleValue) {
toast({
title: "Select a vehicle",
description: "Choose a vehicle to assign to this pickup.",
variant: "destructive",
});
return;
}
const vehicleLabel =
VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue;
const targetIds = bulkMode
? selectedIds
: [activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id].filter(
(id): id is string => Boolean(id),
);
if (!targetIds.length) return;
const targetSet = new Set(targetIds);
setJobs((current) =>
current.map((job) =>
targetSet.has(job.id)
? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel }
: job,
),
);
toast({
title: "Vehicle assigned",
description: bulkMode
? `${targetIds.length} pickups → ${vehicleLabel}`
: vehicleLabel,
});
if (bulkMode) setRowSelection({});
closeAssign();
};
const handleAdvanceStatus = (job: FirstMileJob) => {
const next = NEXT_PICKUP_STATUS[job.pickupStatus];
if (!next) return;
setJobs((current) =>
current.map((item) =>
item.id === job.id ? { ...item, pickupStatus: next } : item,
),
);
toast({
title: "Status updated",
description: `${job.bookingRef}${PICKUP_STATUS_META[next].label}`,
});
};
const handlePrintTripSlip = (job: FirstMileJob) => {
setTripSlipJob(job);
setTripSlipOpen(true);
};
const printTripSlip = () => {
if (!tripSlipJob) return;
const win = window.open("", "_blank", "width=820,height=920");
if (!win) {
toast({
title: "Pop-up blocked",
description: "Allow pop-ups to print the trip slip.",
variant: "destructive",
});
return;
}
win.document.write(buildTripSlipHtml(tripSlipJob));
win.document.close();
};
const columns = useMemo((): ColumnDef<FirstMileJob>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "select",
size: 40,
meta: { headerClassName, cellClassName },
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={table.getIsAllPageRowsSelected()}
indeterminate={
table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
}
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: "bookingRef",
header: "Booking",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600}>
{row.original.bookingRef}
</Text>
),
},
{
id: "customer",
header: "Customer",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.customer,
},
{
id: "pickup",
header: "Pickup",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.pickup,
},
{
id: "cargo",
header: "Cargo",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.cargo,
},
{
id: "price",
header: "Price",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.price),
},
{
id: "vehicle",
header: "Vehicle",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.assignedVehicle ?? <Text c="dimmed"></Text>,
},
{
id: "pickupStatus",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const meta = PICKUP_STATUS_META[row.original.pickupStatus];
return (
<Badge color={meta.color} variant="light" size="sm">
{meta.label}
</Badge>
);
},
},
{
id: "status",
header: "Assignment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge
color={row.original.status === "ASSIGNED" ? "green" : "orange"}
variant="light"
size="sm"
>
{row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
</Badge>
),
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const isAssigned = row.original.status === "ASSIGNED";
const nextStatus = NEXT_PICKUP_STATUS[row.original.pickupStatus];
const canPrintTripSlip =
row.original.pickupStatus === "READY_FOR_PICKUP" ||
row.original.pickupStatus === "PICKED_UP";
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Menu position="bottom-end" width={200} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Pickup actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
disabled={!nextStatus}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus
? `Mark ${PICKUP_STATUS_META[nextStatus].label}`
: "Picked Up"}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Truck size={15} />}
disabled={isAssigned}
onClick={() => openAssign(row.original.id)}
>
Assign
</Menu.Item>
<Menu.Item
leftSection={<RefreshCw size={15} />}
disabled={!isAssigned}
onClick={() => openAssign(row.original.id)}
>
Reassign
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() => openDetail(row.original.id)}
>
View detail
</Menu.Item>
{canPrintTripSlip && (
<Menu.Item
leftSection={<Printer size={15} />}
onClick={() => handlePrintTripSlip(row.original)}
>
Print trip slip
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
}, []);
const tableStatus = "success" as const;
return (
<Stack gap="md">
<Card
radius="lg"
padding={0}
withBorder
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<TextInput
placeholder="Search pickups…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
<Group gap="sm">
{selectedIds.length > 0 && (
<Button
variant="light"
leftSection={<Truck size={16} />}
onClick={openBulkAssign}
>
Assign vehicle ({selectedIds.length})
</Button>
)}
<Button
leftSection={<Truck size={16} />}
onClick={() => openAssign(null)}
>
Assign Mile
</Button>
</Group>
</Group>
<Group gap="xs" wrap="wrap">
{FILTER_OPTIONS.map((option) => {
const active = statusFilter === option.value;
return (
<Button
key={option.value}
size="xs"
variant={active ? "filled" : "default"}
onClick={() => {
setStatusFilter(option.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
{option.label} ({statusCounts[option.value]})
</Button>
);
})}
</Group>
</Stack>
</Box>
<DataTable
columns={columns}
data={pagedJobs}
status={tableStatus}
emptyMessage="No first-mile pickups found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredJobs.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
enableRowSelection: true,
getRowId: (row) => row.id,
state: { pagination, rowSelection },
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "pickups" } }}
/>
)}
/>
</Stack>
</Card>
<Modal
opened={assignOpen}
onClose={closeAssign}
title={<Text fw={600}>Assign Vehicle</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{bulkMode ? (
<Text size="sm">
Assigning a vehicle to{" "}
<Text span fw={600}>
{selectedIds.length}
</Text>{" "}
selected {selectedIds.length === 1 ? "pickup" : "pickups"}.
</Text>
) : activeJob ? (
<BookingInfo job={activeJob} />
) : (
<Text size="sm" c="dimmed">
No unassigned pickups available.
</Text>
)}
<Divider />
<Select
label="Vehicle"
placeholder="Select a vehicle"
data={VEHICLE_OPTIONS}
value={vehicleValue}
onChange={setVehicleValue}
searchable
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>
Cancel
</Button>
<Button
onClick={handleAssign}
disabled={bulkMode ? selectedIds.length === 0 : !activeJob}
>
{!bulkMode && activeJob?.status === "ASSIGNED" ? "Reassign" : "Assign"}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={detailOpen}
onClose={closeDetail}
title={<Text fw={600}>Pickup Detail</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{activeJob && <BookingInfo job={activeJob} />}
<Group justify="flex-end">
<Button variant="default" onClick={closeDetail}>
Close
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={tripSlipOpen}
onClose={() => setTripSlipOpen(false)}
title={<Text fw={600}>Trip Slip</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{tripSlipJob && <TripSlipDocument job={tripSlipJob} />}
<Divider />
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setTripSlipOpen(false)}>
Close
</Button>
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>
Print
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default FirstMilePage;

View File

@@ -0,0 +1,901 @@
import { type ReactNode, useMemo, useState } from "react";
import {
ArrowRight,
Eye,
MoreHorizontal,
Printer,
RefreshCw,
Truck,
} from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Menu,
Modal,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast";
type LastMileStatus = "UNASSIGNED" | "ASSIGNED";
type DeliveryStatus = "PAYMENT_PENDING" | "READY_TO_TRANSIT" | "DELIVERED";
interface LastMileJob {
id: string;
bookingRef: string;
customer: string;
destination: string;
cargo: string;
status: LastMileStatus;
deliveryStatus: DeliveryStatus;
assignedVehicle: string | null;
// Booking info shown in the Assign / View Detail modals.
serviceType: string;
weight: string;
price: number;
originYard: string;
contactName: string;
contactPhone: string;
requestedDate: string;
}
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
const DELIVERY_STATUS_META: Record<
DeliveryStatus,
{ label: string; color: string }
> = {
PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" },
READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" },
DELIVERED: { label: "Delivered", color: "green" },
};
// Forward-only lifecycle: Payment Pending → Ready to Transit → Delivered.
const NEXT_DELIVERY_STATUS: Partial<Record<DeliveryStatus, DeliveryStatus>> = {
PAYMENT_PENDING: "READY_TO_TRANSIT",
READY_TO_TRANSIT: "DELIVERED",
};
// Single filter covering both the delivery lifecycle and assignment state.
type StatusFilter =
| "ALL"
| DeliveryStatus
| LastMileStatus;
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "ALL", label: "All statuses" },
{ value: "PAYMENT_PENDING", label: "Payment Pending" },
{ value: "READY_TO_TRANSIT", label: "Ready to Transit" },
{ value: "DELIVERED", label: "Delivered" },
{ value: "ASSIGNED", label: "Assigned" },
{ value: "UNASSIGNED", label: "Unassigned" },
];
// Placeholder data — replace with a real last-mile service once the API exists.
const PLACEHOLDER_JOBS: LastMileJob[] = [
{
id: "1",
bookingRef: "BK-10241",
customer: "Awash Trading PLC",
destination: "Bole Sub-city, Addis Ababa",
cargo: "20ft container · Electronics",
status: "UNASSIGNED",
deliveryStatus: "PAYMENT_PENDING",
assignedVehicle: null,
serviceType: "Door-to-door (Last Mile)",
weight: "12.4 t",
price: 4500,
originYard: "Indode Dry Port",
contactName: "Selam Bekele",
contactPhone: "+251 911 234 567",
requestedDate: "2026-06-22",
},
{
id: "2",
bookingRef: "BK-10238",
customer: "Dire Logistics",
destination: "Industry Zone, Dire Dawa",
cargo: "Bulk · 18t Cement",
status: "ASSIGNED",
deliveryStatus: "READY_TO_TRANSIT",
assignedVehicle: "Isuzu FVR (3-AA-45821)",
serviceType: "Terminal-to-door (Last Mile)",
weight: "18.0 t",
price: 3200,
originYard: "Dire Dawa Terminal",
contactName: "Yonas Tadesse",
contactPhone: "+251 912 887 010",
requestedDate: "2026-06-21",
},
{
id: "3",
bookingRef: "BK-10233",
customer: "Horizon Imports",
destination: "Kality Terminal, Addis Ababa",
cargo: "40ft container · Machinery",
status: "UNASSIGNED",
deliveryStatus: "DELIVERED",
assignedVehicle: null,
serviceType: "Door-to-door (Last Mile)",
weight: "24.7 t",
price: 6800,
originYard: "Mojo Dry Port",
contactName: "Hanna Girma",
contactPhone: "+251 913 445 221",
requestedDate: "2026-06-23",
},
];
// Placeholder vehicle options — replace with the vehicles service.
const VEHICLE_OPTIONS = [
{ value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" },
{ value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" },
{ value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" },
];
const InfoRow = ({ label, value }: { label: string; value: string }) => (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="sm">{value}</Text>
</Stack>
);
const BookingInfo = ({ job }: { job: LastMileJob }) => (
<Card radius="md" padding="md" withBorder bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{job.bookingRef}</Text>
<Group gap="xs">
<Badge
color={DELIVERY_STATUS_META[job.deliveryStatus].color}
variant="light"
size="sm"
>
{DELIVERY_STATUS_META[job.deliveryStatus].label}
</Badge>
<Badge
color={job.status === "ASSIGNED" ? "green" : "orange"}
variant="light"
size="sm"
>
{job.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
</Badge>
</Group>
</Group>
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Customer" value={job.customer} />
<InfoRow label="Service type" value={job.serviceType} />
<InfoRow label="Origin yard" value={job.originYard} />
<InfoRow label="Destination" value={job.destination} />
<InfoRow label="Cargo" value={job.cargo} />
<InfoRow label="Weight" value={job.weight} />
<InfoRow label="Price" value={formatPrice(job.price)} />
<InfoRow label="Contact" value={job.contactName} />
<InfoRow label="Phone" value={job.contactPhone} />
<InfoRow label="Requested date" value={job.requestedDate} />
<InfoRow label="Assigned vehicle" value={job.assignedVehicle ?? "—"} />
</SimpleGrid>
</Stack>
</Card>
);
const tripSlipRows = (job: LastMileJob): [string, string][] => [
["Customer", job.customer],
["Service", job.serviceType],
["Origin yard", job.originYard],
["Destination", job.destination],
["Cargo", job.cargo],
["Weight", job.weight],
["Price", formatPrice(job.price)],
["Vehicle", job.assignedVehicle ?? "Unassigned"],
["Contact", `${job.contactName} · ${job.contactPhone}`],
["Requested date", job.requestedDate],
["Delivery status", DELIVERY_STATUS_META[job.deliveryStatus].label],
];
const SampleStamp = () => (
<Box style={{ height: 96, display: "flex", alignItems: "center" }}>
<Box
style={{
width: 96,
height: 96,
borderRadius: "50%",
border: "2px solid var(--mantine-color-teal-7)",
transform: "rotate(-12deg)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
style={{
width: 82,
height: 82,
borderRadius: "50%",
border: "1px solid var(--mantine-color-teal-7)",
color: "var(--mantine-color-teal-7)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
lineHeight: 1.1,
}}
>
<Text size="9px" fw={700} style={{ letterSpacing: 1 }}>
EDR FREIGHT
</Text>
<Text size="sm" fw={800}>
APPROVED
</Text>
<Text size="8px" fw={600}>
OPERATIONS
</Text>
</Box>
</Box>
</Box>
);
const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => (
<Stack gap="sm" style={{ flex: 1, position: "relative", minHeight: stamp ? 130 : undefined }}>
<Text fw={600} size="sm">
{title}
</Text>
<Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed">
Name:
</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group>
<Group gap="xs" align="flex-end">
<Text size="sm" c="dimmed">
Signature:
</Text>
<Box style={{ flex: 1, borderBottom: "1px solid var(--mantine-color-gray-5)", height: 18 }} />
</Group>
{stamp && (
<Box
style={{
position: "absolute",
right: 4,
top: 22,
opacity: 0.85,
pointerEvents: "none",
}}
>
{stamp}
</Box>
)}
</Stack>
);
const TripSlipDocument = ({ job }: { job: LastMileJob }) => (
<Stack gap="md">
<Stack gap={2} align="center">
<Text fw={700}>EDR Freight</Text>
<Text size="sm" c="dimmed" tt="uppercase" fw={600}>
Last Mile Trip Slip
</Text>
</Stack>
<Group justify="space-between">
<Text size="sm" fw={600}>
{job.bookingRef}
</Text>
<Text size="sm" c="dimmed">
{job.requestedDate}
</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="xs">
{tripSlipRows(job).map(([label, value]) => (
<InfoRow key={label} label={label} value={value} />
))}
</SimpleGrid>
<Divider label="Acknowledgement" labelPosition="center" />
<Group align="flex-start" gap="xl" wrap="nowrap">
<SignatureBlock title="Driver" />
<SignatureBlock title="Operator" stamp={<SampleStamp />} />
</Group>
</Stack>
);
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const buildTripSlipHtml = (job: LastMileJob) => {
const rows = tripSlipRows(job)
.map(
([label, value]) =>
`<tr><td class="lbl">${escapeHtml(label)}</td><td>${escapeHtml(value)}</td></tr>`,
)
.join("");
const signature = (title: string, withStamp: boolean) => `
<div class="sign-col">
<div class="sign-title">${title}</div>
<div class="sign-field"><span>Name:</span><span class="line"></span></div>
<div class="sign-field"><span>Signature:</span><span class="line"></span></div>
${
withStamp
? '<div class="stamp"><div class="ring"><div class="ring-inner"><span>EDR FREIGHT</span><strong>APPROVED</strong><span>OPERATIONS</span></div></div></div>'
: ""
}
</div>`;
return `<!doctype html><html><head><meta charset="utf-8" />
<title>Trip Slip ${escapeHtml(job.bookingRef)}</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; margin: 32px; }
.head { text-align: center; margin-bottom: 16px; }
.head h1 { font-size: 18px; margin: 0; }
.head p { font-size: 12px; letter-spacing: 1px; text-transform: uppercase; color: #555; margin: 2px 0 0; }
.meta { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0 20px; }
td { padding: 5px 6px; border-bottom: 1px solid #eee; vertical-align: top; }
td.lbl { color: #666; text-transform: uppercase; font-size: 11px; font-weight: 700; width: 40%; }
.ack { text-align: center; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #777; margin: 16px 0 8px; }
.signs { display: flex; gap: 32px; }
.sign-col { flex: 1; position: relative; min-height: 130px; }
.sign-title { font-weight: 700; font-size: 13px; margin-bottom: 12px; }
.sign-field { display: flex; gap: 6px; align-items: flex-end; font-size: 12px; color: #666; margin-bottom: 10px; }
.sign-field .line { flex: 1; border-bottom: 1px solid #888; height: 16px; }
.stamp { position: absolute; right: 4px; top: 22px; opacity: 0.85; }
.ring { width: 96px; height: 96px; border-radius: 50%; border: 2px solid #0c7a57; transform: rotate(-12deg); display: flex; align-items: center; justify-content: center; }
.ring-inner { width: 82px; height: 82px; border-radius: 50%; border: 1px solid #0c7a57; color: #0c7a57; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; line-height: 1.1; }
.ring-inner span { font-size: 9px; font-weight: 700; letter-spacing: 1px; }
.ring-inner strong { font-size: 13px; font-weight: 800; }
</style></head>
<body onload="window.print()">
<div class="head"><h1>EDR Freight</h1><p>Last Mile Trip Slip</p></div>
<div class="meta"><span>${escapeHtml(job.bookingRef)}</span><span>${escapeHtml(job.requestedDate)}</span></div>
<table>${rows}</table>
<div class="ack">Acknowledgement</div>
<div class="signs">${signature("Driver", false)}${signature("Operator", true)}</div>
</body></html>`;
};
const LastMilePage = () => {
const { toast } = useToast();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [jobs, setJobs] = useState<LastMileJob[]>(PLACEHOLDER_JOBS);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [assignOpen, setAssignOpen] = useState(false);
const [bulkMode, setBulkMode] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [tripSlipOpen, setTripSlipOpen] = useState(false);
const [tripSlipJob, setTripSlipJob] = useState<LastMileJob | null>(null);
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
const activeJob = useMemo(
() => jobs.find((job) => job.id === activeJobId) ?? null,
[jobs, activeJobId],
);
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
);
const matchesStatusFilter = (job: LastMileJob) => {
switch (statusFilter) {
case "ALL":
return true;
case "ASSIGNED":
case "UNASSIGNED":
return job.status === statusFilter;
default:
return job.deliveryStatus === statusFilter;
}
};
const statusCounts = useMemo(() => {
const counts: Record<StatusFilter, number> = {
ALL: jobs.length,
PAYMENT_PENDING: 0,
READY_TO_TRANSIT: 0,
DELIVERED: 0,
ASSIGNED: 0,
UNASSIGNED: 0,
};
for (const job of jobs) {
counts[job.deliveryStatus] += 1;
counts[job.status] += 1;
}
return counts;
}, [jobs]);
const filteredJobs = useMemo(() => {
const term = search.trim().toLowerCase();
return jobs.filter((job) => {
if (!matchesStatusFilter(job)) return false;
if (!term) return true;
return [job.bookingRef, job.customer, job.destination, job.cargo]
.join(" ")
.toLowerCase()
.includes(term);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobs, search, statusFilter]);
const pageCount = Math.max(1, Math.ceil(filteredJobs.length / pagination.pageSize));
const pagedJobs = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredJobs.slice(start, start + pagination.pageSize);
}, [filteredJobs, pagination.pageIndex, pagination.pageSize]);
const openAssign = (jobId: string | null) => {
const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null;
setBulkMode(false);
setActiveJobId(resolved);
setVehicleValue(null);
setAssignOpen(true);
};
const openBulkAssign = () => {
setBulkMode(true);
setActiveJobId(null);
setVehicleValue(null);
setAssignOpen(true);
};
const openDetail = (jobId: string) => {
setActiveJobId(jobId);
setDetailOpen(true);
};
const closeAssign = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveJobId(null);
setVehicleValue(null);
};
const closeDetail = () => {
setDetailOpen(false);
setActiveJobId(null);
};
const handleAssign = () => {
if (!vehicleValue) {
toast({
title: "Select a vehicle",
description: "Choose a vehicle to assign to this delivery.",
variant: "destructive",
});
return;
}
const vehicleLabel =
VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue;
const targetIds = bulkMode
? selectedIds
: [activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id].filter(
(id): id is string => Boolean(id),
);
if (!targetIds.length) return;
const targetSet = new Set(targetIds);
setJobs((current) =>
current.map((job) =>
targetSet.has(job.id)
? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel }
: job,
),
);
toast({
title: "Vehicle assigned",
description: bulkMode
? `${targetIds.length} deliveries → ${vehicleLabel}`
: vehicleLabel,
});
if (bulkMode) setRowSelection({});
closeAssign();
};
const handleAdvanceStatus = (job: LastMileJob) => {
const next = NEXT_DELIVERY_STATUS[job.deliveryStatus];
if (!next) return;
setJobs((current) =>
current.map((item) =>
item.id === job.id ? { ...item, deliveryStatus: next } : item,
),
);
toast({
title: "Status updated",
description: `${job.bookingRef}${DELIVERY_STATUS_META[next].label}`,
});
};
const handlePrintTripSlip = (job: LastMileJob) => {
setTripSlipJob(job);
setTripSlipOpen(true);
};
const printTripSlip = () => {
if (!tripSlipJob) return;
const win = window.open("", "_blank", "width=820,height=920");
if (!win) {
toast({
title: "Pop-up blocked",
description: "Allow pop-ups to print the trip slip.",
variant: "destructive",
});
return;
}
win.document.write(buildTripSlipHtml(tripSlipJob));
win.document.close();
};
const columns = useMemo((): ColumnDef<LastMileJob>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "select",
size: 40,
meta: { headerClassName, cellClassName },
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={table.getIsAllPageRowsSelected()}
indeterminate={
table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
}
onChange={(e) => table.toggleAllPageRowsSelected(e.currentTarget.checked)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: "bookingRef",
header: "Booking",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600}>
{row.original.bookingRef}
</Text>
),
},
{
id: "customer",
header: "Customer",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.customer,
},
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.destination,
},
{
id: "cargo",
header: "Cargo",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.cargo,
},
{
id: "price",
header: "Price",
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatPrice(row.original.price),
},
{
id: "vehicle",
header: "Vehicle",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.assignedVehicle ?? <Text c="dimmed"></Text>,
},
{
id: "deliveryStatus",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const meta = DELIVERY_STATUS_META[row.original.deliveryStatus];
return (
<Badge color={meta.color} variant="light" size="sm">
{meta.label}
</Badge>
);
},
},
{
id: "status",
header: "Assignment",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge
color={row.original.status === "ASSIGNED" ? "green" : "orange"}
variant="light"
size="sm"
>
{row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"}
</Badge>
),
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const isAssigned = row.original.status === "ASSIGNED";
const nextStatus = NEXT_DELIVERY_STATUS[row.original.deliveryStatus];
const canPrintTripSlip =
row.original.deliveryStatus === "READY_TO_TRANSIT" ||
row.original.deliveryStatus === "DELIVERED";
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Menu position="bottom-end" width={200} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Delivery actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
disabled={!nextStatus}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus
? `Mark ${DELIVERY_STATUS_META[nextStatus].label}`
: "Delivered"}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Truck size={15} />}
disabled={isAssigned}
onClick={() => openAssign(row.original.id)}
>
Assign
</Menu.Item>
<Menu.Item
leftSection={<RefreshCw size={15} />}
disabled={!isAssigned}
onClick={() => openAssign(row.original.id)}
>
Reassign
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() => openDetail(row.original.id)}
>
View detail
</Menu.Item>
{canPrintTripSlip && (
<Menu.Item
leftSection={<Printer size={15} />}
onClick={() => handlePrintTripSlip(row.original)}
>
Print trip slip
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
}, []);
const tableStatus = "success" as const;
return (
<Stack gap="md">
<Card
radius="lg"
padding={0}
withBorder
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<TextInput
placeholder="Search deliveries…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
<Group gap="sm">
{selectedIds.length > 0 && (
<Button
variant="light"
leftSection={<Truck size={16} />}
onClick={openBulkAssign}
>
Assign vehicle ({selectedIds.length})
</Button>
)}
<Button
leftSection={<Truck size={16} />}
onClick={() => openAssign(null)}
>
Assign Mile
</Button>
</Group>
</Group>
<Group gap="xs" wrap="wrap">
{FILTER_OPTIONS.map((option) => {
const active = statusFilter === option.value;
return (
<Button
key={option.value}
size="xs"
variant={active ? "filled" : "default"}
onClick={() => {
setStatusFilter(option.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
{option.label} ({statusCounts[option.value]})
</Button>
);
})}
</Group>
</Stack>
</Box>
<DataTable
columns={columns}
data={pagedJobs}
status={tableStatus}
emptyMessage="No last-mile deliveries found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredJobs.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
enableRowSelection: true,
getRowId: (row) => row.id,
state: { pagination, rowSelection },
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "deliveries" } }}
/>
)}
/>
</Stack>
</Card>
<Modal
opened={assignOpen}
onClose={closeAssign}
title={<Text fw={600}>Assign Vehicle</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{bulkMode ? (
<Text size="sm">
Assigning a vehicle to{" "}
<Text span fw={600}>
{selectedIds.length}
</Text>{" "}
selected {selectedIds.length === 1 ? "delivery" : "deliveries"}.
</Text>
) : activeJob ? (
<BookingInfo job={activeJob} />
) : (
<Text size="sm" c="dimmed">
No unassigned deliveries available.
</Text>
)}
<Divider />
<Select
label="Vehicle"
placeholder="Select a vehicle"
data={VEHICLE_OPTIONS}
value={vehicleValue}
onChange={setVehicleValue}
searchable
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>
Cancel
</Button>
<Button
onClick={handleAssign}
disabled={bulkMode ? selectedIds.length === 0 : !activeJob}
>
{!bulkMode && activeJob?.status === "ASSIGNED" ? "Reassign" : "Assign"}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={detailOpen}
onClose={closeDetail}
title={<Text fw={600}>Delivery Detail</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{activeJob && <BookingInfo job={activeJob} />}
<Group justify="flex-end">
<Button variant="default" onClick={closeDetail}>
Close
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={tripSlipOpen}
onClose={() => setTripSlipOpen(false)}
title={<Text fw={600}>Trip Slip</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{tripSlipJob && <TripSlipDocument job={tripSlipJob} />}
<Divider />
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setTripSlipOpen(false)}>
Close
</Button>
<Button leftSection={<Printer size={16} />} onClick={printTripSlip}>
Print
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default LastMilePage;

View File

@@ -1,18 +1,16 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Box,
Card,
Container,
Group,
Paper,
Badge,
Badge as MantineBadge,
Select,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import { Badge as MantineBadge } from "@mantine/core";
import {
CheckCircle2,
CircleDollarSign,
@@ -22,27 +20,28 @@ import {
Search,
X,
XCircle,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import "@/components/overview/overview.css";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type {
PaymentMethod,
PaymentRow,
} from "@/services/payments.service";
import { cn } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import {
Badge,
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const STATUS_TABS = [
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
{
key: "all",
label: "All",
statuses: undefined as string | undefined,
icon: LayoutGrid,
},
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
{
key: "processing",
@@ -50,7 +49,12 @@ const STATUS_TABS = [
statuses: "processing,action-required",
icon: Loader2,
},
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
{
key: "failed",
label: "Failed",
statuses: "failed,canceled",
icon: XCircle,
},
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
] as const;
@@ -67,7 +71,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
];
const STATUS_COLORS: Record<string, string> = {
success: "green",
success: "edr-green",
processing: "yellow",
"action-required": "yellow",
failed: "red",
@@ -75,57 +79,6 @@ const STATUS_COLORS: Record<string, string> = {
refunded: "indigo",
};
function StatCard({
icon: Icon,
label,
value,
accent,
}: {
icon: LucideIcon;
label: string;
value: string | number;
accent: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 180px",
minWidth: 160,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 11,
background: `var(--mantine-color-${accent}-1)`,
color: `var(--mantine-color-${accent}-7)`,
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{label}
</Text>
</Stack>
</Group>
</Paper>
);
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
@@ -138,13 +91,14 @@ function formatDate(iso: string | null): string {
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
year: "numeric",
month: "short",
day: "numeric",
});
}
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function PaymentsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -165,23 +119,25 @@ export default function PaymentsPage() {
[query, statuses, method, pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError } = usePaymentList(filter);
const { data: summary, isLoading: summaryLoading } = usePaymentSummary();
const { data, isLoading, isError } = useQuery(
api.payments.list.queryOptions({ input: { filter } }),
);
const { data: summary, isLoading: summaryLoading } = useQuery(
api.payments.summary.queryOptions({ staleTime: 30_000 }),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
const tabCounts: Record<StatusTabKey, number | undefined> = {
all:
summary === undefined
? undefined
: (summary.success ?? 0) +
(summary.processing ?? 0) +
(summary.failed ?? 0) +
(summary.refunded ?? 0),
(summary.processing ?? 0) +
(summary.failed ?? 0) +
(summary.refunded ?? 0),
success: summary?.success,
processing: summary?.processing,
failed: summary?.failed,
@@ -248,177 +204,161 @@ export default function PaymentsPage() {
];
return (
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
<PageContainer>
<PageHeader
title="Payments"
subtitle="View and reconcile booking payment transactions."
/>
<Stack gap="lg" mt="md">
<Group grow gap="md" align="stretch" wrap="wrap">
<StatCard
icon={CircleDollarSign}
label="Total collected"
value={
summaryLoading
? "—"
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
}
accent="teal"
/>
<StatCard
icon={CheckCircle2}
label="Successful"
value={val(summary?.success)}
accent="green"
/>
<StatCard
icon={Loader2}
label="Processing"
value={val(summary?.processing)}
accent="yellow"
/>
<StatCard
icon={XCircle}
label="Failed"
value={val(summary?.failed)}
accent="red"
/>
<StatCard
icon={RotateCcw}
label="Refunded"
value={val(summary?.refunded)}
accent="indigo"
/>
</Group>
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total collected",
value: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`,
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Successful",
value: summary?.success ?? 0,
icon: CheckCircle2,
color: "edr-green",
},
{
label: "Processing",
value: summary?.processing ?? 0,
icon: Loader2,
color: "yellow",
},
{
label: "Failed",
value: summary?.failed ?? 0,
icon: XCircle,
color: "red",
},
{
label: "Refunded",
value: summary?.refunded ?? 0,
icon: RotateCcw,
color: "indigo",
},
]}
/>
<Tabs
value={statusTab}
onChange={(value) => {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
variant="pills"
color="green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{STATUS_TABS.map((t) => {
const isActive = statusTab === t.key;
const count = tabCounts[t.key];
const Icon = t.icon;
return (
<Tabs.Tab
key={t.key}
value={t.key}
leftSection={<Icon size={17} strokeWidth={1.85} />}
rightSection={
count !== undefined ? (
<MantineBadge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
styles={
isActive
? {
root: {
background: "rgba(255,255,255,0.9)",
color: "#15805f",
},
}
: undefined
}
>
{count}
</MantineBadge>
) : undefined
}
>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
<Tabs
value={statusTab}
onChange={(value) => {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
variant="pills"
color="edr-green"
keepMounted={false}
>
<Tabs.List>
{STATUS_TABS.map((t) => {
const isActive = statusTab === t.key;
const count = tabCounts[t.key];
const Icon = t.icon;
return (
<Tabs.Tab
key={t.key}
value={t.key}
leftSection={<Icon size={17} strokeWidth={1.85} />}
rightSection={
count !== undefined ? (
<MantineBadge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
>
{count}
</MantineBadge>
) : undefined
}
>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
<Card
p="md"
radius="lg"
withBorder
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
>
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search order, booking, or transaction…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
placeholder="All methods"
clearable
data={METHOD_OPTIONS}
value={method}
onChange={(value) => {
setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
radius="lg"
style={{ minWidth: 180 }}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<div style={{ overflowX: "auto" }}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search order, booking, or transaction…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
)}
footer={DataTableFooter}
/>
</div>
</Stack>
</Card>
});
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
/>
<Select
placeholder="All methods"
clearable
data={METHOD_OPTIONS}
value={method}
onChange={(value) => {
setMethod(value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
style={{ minWidth: 180 }}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Container>
</div>
</Card>
</PageContainer>
);
}

View File

@@ -1,47 +1,56 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import type { ColumnDef } from "@tanstack/react-table";
import { Loader2 } from "lucide-react";
import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
Button,
Card,
Group,
List,
Loader,
Modal,
Stack,
Text,
} from "@mantine/core";
import { Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
import {
DEFAULT_CONFIGURATION_SLUG,
DEFAULT_RULES_SLUG,
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
import {
useApprovalChain,
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
useRuleEngineOrderList,
useRuleEngineOrderMutations,
useApprovalChain,
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
useRuleEngineOrderList,
useRuleEngineOrderMutations,
} from "@/hooks/rule-engine/useRuleEngine";
import {
DEFAULT_CONFIGURATION_SLUG,
DEFAULT_RULES_SLUG,
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
import {
DataTable,
DataTableFooter,
getCoreRowModel,
usePagination,
useReactTable,
DataTable,
DataTableFooter,
usePagination,
} from "@edr/ui-common";
const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
@@ -59,16 +68,17 @@ const RuleEngineResourcePage = () => {
const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined;
const defaultPath = category
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${
category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
}`
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
}`
: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`;
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
null,
);
const [chainOpen, setChainOpen] = useState(false);
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const { viewMode, setViewMode } = useRuleEngineViewMode(
@@ -89,9 +99,9 @@ const RuleEngineResourcePage = () => {
pageSize: pagination.pageSize,
...(config?.orderConfig
? {
sortBy: config.orderConfig.field,
sortOrder: "ASC" as const,
}
sortBy: config.orderConfig.field,
sortOrder: "ASC" as const,
}
: {}),
}),
[
@@ -119,11 +129,12 @@ const RuleEngineResourcePage = () => {
const { reorder, moveOrder } = useRuleEngineOrderMutations(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(orderDialogOpen && config?.orderConfig),
config?.orderConfig?.field,
);
const { data: orderListData, isLoading: orderListLoading } =
useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(orderDialogOpen && config?.orderConfig),
config?.orderConfig?.field,
);
const { submit, approve } = useRateWorkflow();
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
chainOpen && config?.slug === "approval-rules",
@@ -140,10 +151,7 @@ const RuleEngineResourcePage = () => {
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions(
config?.slug === "rates",
usesContainerTypeField,
);
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
@@ -153,10 +161,9 @@ const RuleEngineResourcePage = () => {
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
return {
...field,
options:
cargoParentOptions ?? [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
],
options: cargoParentOptions ?? [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
],
};
}
if (field.name === "containerTypeId") {
@@ -182,14 +189,16 @@ const RuleEngineResourcePage = () => {
const pageCount = meta?.totalPages ?? 1;
const totalCount = meta?.total ?? rows.length;
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(formOpen && !editing && config?.orderConfig),
config?.orderConfig?.field,
);
const { data: createPositionList, isLoading: createPositionLoading } =
useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(formOpen && !editing && config?.orderConfig),
config?.orderConfig?.field,
);
const createPositionOptions = useMemo(() => {
if (!config?.orderConfig || !createPositionList?.data?.length) return undefined;
if (!config?.orderConfig || !createPositionList?.data?.length)
return undefined;
return createPositionList.data
.filter((row) => row.id)
.map((row) => ({
@@ -198,7 +207,6 @@ const RuleEngineResourcePage = () => {
}));
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
const handleApproveRate = useCallback(
(record: RuleEngineRecord) => {
approve.mutate(String(record.id));
@@ -258,7 +266,9 @@ const RuleEngineResourcePage = () => {
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
@@ -269,7 +279,15 @@ const RuleEngineResourcePage = () => {
});
return base;
}, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]);
}, [
canManage,
config,
submit,
handleApproveRate,
handleMoveOrder,
moveOrder.isPending,
totalCount,
]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -325,31 +343,49 @@ const RuleEngineResourcePage = () => {
};
const itemLabel = config.label.toLowerCase();
const addLabel = `Add ${config.label.replace(/s$/, "")}`;
return (
<Stack gap="lg">
<Card p="lg" radius="lg" withBorder style={{ background: "white", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)" }}>
<Stack gap="md">
<RuleEngineToolbar
search={search}
onSearchChange={
config.supportsSearch
? (v) => {
<PageContainer>
<PageHeader
title={config.label}
subtitle={config.subtitle}
action={
canManage ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
{addLabel}
</Button>
) : undefined
}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<RuleEngineToolbar
search={search}
onSearchChange={
config.supportsSearch
? (v) => {
setSearch(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}
: undefined
}
showSearch={Boolean(config.supportsSearch)}
searchPlaceholder={config.searchPlaceholder}
onAdd={canManage ? openCreate : undefined}
addLabel={`Add ${config.label.replace(/s$/, "")}`}
onManageOrder={
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
: undefined
}
showSearch={Boolean(config.supportsSearch)}
searchPlaceholder={config.searchPlaceholder}
onManageOrder={
canManage && config.orderConfig
? () => setOrderDialogOpen(true)
: undefined
}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
{viewMode === "table" ? (
<DataTable
@@ -359,10 +395,12 @@ const RuleEngineResourcePage = () => {
error={
isError
? {
message: "Failed to load data",
description:
error instanceof Error ? error.message : "Unknown error",
}
message: "Failed to load data",
description:
error instanceof Error
? error.message
: "Unknown error",
}
: undefined
}
emptyMessage={`No ${itemLabel} found.`}
@@ -378,8 +416,7 @@ const RuleEngineResourcePage = () => {
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
footerClassName="border-t border-border bg-card"
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
@@ -409,7 +446,9 @@ const RuleEngineResourcePage = () => {
onEdit={canManage ? openEdit : undefined}
onDelete={canManage ? setDeleteTarget : undefined}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
@@ -421,7 +460,11 @@ const RuleEngineResourcePage = () => {
<RuleEngineFormDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? `Edit ${config.label.replace(/s$/, "")}` : `Add ${config.label.replace(/s$/, "")}`}
title={
editing
? `Edit ${config.label.replace(/s$/, "")}`
: `Add ${config.label.replace(/s$/, "")}`
}
description={
editing
? `Update this ${config.label.toLowerCase()} record.`
@@ -465,7 +508,8 @@ const RuleEngineResourcePage = () => {
>
<Stack gap="md">
<Text size="sm">
This will soft-delete the selected {config.label.toLowerCase()} record.
This will soft-delete the selected {config.label.toLowerCase()}{" "}
record.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
@@ -473,16 +517,15 @@ const RuleEngineResourcePage = () => {
</Button>
<Button
color="red"
disabled={remove.isPending}
loading={remove.isPending}
onClick={() => {
if (!deleteTarget) return;
remove.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
}}
leftSection={remove.isPending && <Loader2 size={16} />}
>
{remove.isPending ? "Deleting..." : "Delete"}
Delete
</Button>
</Group>
</Stack>
@@ -498,19 +541,22 @@ const RuleEngineResourcePage = () => {
<Stack gap="md">
{chainLoading ? (
<Group justify="center" p="xl">
<Loader2 size={32} style={{ animation: "spin 1s linear infinite" }} />
<Loader />
</Group>
) : (
<>
{(chainData ?? []).length === 0 ? (
<Text size="sm" c="dimmed">No approval rules configured.</Text>
<Text size="sm" c="dimmed">
No approval rules configured.
</Text>
) : (
<List spacing="md">
{(chainData ?? []).map((step, index) => (
<List.Item key={String(step.id ?? index)}>
<Stack gap="xs">
<Text size="sm" fw={500}>
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
Step {String(step.stepOrder ?? index + 1)}:{" "}
{String(step.actionLabel ?? "")}
</Text>
<Text size="sm" c="dimmed">
Role: {String(step.requiredRole ?? "—")}
@@ -524,7 +570,7 @@ const RuleEngineResourcePage = () => {
)}
</Stack>
</Modal>
</Stack>
</PageContainer>
);
};

View File

@@ -1,12 +1,13 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Alert,
Box,
Button,
Card,
Container,
Group,
Menu,
Paper,
RingProgress,
Select,
@@ -21,7 +22,9 @@ import {
ArrowRight,
CalendarClock,
CalendarDays,
Eye,
Inbox,
MoreHorizontal,
Package,
Ruler,
Train,
@@ -31,7 +34,7 @@ import {
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import {
@@ -40,10 +43,11 @@ import {
totalBookingCount,
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import type { BatchBoardSchedule } from "@/types/trainScheduling";
const fmtTons = (n: number) =>
@@ -365,7 +369,9 @@ function CardSkeleton() {
export default function BatchBoardPage() {
const navigate = useNavigate();
const { data, isLoading, isFetching, refetch } = useBatchBoard();
const { data, isLoading, isError, isFetching, refetch } = useQuery(
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 30_000 }),
);
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
@@ -513,12 +519,12 @@ export default function BatchBoardPage() {
style={{
padding: "2px 8px",
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
background: "var(--mantine-color-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8" lh={1.2}>
<Package size={12} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
{row.original.capacity.allocatedWagons}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
@@ -549,72 +555,69 @@ export default function BatchBoardPage() {
header: "",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group justify="flex-end" wrap="nowrap">
<Button
variant="light"
color="green"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
}
>
View windows
</Button>
<Group justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal shadow="md" width={180}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Row actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
}
>
View windows
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
),
},
];
}, [navigate]);
const tableStatus = isLoading ? "loading" : "success";
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<Container fluid py="lg" px="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
<PageContainer>
<PageHeader
title="Batch Board"
subtitle="Active schedules and their booking windows."
action={
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
Refresh
</Button>
}
/>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md">
<StatTile
icon={Train}
label="Active schedules"
value={isLoading ? "…" : schedules.length}
hint="on the board right now"
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={CalendarDays}
label="Open windows"
value={isLoading ? "…" : summary.openWindows}
hint="accepting bookings"
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/>
<StatTile
icon={Package}
label="Bookings in play"
value={isLoading ? "…" : summary.totalBookings}
hint={`${summary.totalWagons} wagons allocated`}
accent="#F2A516"
graph="ring"
graphAccent="gold"
graphPct={
schedules.length
? Math.min(100, Math.round((summary.openWindows / schedules.length) * 100))
: 0
}
/>
</SimpleGrid>
<KpiStrip
loading={isLoading}
items={[
{
label: "Active schedules",
value: schedules.length,
hint: "on the board right now",
icon: Train,
},
{
label: "Open windows",
value: summary.openWindows,
hint: "accepting bookings",
icon: CalendarDays,
},
{
label: "Bookings in play",
value: summary.totalBookings,
hint: `${summary.totalWagons} wagons allocated`,
icon: Package,
},
]}
/>
<Card
radius="lg"
padding={0}
withBorder
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
@@ -647,6 +650,17 @@ export default function BatchBoardPage() {
columns={columns}
data={paged}
status={tableStatus}
onRowClick={(schedule) =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
error={
isError
? {
message: "Failed to load the batch board.",
onRetry: () => void refetch(),
}
: undefined
}
emptyMessage="No active schedules"
pagination={{
pageIndex: pagination.pageIndex,
@@ -688,7 +702,6 @@ export default function BatchBoardPage() {
borderRadius: 20,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
}}
>
<Inbox size={28} color="var(--mantine-color-gray-5)" />
@@ -711,18 +724,6 @@ export default function BatchBoardPage() {
)}
</Stack>
</Card>
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
color="gray"
size="xs"
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
</Container>
</PageContainer>
);
}

View File

@@ -24,10 +24,12 @@ import {
Title,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
import { freightBrand } from "@/theme/freight-brand";
import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => {
@@ -62,7 +64,7 @@ function MetaStat({
}) {
return (
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={32} radius="md" variant="light" color="green">
<ThemeIcon size={32} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
@@ -80,23 +82,34 @@ function MetaStat({
export default function TrainScheduleTrackPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const trackQuery = useTrainTrack(scheduleId);
const { recordCheckpoint } = useScheduleMutations(scheduleId);
const trackQuery = useQuery(
api.trainScheduling.trainTrack.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId),
}),
);
const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
if (trackQuery.isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" color="green" />
</Group>
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
</PageContainer>
);
}
const track = trackQuery.data;
if (!track || !scheduleId) {
return (
<Text c="dimmed" py="xl">
Tracking data not found.
</Text>
<PageContainer>
<Text c="dimmed" py="xl">
Tracking data not found.
</Text>
</PageContainer>
);
}
@@ -130,7 +143,7 @@ export default function TrainScheduleTrackPage() {
};
return (
<Stack gap="md" px={{ base: "xs", sm: 0 }} py="md" maw={1080} mx="auto" w="100%">
<PageContainer>
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
@@ -144,7 +157,7 @@ export default function TrainScheduleTrackPage() {
</Button>
{/* Header */}
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Paper radius="lg" withBorder p="lg">
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
@@ -169,7 +182,7 @@ export default function TrainScheduleTrackPage() {
Train tracking
</Title>
{track.trainNumber ? (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{track.trainNumber}
</Badge>
) : null}
@@ -193,7 +206,7 @@ export default function TrainScheduleTrackPage() {
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
Journey progress
</Text>
<Text size="xs" fw={700} c="green.8">
<Text size="xs" fw={700} c="edr-green.8">
{reached} / {totalStations} stations · {Math.round(clampedPct)}%
</Text>
</Group>
@@ -201,7 +214,7 @@ export default function TrainScheduleTrackPage() {
value={clampedPct}
size="lg"
radius="xl"
color="green"
color="edr-green"
striped={track.status === "DISPATCHED"}
animated={track.status === "DISPATCHED"}
/>
@@ -230,10 +243,10 @@ export default function TrainScheduleTrackPage() {
</Paper>
{/* Corridor */}
<Paper radius="lg" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Paper radius="lg" p="lg" withBorder>
<Stack gap="md">
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color="green">
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
<Navigation size={17} />
</ThemeIcon>
<Stack gap={0}>
@@ -264,9 +277,9 @@ export default function TrainScheduleTrackPage() {
</Paper>
{/* Checkpoint log */}
<Paper radius="lg" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Paper radius="lg" p="lg" withBorder>
<Group gap="sm" align="center" wrap="nowrap" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="green">
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={17} />
</ThemeIcon>
<Stack gap={0}>
@@ -292,7 +305,7 @@ export default function TrainScheduleTrackPage() {
</Text>
</Stack>
) : (
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green">
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="edr-green">
{track.checkpoints.map((cp) => (
<Timeline.Item
key={cp.id}
@@ -306,7 +319,7 @@ export default function TrainScheduleTrackPage() {
size="xs"
radius="sm"
variant="light"
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"}
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "edr-green"}
>
{cp.kind}
</Badge>
@@ -326,6 +339,6 @@ export default function TrainScheduleTrackPage() {
</Timeline>
)}
</Paper>
</Stack>
</PageContainer>
);
}

View File

@@ -1,5 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Paper,
RingProgress,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { isAxiosError } from "axios";
import {
ArrowLeft,
@@ -15,53 +27,37 @@ import {
Train,
Weight,
} from "lucide-react";
import {
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Paper,
RingProgress,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { KpiStrip, PageContainer } from "@/components/page";
import {
autoFillPlacements,
mergePlacementsWithSaved,
placementsFromScheduleWagons,
validateLocalPlacements,
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
useEligibleBookings,
useScheduleDetail,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
ContainerPlacement,
@@ -92,7 +88,12 @@ export default function TrainScheduleV2DetailPage() {
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const autoPreviewedRef = useRef(false);
const detailQuery = useScheduleDetail(scheduleId);
const detailQuery = useQuery(
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId),
}),
);
const schedule = detailQuery.data;
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
@@ -112,12 +113,17 @@ export default function TrainScheduleV2DetailPage() {
const eligibleFreightType =
freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined;
const eligibleQuery = useEligibleBookings(
eligibleFilters,
Boolean(schedule),
eligibleFreightType,
const eligibleQuery = useQuery(
api.trainScheduling.eligibleBookings.queryOptions({
input: { filters: eligibleFilters, freightType: eligibleFreightType },
enabled: Boolean(schedule),
}),
);
const { preview, assign, unassign, finalize, dispatch } = useScheduleMutations(scheduleId);
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const assignedIds = useMemo(
() => (schedule?.bookings ?? []).map((b) => b.id),
@@ -267,17 +273,21 @@ export default function TrainScheduleV2DetailPage() {
if (detailQuery.isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
if (!schedule || !scheduleId) {
return (
<Text c="dimmed" py="xl">
Schedule not found
</Text>
<PageContainer>
<Text c="dimmed" py="xl">
Schedule not found
</Text>
</PageContainer>
);
}
@@ -405,7 +415,7 @@ export default function TrainScheduleV2DetailPage() {
return (
<Badge
variant="light"
color={previewResult.valid ? "green" : "red"}
color={previewResult.valid ? "edr-green" : "red"}
radius="sm"
>
{previewResult.valid ? "Plan valid" : "Has issues"}
@@ -413,14 +423,14 @@ export default function TrainScheduleV2DetailPage() {
);
}
return allSelectedIds.length ? (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{allSelectedIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
@@ -429,7 +439,7 @@ export default function TrainScheduleV2DetailPage() {
return (
<Badge
variant="light"
color={containerComplete ? "green" : "yellow"}
color={containerComplete ? "edr-green" : "yellow"}
radius="sm"
>
{containerUnits.length} units
@@ -485,7 +495,7 @@ export default function TrainScheduleV2DetailPage() {
size="sm"
/>
<Button
color="green"
color="edr-green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
@@ -532,7 +542,7 @@ export default function TrainScheduleV2DetailPage() {
<Group>
{!hasContainerStep ? (
<Button
color="green"
color="edr-green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
@@ -541,7 +551,7 @@ export default function TrainScheduleV2DetailPage() {
</Button>
) : (
<Button
color="green"
color="edr-green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
@@ -578,7 +588,7 @@ export default function TrainScheduleV2DetailPage() {
{canEditBookings ? (
<Group>
<Button
color="green"
color="edr-green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
@@ -629,7 +639,7 @@ export default function TrainScheduleV2DetailPage() {
<Text fw={600}>Ready to depart</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan and moves the schedule to{" "}
<Text span fw={600} c="green.7">
<Text span fw={600} c="edr-green.7">
SCHEDULED
</Text>
. Dispatch then begins rail movement and notifies the yard.
@@ -640,7 +650,7 @@ export default function TrainScheduleV2DetailPage() {
<Group>
{canFinalize ? (
<Button
color="green"
color="edr-green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
@@ -663,7 +673,7 @@ export default function TrainScheduleV2DetailPage() {
) : null}
{canDispatch ? (
<Button
color="green"
color="edr-green"
size="md"
radius="md"
leftSection={<Send size={18} />}
@@ -695,7 +705,7 @@ export default function TrainScheduleV2DetailPage() {
};
return (
<Stack gap="lg">
<PageContainer>
<Button
component={Link}
to="/dashboard/operations/train-scheduling-v2"
@@ -711,13 +721,7 @@ export default function TrainScheduleV2DetailPage() {
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
@@ -758,7 +762,7 @@ export default function TrainScheduleV2DetailPage() {
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
color="green"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Navigation size={16} />}
@@ -779,63 +783,12 @@ export default function TrainScheduleV2DetailPage() {
</Group>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
icon={Train}
label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"}
hint={
schedule.trainSet?.locomotive?.currentYardId
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
: "Not at schedule origin yard"
: "No current yard set"
}
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={Package}
label="Bookings"
value={schedule.bookings?.length ?? 0}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/>
<StatTile
icon={Weight}
label="Wagons / load"
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
schedule.trainSet?.totalWeightTons ?? 0
}T`}
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={CalendarClock}
label="Departure"
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
"en",
{ month: "short", day: "2-digit" },
)}
hint={new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", {
hour: "2-digit",
minute: "2-digit",
})}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
/>
</SimpleGrid>
{previewResult ? (
<Badge
size="lg"
radius="sm"
variant="light"
color={previewResult.valid ? "green" : "red"}
color={previewResult.valid ? "edr-green" : "red"}
leftSection={
<Box
w={8}
@@ -843,7 +796,7 @@ export default function TrainScheduleV2DetailPage() {
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
@@ -855,17 +808,51 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<KpiStrip
items={[
{
label: "Locomotive",
value: schedule.trainSet?.locomotive?.code ?? "—",
hint: schedule.trainSet?.locomotive?.currentYardId
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
: "Not at schedule origin yard"
: "No current yard set",
icon: Train,
},
{
label: "Bookings",
value: schedule.bookings?.length ?? 0,
icon: Package,
},
{
label: "Wagons / load",
value: `${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
schedule.trainSet?.totalWeightTons ?? 0
}T`,
icon: Weight,
},
{
label: "Departure",
value: new Date(schedule.scheduledDepartureDate).toLocaleDateString("en", {
month: "short",
day: "2-digit",
}),
hint: new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", {
hour: "2-digit",
minute: "2-digit",
}),
icon: CalendarClock,
},
]}
/>
<Paper radius="xl" p="lg">
<Stack gap="lg">
{/* Workflow header with ring progress */}
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
>
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<RouteIcon size={22} />
</ThemeIcon>
<Stack gap={2}>
@@ -882,9 +869,9 @@ export default function TrainScheduleV2DetailPage() {
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
sections={[{ value: progressPct, color: "edr-green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
<Text ta="center" size="xs" fw={700} c="edr-green.7">
{progressPct}%
</Text>
}
@@ -928,6 +915,6 @@ export default function TrainScheduleV2DetailPage() {
onComplete={() => void detailQuery.refetch()}
/>
) : null}
</Stack>
</PageContainer>
);
}

View File

@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common";
import {
ActionIcon,
Box,
Button,
Card,
Group,
Menu,
Modal,
Select,
SimpleGrid,
@@ -15,25 +14,33 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { ArrowRight, CalendarClock, Navigation, Send, Train, Weight } from "lucide-react";
import { isAxiosError } from "axios";
import {
ArrowRight,
Ban,
CalendarClock,
Eye,
MoreHorizontal,
Navigation,
Send,
Train,
Weight,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRoutes } from "@/hooks/useRoutes";
import {
useAvailableLocomotives,
useScheduleList,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
@@ -77,10 +84,17 @@ export default function TrainScheduleV2ListPage() {
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveId, setLocomotiveId] = useState("");
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives(routeId || undefined);
const { create, cancel } = useScheduleMutations();
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
);
const routesQuery = useQuery(api.routes.list.queryOptions());
const locomotivesQuery = useQuery(
api.trainScheduling.availableLocomotives.queryOptions({
input: { routeId: routeId || undefined },
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.isActive),
@@ -169,8 +183,8 @@ export default function TrainScheduleV2ListPage() {
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-green-0)",
color: "var(--mantine-color-green-7)",
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
flexShrink: 0,
}}
>
@@ -251,63 +265,67 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "actions",
header: "Actions",
size:32,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group gap={6} justify="flex-end" wrap="nowrap">
<Button
variant="light"
color="green"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`)
}
>
Open
</Button>
{["DISPATCHED", "ARRIVED"].includes(row.original.status) ? (
<Button
variant="light"
color="teal"
size="compact-sm"
leftSection={<Navigation size={14} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${row.original.id}/track`,
)
}
>
Track
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
<Button
variant="subtle"
color="red"
size="compact-sm"
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({
id: row.original.id,
freightType: row.original.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
>
Cancel
</Button>
) : null}
</Group>
),
cell: ({ row }) => {
const schedule = row.original;
return (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Row actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
>
View detail
</Menu.Item>
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<Navigation size={15} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
}
>
Track
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
color="red"
leftSection={<Ban size={15} />}
onClick={async () => {
try {
await cancel.mutateAsync({
id: schedule.id,
freightType: schedule.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
>
Cancel schedule
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
}, [navigate, cancel.isPending, cancel, toast]);
@@ -340,65 +358,33 @@ export default function TrainScheduleV2ListPage() {
: "success";
return (
<Stack gap="lg">
<Group justify="flex-end">
<Button
size="md"
radius="lg"
color="green"
leftSection={<Train size={18} />}
onClick={() => setCreateOpen(true)}
>
New schedule
</Button>
</Group>
<PageContainer>
<PageHeader
title="Train Schedules"
subtitle="Operational train scheduling with full allocation workflow."
action={
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
New schedule
</Button>
}
/>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
icon={Train}
label="Total trains"
value={stats.total}
accent="#F2A516"
graph="area"
graphAccent="gold"
/>
<StatTile
icon={CalendarClock}
label="Scheduled"
value={stats.scheduled}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
graphPct={stats.total ? Math.round((stats.scheduled / stats.total) * 100) : 0}
/>
<StatTile
icon={Send}
label="Dispatched"
value={stats.dispatched}
accent="#F2A516"
graph="ring"
graphAccent="gold"
graphPct={stats.total ? Math.round((stats.dispatched / stats.total) * 100) : 0}
/>
<StatTile
icon={Weight}
label="Planned load"
value={`${Math.round(stats.weight)}T`}
accent="#FB8C2E"
graph="area"
graphAccent="orange"
/>
</SimpleGrid>
<KpiStrip
items={[
{ label: "Total trains", value: stats.total, icon: Train },
{ label: "Scheduled", value: stats.scheduled, icon: CalendarClock },
{ label: "Dispatched", value: stats.dispatched, icon: Send },
{ label: "Planned load", value: `${Math.round(stats.weight)}T`, icon: Weight },
]}
/>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search schedules…"
addLabel="Create schedule"
onAdd={() => setCreateOpen(true)}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
@@ -442,6 +428,17 @@ export default function TrainScheduleV2ListPage() {
columns={columns}
data={paged}
status={tableStatus}
onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
error={
schedulesQuery.isError
? {
message: "Failed to load train schedules.",
onRetry: () => void schedulesQuery.refetch(),
}
: undefined
}
emptyMessage="No train schedules found"
pagination={{
pageIndex: pagination.pageIndex,
@@ -554,13 +551,13 @@ export default function TrainScheduleV2ListPage() {
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button color="green" loading={create.isPending} onClick={handleCreate}>
<Button loading={create.isPending} onClick={handleCreate}>
Create
</Button>
</Group>
</Stack>
</Modal>
</Stack>
</PageContainer>
);
}
@@ -582,13 +579,13 @@ function MetricChip({
borderRadius: 8,
background: subtle
? "var(--mantine-color-gray-1)"
: "var(--mantine-color-green-0)",
: "var(--mantine-color-edr-green-0)",
border: `1px solid ${
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-green-1)"
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-edr-green-1)"
}`,
}}
>
<Text size="sm" fw={700} c={subtle ? "gray.7" : "green.8"} lh={1.2}>
<Text size="sm" fw={700} c={subtle ? "gray.7" : "edr-green.8"} lh={1.2}>
{value}
</Text>
{label ? (
@@ -613,31 +610,15 @@ function ScheduleCard({
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
return (
<Card
radius="lg"
padding={0}
withBorder
onClick={onOpen}
style={{
cursor: "pointer",
overflow: "hidden",
borderColor: "var(--mantine-color-gray-2)",
transition: "box-shadow 150ms ease, transform 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = scheduleBrand.shadowSm;
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "";
e.currentTarget.style.transform = "";
}}
className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
{/* accent strip */}
<Box style={{ height: 4, background: "linear-gradient(90deg, #FBD171, #F2A516)" }} />
<Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
<ThemeIcon size={38} radius="md" variant="light" color="edr-green">
<Train size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
@@ -675,7 +656,7 @@ function ScheduleCard({
<Group gap="xs" wrap="nowrap">
<Button
variant="light"
color="green"
color="edr-green"
size="sm"
radius="md"
style={{ flex: 1 }}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { Button, Card, Group, NumberInput, Stack, Text, Title } from "@mantine/core";
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
import { PageContainer, PageHeader } from "@/components/page";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
@@ -44,15 +45,13 @@ export default function TrainSchedulingGlobalRulesPage() {
};
return (
<Stack gap="lg" maw={720}>
<Stack gap={4}>
<Title order={3}>Train scheduling rules</Title>
<Text size="sm" c="dimmed">
Global limits applied when previewing and assigning bookings to trains.
</Text>
</Stack>
<PageContainer>
<PageHeader
title="Train scheduling rules"
subtitle="Global limits applied when previewing and assigning bookings to trains."
/>
<Card radius="xl" padding="lg" withBorder>
<Card maw={720}>
<Stack gap="md">
<NumberInput
label="Max train length (m)"
@@ -110,12 +109,12 @@ export default function TrainSchedulingGlobalRulesPage() {
disabled={loading}
/>
<Group justify="flex-end">
<Button color="teal" loading={saving} disabled={loading} onClick={() => void handleSave()}>
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules
</Button>
</Group>
</Stack>
</Card>
</Stack>
</PageContainer>
);
}

View File

@@ -2,13 +2,17 @@ import { useParams, Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Badge, Button, Card, Group, Loader, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AssignWagonDialog } from "@/components/wagons/AssignWagonDialog";
import { WagonsTable } from "@/components/wagons/WagonsTable";
import { useTrain } from "@/hooks/useTrains";
import { api } from "@/services/api";
export default function TrainDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: train, isLoading } = useTrain(id!);
const { data: train, isLoading } = useQuery(
api.trains.getById.queryOptions({ input: { id: id ?? "" }, enabled: !!id }),
);
if (isLoading) {
return (

View File

@@ -12,6 +12,7 @@ import {
} from '@mantine/core';
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
VisualEmptyState,
@@ -78,13 +79,17 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
{item.bookingReference ?? item.bookingId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{item.customerName ?? ''}</Table.Td>
<Table.Td>{item.containerNumber ?? ''}</Table.Td>
<Table.Td>{item.cargoType ?? ''}</Table.Td>
<Table.Td>{item.customerName ?? '-'}</Table.Td>
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
<Table.Td>{item.cargoType ?? '-'}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
<Table.Td>
<Badge variant="light" color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'} size="sm">
<Badge
variant="light"
color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'}
size="sm"
>
{item.currentStatus ?? 'PENDING'}
</Badge>
</Table.Td>
@@ -107,7 +112,9 @@ export default function ArrivalQueuePage() {
const unloadTrain = async (train: ImportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
const res = (await autoUnload.mutateAsync(train.scheduleId)) as {
data: AutoUnloadArrivedResult;
};
const result = res.data;
const details = [
result.skippedCount ? `${result.skippedCount} skipped` : '',
@@ -136,6 +143,11 @@ export default function ArrivalQueuePage() {
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
<Stack gap="lg" mt="sm">
<PageHeader
title="Arrival / Unloading Queue"
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
/>
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
@@ -187,16 +199,16 @@ export default function ArrivalQueuePage() {
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={700}>
{train.trainNumber ?? ''}
{train.trainNumber ?? '-'}
</Text>
<Text size="xs" c="dimmed">
{train.scheduleId.slice(0, 8)}
</Text>
</Stack>
</Table.Td>
<Table.Td>{train.route ?? ''}</Table.Td>
<Table.Td>{train.origin ?? ''}</Table.Td>
<Table.Td>{train.destination ?? ''}</Table.Td>
<Table.Td>{train.route ?? '-'}</Table.Td>
<Table.Td>{train.origin ?? '-'}</Table.Td>
<Table.Td>{train.destination ?? '-'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(train.arrivalTime)}</Text>
</Table.Td>
@@ -213,7 +225,9 @@ export default function ArrivalQueuePage() {
<Button
size="compact-xs"
variant="light"
leftSection={isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
leftSection={
isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
>
Open
@@ -221,7 +235,13 @@ export default function ArrivalQueuePage() {
<Button
size="compact-xs"
color="orange"
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
leftSection={
busyScheduleId === train.scheduleId ? (
<PackageOpen size={14} />
) : (
<Truck size={14} />
)
}
loading={busyScheduleId === train.scheduleId}
onClick={() => unloadTrain(train)}
>

View File

@@ -1,38 +1,36 @@
import { Card, Container, Stack } from '@mantine/core';
import { Card } from '@mantine/core';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
import { useWarehouseInventory } from '@/hooks/useWarehouses';
import { PageContainer, PageHeader } from '@/components/page';
import { useQuery } from '@tanstack/react-query';
import { InventoryWorkbench, VisualEmptyState } from '@/components/warehouses';
import { api } from '@/services/api';
/** Items that are LOADED and awaiting dispatch (train departure). */
export default function DispatchQueuePage() {
const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' });
const { data, isLoading } = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { status: 'LOADED' } } }),
);
const items = data ?? [];
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Dispatch queue' }]} />
<PageContainer>
<PageHeader
title="Dispatch Queue"
subtitle="Loaded inventory awaiting train departure. Mark items dispatched once they leave."
/>
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="train"
secondaryVariant="route"
title="Dispatch Queue"
subtitle="Loaded inventory awaiting train departure. Mark items dispatched once they leave."
/>
<Card withBorder radius="md" padding="lg">
{!isLoading && items.length === 0 ? (
<VisualEmptyState
variant="train"
title="Nothing to dispatch"
description="Loaded items appear here, ready to mark as dispatched."
/>
) : (
<InventoryWorkbench items={items} isLoading={isLoading} />
)}
</Card>
</Stack>
</Container>
<Card>
{!isLoading && items.length === 0 ? (
<VisualEmptyState
variant="train"
title="Nothing to dispatch"
description="Loaded items appear here, ready to mark as dispatched."
/>
) : (
<InventoryWorkbench items={items} isLoading={isLoading} />
)}
</Card>
</PageContainer>
);
}

View File

@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react';
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@mantine/core';
import { Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { PageContainer, PageHeader } from '@/components/page';
import {
InventoryInquiryDetailModal,
VisualEmptyState,
@@ -73,17 +73,13 @@ export default function InventoryInquiryPage() {
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Inventory inquiry' }]} />
<PageContainer>
<PageHeader
title="Inventory Inquiry"
subtitle="Locate any cargo, container or goods inside the warehouse network."
/>
<Stack gap="lg" mt="sm">
<div>
<Title order={2}>Inventory Inquiry</Title>
<Text c="dimmed" size="sm">
Locate any cargo, container or goods inside the warehouse network.
</Text>
</div>
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
@@ -186,6 +182,6 @@ export default function InventoryInquiryPage() {
onClose={() => setViewResult(null)}
result={viewResult}
/>
</Container>
</PageContainer>
);
}

View File

@@ -1,86 +1,96 @@
import { Badge, Card, Container, Group, Loader, Stack, Table, Text } from '@mantine/core';
import { Badge, Card, Group, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { FreightVisual, VisualEmptyState, WarehouseHero, formatDate, formatNumber } from '@/components/warehouses';
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
import { PageContainer, PageHeader } from '@/components/page';
import { useQuery } from '@tanstack/react-query';
import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
import { api } from '@/services/api';
import type { WarehouseLoading } from '@/types/warehouse';
type Loading = WarehouseLoading;
const columns: ColumnDef<Loading>[] = [
{
id: 'wagon',
header: 'Wagon',
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<FreightVisual variant="wagon" size={22} />
<Text fw={600} size="sm">
{row.original.wagonNumber ?? row.original.wagonId.slice(0, 8)}
</Text>
</Group>
),
},
{
id: 'warehouse',
header: 'Warehouse',
cell: ({ row }) =>
row.original.inventory?.warehouse
? `${row.original.inventory.warehouse.name} (${row.original.inventory.warehouse.code})`
: '—',
},
{
id: 'zone',
header: 'Zone',
cell: ({ row }) => (row.original.inventory?.zone ? row.original.inventory.zone.name : '—'),
},
{
id: 'weight',
header: 'Loaded Weight (kg)',
cell: ({ row }) => formatNumber(row.original.loadedWeight),
},
{
id: 'loadedAt',
header: 'Loaded At',
cell: ({ row }) => formatDate(row.original.loadedAt),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge
variant="light"
color={row.original.inventory?.status === 'DISPATCHED' ? 'edr-green' : 'teal'}
size="sm"
>
{row.original.inventory?.status ?? 'LOADED'}
</Badge>
),
},
];
/** Record of every inventory item loaded onto a wagon. */
export default function LoadedInventoryPage() {
const { data, isLoading } = useWarehouseLoadings();
const { data, isLoading } = useQuery(
api.warehouses.loadings.queryOptions({ input: {} }),
);
const loadings = data ?? [];
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Loaded inventory' }]} />
<PageContainer>
<PageHeader
title="Loaded Inventory"
subtitle="Items loaded onto wagons, with their loading records."
/>
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="wagon"
title="Loaded Inventory"
subtitle="Items loaded onto wagons, with their loading records."
/>
<Card withBorder radius="md" padding="lg">
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : loadings.length === 0 ? (
<VisualEmptyState
variant="container"
title="No loaded inventory yet"
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
<Table.ScrollContainer minWidth={760}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Loaded Weight (kg)</Table.Th>
<Table.Th>Loaded At</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadings.map((l) => (
<Table.Tr key={l.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FreightVisual variant="wagon" size={22} />
<Text fw={600} size="sm">
{l.wagonNumber ?? l.wagonId.slice(0, 8)}
</Text>
</Group>
</Table.Td>
<Table.Td>
{l.inventory?.warehouse
? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})`
: '—'}
</Table.Td>
<Table.Td>{l.inventory?.zone ? l.inventory.zone.name : '—'}</Table.Td>
<Table.Td>{formatNumber(l.loadedWeight)}</Table.Td>
<Table.Td>{formatDate(l.loadedAt)}</Table.Td>
<Table.Td>
<Badge
variant="light"
color={l.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
size="sm"
>
{l.inventory?.status ?? 'LOADED'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
</Container>
<Card>
{!isLoading && loadings.length === 0 ? (
<VisualEmptyState
variant="container"
title="No loaded inventory yet"
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
<DataTable
columns={columns}
data={loadings}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
/>
)}
</Card>
</PageContainer>
);
}

View File

@@ -1,16 +1,18 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Button, Card, Container, Group, Stack, Table, Tabs, Text } from '@mantine/core';
import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core';
import { CreditCard, Eye, Truck } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { PageContainer, PageHeader } from '@/components/page';
import {
InventoryWorkbench,
VisualEmptyState,
WarehouseHero,
formatNumber,
} from '@/components/warehouses';
import { useAutoLoadReady, useWarehouseInventory } from '@/hooks/useWarehouses';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
@@ -27,16 +29,19 @@ const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID'
export default function LoadingQueuePage() {
const navigate = useNavigate();
const { toast } = useToast();
const autoLoad = useAutoLoadReady();
const { data: readyData, isLoading: readyLoading } = useWarehouseInventory({
status: 'READY_FOR_LOADING',
});
const { data: loadedData, isLoading: loadedLoading } = useWarehouseInventory({ status: 'LOADED' });
const autoLoad = useMutation(api.warehouses.autoLoadReady.mutationOptions());
const { data: readyData, isLoading: readyLoading } = useQuery(
api.warehouses.listInventory.queryOptions({
input: { filter: { status: 'READY_FOR_LOADING' } },
}),
);
const { data: loadedData, isLoading: loadedLoading } = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { status: 'LOADED' } } }),
);
const handleAutoLoad = async () => {
try {
const res = await autoLoad.mutateAsync();
const r = res.data;
const r = await autoLoad.mutateAsync();
toast({
title: 'Auto-load complete',
description: `Loaded ${r.loadedCount} PAID item(s); skipped ${r.skippedCount} (unpaid stay pending).`,
@@ -53,35 +58,28 @@ export default function LoadingQueuePage() {
const unpaidItems = useMemo(() => readyItems.filter((i) => !isPaid(i)), [readyItems]);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Loading queue' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="wagon"
secondaryVariant="cargo"
title="Loading Queue"
subtitle="Manage bookings and inventory through the loading workflow."
/>
<Group justify="flex-end">
<PageContainer>
<PageHeader
title="Loading Queue"
subtitle="Manage bookings and inventory through the loading workflow."
action={
<Button
color="green"
leftSection={<Truck size={16} />}
loading={autoLoad.isPending}
onClick={handleAutoLoad}
>
Auto Load Ready Items
</Button>
</Group>
}
/>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="ready">
<Card>
<Tabs defaultValue="ready">
<Tabs.List>
<Tabs.Tab
value="ready"
leftSection={
<Badge size="xs" color="green">
<Badge size="xs" color="edr-green">
{paidItems.length}
</Badge>
}
@@ -171,10 +169,9 @@ export default function LoadingQueuePage() {
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
)}
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
</Container>
</Tabs>
</Card>
</PageContainer>
);
}
@@ -185,63 +182,67 @@ interface PendingPaymentTableProps {
/** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */
function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
const columns: ColumnDef<WarehouseInventoryItem>[] = [
{
id: 'booking',
header: 'Booking',
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.booking?.reference ?? row.original.bookingId?.slice(0, 8) ?? '—'}
</Text>
),
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'payment',
header: 'Payment',
cell: ({ row }) => (
<Badge color="orange" variant="light" size="sm">
{row.original.booking?.status ?? row.original.booking?.paymentStatus ?? 'UNPAID'}
</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
const item = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Button
size="compact-xs"
variant="light"
color="gray"
leftSection={<Eye size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Booking
</Button>
<Button
size="compact-xs"
variant="light"
leftSection={<CreditCard size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Payment
</Button>
</Group>
);
},
},
];
return (
<Table.ScrollContainer minWidth={820}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Weight (kg)</Table.Th>
<Table.Th>Payment</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>
<Text fw={600} size="sm">
{item.booking?.reference ?? item.bookingId?.slice(0, 8) ?? '—'}
</Text>
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>
<Badge color="orange" variant="light" size="sm">
{item.booking?.status ?? item.booking?.paymentStatus ?? 'UNPAID'}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
color="gray"
leftSection={<Eye size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Booking
</Button>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<CreditCard size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Payment
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={items}
status="success"
emptyMessage="No unpaid items."
containerClassName="border-0 shadow-none"
/>
);
}

View File

@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
ClipboardList,
@@ -15,24 +15,23 @@ import {
Layers,
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { PageContainer, PageHeader } from '@/components/page';
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
/** Brand palette: alternating orange + light green. */
const ORANGE = { solid: '#f08c00', soft: '#fff4e6', border: '#ffd8a8', text: '#e8590c' };
const GREEN = { solid: '#5bbf4a', soft: '#ebfbee', border: '#b2f2bb', text: '#2f9e44' };
interface Metric {
key: keyof WarehouseDashboard;
label: string;
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: typeof ORANGE;
theme: string;
}
const ORANGE = 'rgb(245, 227, 203)';
const GREEN = '#084b21';
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
@@ -53,8 +52,11 @@ export default function WarehouseDashboardPage() {
const { data, isError, isLoading } = useWarehouseDashboard();
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
<PageContainer>
<PageHeader
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
/>
<Stack gap="lg" mt="sm">
<WarehouseHero
@@ -74,54 +76,40 @@ export default function WarehouseDashboardPage() {
</Center>
) : (
<>
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={metric.key}
radius="lg"
padding="lg"
onClick={() => navigate(metric.to)}
style={{
cursor: 'pointer',
background: `linear-gradient(135deg, ${metric.theme.soft} 0%, #ffffff 75%)`,
border: `1px solid ${metric.theme.border}`,
transition: 'box-shadow 150ms ease, transform 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = `0 10px 24px -12px ${metric.theme.solid}`;
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '';
e.currentTarget.style.transform = '';
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
<Text fw={800} size="32px" mt={8} style={{ color: metric.theme.text, lineHeight: 1.1 }}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="filled"
size={46}
radius="md"
style={{ backgroundColor: metric.theme.solid, color: '#fff' }}
>
{metric.icon}
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={metric.key}
padding="lg"
onClick={() => navigate(metric.to)}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="light"
size={46}
radius="md"
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
>
{metric.icon}
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
<WarehouseDashboardCharts data={data} />
<WarehouseDashboardCharts data={data} />
</>
)}
</Stack>
</Container>
</PageContainer>
);
}

View File

@@ -5,20 +5,18 @@ import {
Button,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Stack,
Select,
Table,
Stack,
Tabs,
Text,
Title,
} from '@mantine/core';
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
import {
CreateYardModal,
CreateZoneModal,
@@ -28,44 +26,44 @@ import {
formatCapacity,
humanizeEnum,
} from '@/components/warehouses';
import {
useWarehouse,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
function StatCard({ label, value }: { label: string; value: string }) {
return (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text fw={700} size="lg" mt={4}>
{value}
</Text>
</Card>
);
}
export default function WarehouseDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: warehouse, isLoading } = useWarehouse(id);
const yardsQuery = useWarehouseYards(id);
const { data: warehouse, isLoading } = useQuery(
api.warehouses.getById.queryOptions({
input: { id: id ?? '' },
enabled: Boolean(id),
}),
);
const yardsQuery = useQuery(
api.warehouses.listYards.queryOptions({
input: { warehouseId: id ?? '' },
enabled: Boolean(id),
}),
);
const [yardModalOpen, setYardModalOpen] = useState(false);
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
const [zoneModalOpen, setZoneModalOpen] = useState(false);
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
const zonesQuery = useWarehouseZones(selectedYardId ?? undefined);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
input: { yardId: selectedYardId ?? '' },
enabled: Boolean(selectedYardId),
}),
);
const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined);
const inventoryQuery = useQuery(
api.warehouses.listInventory.queryOptions({
input: { filter: id ? { warehouseId: id } : undefined },
}),
);
const yards = yardsQuery.data ?? [];
const yardOptions = useMemo(
@@ -73,6 +71,90 @@ export default function WarehouseDetailPage() {
[yards],
);
const yardColumns = useMemo<ColumnDef<WarehouseYard>[]>(
() => [
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'code', header: 'Code', cell: ({ row }) => row.original.code },
{ id: 'type', header: 'Type', cell: ({ row }) => humanizeEnum(row.original.type) },
{
id: 'weight',
header: 'Weight (cur / cap)',
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
},
{
id: 'containers',
header: 'Containers (cur / cap)',
cell: ({ row }) =>
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
},
{
id: 'actions',
header: 'Actions',
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
cell: ({ row }) => (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
setEditingYard(row.original);
setYardModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
),
},
],
[],
);
const zoneColumns = useMemo<ColumnDef<WarehouseZone>[]>(
() => [
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'code', header: 'Code', cell: ({ row }) => row.original.code },
{ id: 'type', header: 'Type', cell: ({ row }) => humanizeEnum(row.original.type) },
{
id: 'weight',
header: 'Weight (cur / cap)',
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
},
{
id: 'containers',
header: 'Containers (cur / cap)',
cell: ({ row }) =>
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
},
{
id: 'actions',
header: 'Actions',
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
cell: ({ row }) => (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
setEditingZone(row.original);
setZoneModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
),
},
],
[],
);
if (isLoading) {
return (
<Center mih="60vh">
@@ -83,234 +165,175 @@ export default function WarehouseDetailPage() {
if (!warehouse) {
return (
<Container size="sm" py="xl">
<Stack align="center" gap="md">
<PageContainer>
<Stack align="center" gap="md" py="xl">
<Text fw={700}>Warehouse not found</Text>
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses/list')}>
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate('/dashboard/warehouses')}
>
Back to warehouses
</Button>
</Stack>
</Container>
</PageContainer>
);
}
return (
<Container size="xxl" py="lg">
<Breadcrumbs
items={[
{ label: 'Warehouse dashboard', href: '/dashboard/warehouses' },
{ label: 'Warehouses', href: '/dashboard/warehouses/list' },
<PageContainer>
<PageHeader
breadcrumbs={[
{ label: 'Warehouses', href: '/dashboard/warehouses' },
{ label: warehouse.name },
]}
backTo="/dashboard/warehouses"
title={warehouse.name}
subtitle={`${warehouse.code}${warehouse.locationName ? ` - ${warehouse.locationName}` : ''}`}
meta={
<Group gap="xs" wrap="nowrap">
<WarehouseTypeBadge type={warehouse.type} />
<WarehouseStatusBadge status={warehouse.status} />
</Group>
}
/>
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-start">
<Group gap="md" align="center">
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses/list')}>
<ArrowLeft size={18} />
</ActionIcon>
<div>
<Group gap="sm">
<Title order={2}>{warehouse.name}</Title>
<WarehouseTypeBadge type={warehouse.type} />
<WarehouseStatusBadge status={warehouse.status} />
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="yards" leftSection={<Boxes size={16} />}>
Yards
</Tabs.Tab>
<Tabs.Tab value="zones" leftSection={<LayoutGrid size={16} />}>
Zones
</Tabs.Tab>
<Tabs.Tab value="inventory" leftSection={<Package size={16} />}>
Inventory
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
<KpiStrip
items={[
{ label: 'Type', value: humanizeEnum(warehouse.type) },
{ label: 'Yards', value: yards.length },
{
label: 'Weight (cur / cap)',
value: formatCapacity(warehouse.currentWeight, warehouse.capacityWeight),
},
{
label: 'Containers (cur / cap)',
value: formatCapacity(
warehouse.currentContainers,
warehouse.capacityContainers,
),
},
]}
/>
</Tabs.Panel>
<Tabs.Panel value="yards" pt="lg">
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>Yards</Text>
<Button
size="sm"
leftSection={<Plus size={16} />}
onClick={() => {
setEditingYard(null);
setYardModalOpen(true);
}}
>
Create Yard
</Button>
</Group>
<Text c="dimmed" size="sm">
{warehouse.code}
{warehouse.locationName ? ` · ${warehouse.locationName}` : ''}
</Text>
</div>
</Group>
</Group>
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="yards" leftSection={<Boxes size={16} />}>
Yards
</Tabs.Tab>
<Tabs.Tab value="zones" leftSection={<LayoutGrid size={16} />}>
Zones
</Tabs.Tab>
<Tabs.Tab value="inventory" leftSection={<Package size={16} />}>
Inventory
</Tabs.Tab>
</Tabs.List>
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
<StatCard label="Type" value={humanizeEnum(warehouse.type)} />
<StatCard label="Yards" value={String(yards.length)} />
<StatCard
label="Weight (cur / cap)"
value={formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}
<DataTable
columns={yardColumns}
data={yards}
status={
yardsQuery.isLoading ? 'loading' : yardsQuery.isError ? 'error' : 'success'
}
emptyMessage="No yards yet."
containerClassName="border-0 shadow-none"
error={
yardsQuery.isError
? {
message: 'Failed to load yards.',
onRetry: () => void yardsQuery.refetch(),
}
: undefined
}
/>
<StatCard
label="Containers (cur / cap)"
value={formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}
/>
</SimpleGrid>
</Tabs.Panel>
</Stack>
</Card>
</Tabs.Panel>
{/* YARDS */}
<Tabs.Panel value="yards" pt="lg">
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>Yards</Text>
<Button
size="sm"
leftSection={<Plus size={16} />}
onClick={() => {
setEditingYard(null);
setYardModalOpen(true);
}}
>
Create Yard
</Button>
</Group>
<Tabs.Panel value="zones" pt="lg">
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-end">
<Select
label="Yard"
placeholder="Select a yard"
data={yardOptions}
value={selectedYardId}
onChange={setSelectedYardId}
w={280}
searchable
/>
<Button
size="sm"
leftSection={<Plus size={16} />}
disabled={!selectedYardId}
onClick={() => {
setEditingZone(null);
setZoneModalOpen(true);
}}
>
Create Zone
</Button>
</Group>
{yards.length === 0 ? (
<Text c="dimmed" ta="center" py="lg">
No yards yet.
</Text>
) : (
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
<Table.Th>Containers (cur / cap)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{yards.map((yard) => (
<Table.Tr key={yard.id}>
<Table.Td>{yard.name}</Table.Td>
<Table.Td>{yard.code}</Table.Td>
<Table.Td>{humanizeEnum(yard.type)}</Table.Td>
<Table.Td>{formatCapacity(yard.currentWeight, yard.capacityWeight)}</Table.Td>
<Table.Td>{formatCapacity(yard.currentContainers, yard.capacityContainers)}</Table.Td>
<Table.Td>
<WarehouseStatusBadge status={yard.status} />
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
setEditingYard(yard);
setYardModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
</Tabs.Panel>
{!selectedYardId ? (
<Text c="dimmed" ta="center" py="lg">
Select a yard to view its zones.
</Text>
) : (
<DataTable
columns={zoneColumns}
data={zonesQuery.data ?? []}
status={
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
}
emptyMessage="No zones in this yard yet."
containerClassName="border-0 shadow-none"
error={
zonesQuery.isError
? {
message: 'Failed to load zones.',
onRetry: () => void zonesQuery.refetch(),
}
: undefined
}
/>
)}
</Stack>
</Card>
</Tabs.Panel>
{/* ZONES */}
<Tabs.Panel value="zones" pt="lg">
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-end">
<Select
label="Yard"
placeholder="Select a yard"
data={yardOptions}
value={selectedYardId}
onChange={setSelectedYardId}
w={280}
searchable
/>
<Button
size="sm"
leftSection={<Plus size={16} />}
disabled={!selectedYardId}
onClick={() => {
setEditingZone(null);
setZoneModalOpen(true);
}}
>
Create Zone
</Button>
</Group>
{!selectedYardId ? (
<Text c="dimmed" ta="center" py="lg">
Select a yard to view its zones.
</Text>
) : (zonesQuery.data ?? []).length === 0 ? (
<Text c="dimmed" ta="center" py="lg">
No zones in this yard yet.
</Text>
) : (
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
<Table.Th>Containers (cur / cap)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(zonesQuery.data ?? []).map((zone) => (
<Table.Tr key={zone.id}>
<Table.Td>{zone.name}</Table.Td>
<Table.Td>{zone.code}</Table.Td>
<Table.Td>{humanizeEnum(zone.type)}</Table.Td>
<Table.Td>{formatCapacity(zone.currentWeight, zone.capacityWeight)}</Table.Td>
<Table.Td>{formatCapacity(zone.currentContainers, zone.capacityContainers)}</Table.Td>
<Table.Td>
<WarehouseStatusBadge status={zone.status} />
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
setEditingZone(zone);
setZoneModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
</Tabs.Panel>
{/* INVENTORY */}
<Tabs.Panel value="inventory" pt="lg">
<Card withBorder radius="md" padding="lg">
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Card>
</Tabs.Panel>
</Tabs>
</Stack>
<Tabs.Panel value="inventory" pt="lg">
<Card withBorder radius="md" padding="lg">
<InventoryWorkbench
items={inventoryQuery.data ?? []}
isLoading={inventoryQuery.isLoading}
/>
</Card>
</Tabs.Panel>
</Tabs>
{id && (
<CreateYardModal
@@ -328,6 +351,6 @@ export default function WarehouseDetailPage() {
zone={editingZone}
/>
)}
</Container>
</PageContainer>
);
}

View File

@@ -1,10 +1,10 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Card, Container, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackagePlus, Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { PageContainer, PageHeader } from '@/components/page';
import {
InventoryWorkbench,
ReceiveInventoryModal,
@@ -36,8 +36,6 @@ export default function WarehouseInventoryPage() {
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(filter.warehouseId);
const zonesQuery = useWarehouseZones(filter.yardId);
const moveYardsQuery = useWarehouseYards(moveDraft.warehouseId);
const moveZonesQuery = useWarehouseZones(moveDraft.yardId);
const inventoryQuery = useWarehouseInventory(queryFilter);
const warehouseOptions = useMemo(
@@ -52,221 +50,84 @@ export default function WarehouseInventoryPage() {
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
const moveYardOptions = useMemo(
() => (moveYardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[moveYardsQuery.data],
);
const moveZoneOptions = useMemo(
() => (moveZonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[moveZonesQuery.data],
);
const openMoveModal = (item: WarehouseInventoryItem) => {
setMoveItem(item);
setMoveDraft({
warehouseId: item.warehouseId,
yardId: item.yardId,
zoneId: item.zoneId,
remarks: '',
});
};
const closeMoveModal = () => {
setMoveItem(null);
setMoveDraft({});
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Warehouse Inventory</Title>
<Text c="dimmed" size="sm">
Track received items through the storage, reservation, loading and dispatch lifecycle.
</Text>
</div>
<PageContainer>
<PageHeader
title="Warehouse Inventory"
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
action={
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
Receive Inventory
</Button>
</Group>
}
/>
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search notes"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={220}
/>
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={filter.warehouseId ?? null}
onChange={(value) =>
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
}
w={220}
/>
<Select
placeholder="All yards"
clearable
searchable
disabled={!filter.warehouseId}
data={yardOptions}
value={filter.yardId ?? null}
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
w={200}
/>
<Select
placeholder="All zones"
clearable
searchable
disabled={!filter.yardId}
data={zoneOptions}
value={filter.zoneId ?? null}
onChange={(value) => setFilter((f) => ({ ...f, zoneId: value ?? undefined }))}
w={200}
/>
<Select
placeholder="All statuses"
clearable
data={inventoryStatusOptions}
value={filter.status ?? null}
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
w={200}
/>
</Group>
<Card>
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search notes"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={220}
/>
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={filter.warehouseId ?? null}
onChange={(value) =>
setFilter((f) => ({
...f,
warehouseId: value ?? undefined,
yardId: undefined,
zoneId: undefined,
}))
}
w={220}
/>
<Select
placeholder="All yards"
clearable
searchable
disabled={!filter.warehouseId}
data={yardOptions}
value={filter.yardId ?? null}
onChange={(value) =>
setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))
}
w={200}
/>
<Select
placeholder="All zones"
clearable
searchable
disabled={!filter.yardId}
data={zoneOptions}
value={filter.zoneId ?? null}
onChange={(value) => setFilter((f) => ({ ...f, zoneId: value ?? undefined }))}
w={200}
/>
<Select
placeholder="All statuses"
clearable
data={inventoryStatusOptions}
value={filter.status ?? null}
onChange={(value) =>
setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))
}
w={200}
/>
</Group>
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Stack>
</Card>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="ready">
<Tabs.List>
<Tabs.Tab value="ready">Ready to Load</Tabs.Tab>
<Tabs.Tab value="pending">Pending Payment</Tabs.Tab>
<Tabs.Tab value="loaded">Loaded Inventory</Tabs.Tab>
<Tabs.Tab value="dispatch">Dispatch Queue</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="ready" pt="md">
<WarehouseInventoryTable
items={readyToLoad}
onInspect={handleInspect}
onStore={handleStore}
onReserve={handleReserve}
onReadyForLoading={handleReady}
onLoad={handleLoad}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="pending" pt="md">
<WarehouseInventoryTable
items={pendingPayment}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="loaded" pt="md">
<WarehouseInventoryTable
items={loadedInventory}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="dispatch" pt="md">
<WarehouseInventoryTable
items={loadedInventory}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onDispatch={handleDispatch}
busyId={busyId}
/>
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Stack>
</Card>
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
<Modal opened={Boolean(moveItem)} onClose={closeMoveModal} title="Move inventory" size="lg" centered>
<Stack gap="md">
<Select
label="Destination warehouse"
placeholder="Select warehouse"
required
searchable
data={warehouseOptions}
value={moveDraft.warehouseId ?? null}
onChange={(value) =>
setMoveDraft((draft) => ({
...draft,
warehouseId: value ?? undefined,
yardId: undefined,
zoneId: undefined,
}))
}
/>
<Select
label="Destination yard"
placeholder="Select yard"
required
searchable
disabled={!moveDraft.warehouseId}
data={moveYardOptions}
value={moveDraft.yardId ?? null}
onChange={(value) =>
setMoveDraft((draft) => ({ ...draft, yardId: value ?? undefined, zoneId: undefined }))
}
/>
<Select
label="Destination zone"
placeholder="Select zone"
required
searchable
disabled={!moveDraft.yardId}
data={moveZoneOptions}
value={moveDraft.zoneId ?? null}
onChange={(value) => setMoveDraft((draft) => ({ ...draft, zoneId: value ?? undefined }))}
/>
<Textarea
label="Remarks"
placeholder="Reason for the move"
minRows={3}
value={moveDraft.remarks ?? ''}
onChange={(event) =>
setMoveDraft((draft) => ({ ...draft, remarks: event.currentTarget.value }))
}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeMoveModal}>
Cancel
</Button>
<Button
color="green"
loading={moveMutation.isPending}
disabled={!moveDraft.warehouseId || !moveDraft.yardId || !moveDraft.zoneId}
onClick={handleMove}
>
Move inventory
</Button>
</Group>
</Stack>
</Modal>
</Container>
</PageContainer>
);
}

View File

@@ -4,7 +4,6 @@ import {
Badge,
Button,
Card,
Container,
Divider,
Group,
Loader,
@@ -17,16 +16,13 @@ import {
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { PageContainer, PageHeader } from '@/components/page';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import {
useCancelInvoice,
usePayInvoice,
useWarehouseInvoice,
useWarehouseInvoices,
} from '@/hooks/useWarehouses';
import {
WAREHOUSE_INVOICE_STATUSES,
type WarehouseFeeInvoice,
@@ -37,7 +33,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PARTIALLY_PAID: 'yellow',
PAID: 'green',
PAID: 'edr-green',
CANCELLED: 'gray',
};
@@ -49,7 +45,11 @@ export default function WarehouseInvoicesPage() {
const [search, setSearch] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useWarehouseInvoices(status ? { status } : undefined);
const { data, isLoading } = useQuery(
api.warehouses.invoices.queryOptions({
input: { filter: status ? { status } : undefined },
}),
);
const invoices = data ?? [];
const filtered = useMemo(() => {
@@ -58,19 +58,65 @@ export default function WarehouseInvoicesPage() {
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
}, [invoices, search]);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse fee invoices' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
title="Warehouse Fee Invoices"
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/>
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
{
id: 'invoiceNumber',
header: 'Invoice No',
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.invoiceNumber}
</Text>
),
},
{ id: 'type', header: 'Type', cell: ({ row }) => row.original.invoiceType.replace(/_/g, ' ') },
{ id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) },
{ id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) },
{
id: 'balance',
header: 'Balance',
cell: ({ row }) => fmt(row.original.balanceAmount, row.original.currency),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLOR[row.original.status]}>
{row.original.status.replace(/_/g, ' ')}
</Badge>
),
},
{
id: 'issued',
header: 'Issued',
cell: ({ row }) => <Text size="xs">{fmtDate(row.original.issuedAt)}</Text>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setDetailId(row.original.id)}
title="View"
>
<Eye size={16} />
</ActionIcon>
</Group>
),
},
];
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md" wrap="wrap">
return (
<PageContainer>
<PageHeader
title="Warehouse Fee Invoices"
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/>
<Card>
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput
placeholder="Search invoice no / booking / customer"
leftSection={<Search size={16} />}
@@ -88,54 +134,30 @@ export default function WarehouseInvoicesPage() {
/>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : filtered.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No invoices found.</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice No</Table.Th><Table.Th>Type</Table.Th><Table.Th>Total</Table.Th>
<Table.Th>Paid</Table.Th><Table.Th>Balance</Table.Th><Table.Th>Status</Table.Th>
<Table.Th>Issued</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td><Text fw={600} size="sm">{inv.invoiceNumber}</Text></Table.Td>
<Table.Td>{inv.invoiceType.replace(/_/g, ' ')}</Table.Td>
<Table.Td>{fmt(inv.totalAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.paidAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.balanceAmount, inv.currency)}</Table.Td>
<Table.Td><Badge variant="light" color={STATUS_COLOR[inv.status]}>{inv.status.replace(/_/g, ' ')}</Badge></Table.Td>
<Table.Td><Text size="xs">{fmtDate(inv.issuedAt)}</Text></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="gray" onClick={() => setDetailId(inv.id)} title="View">
<Eye size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
<DataTable
columns={invoiceColumns}
data={filtered}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No invoices found."
containerClassName="border-0 shadow-none"
/>
</Card>
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
</Container>
</PageContainer>
);
}
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { toast } = useToast();
const { data: inv, isLoading } = useWarehouseInvoice(id ?? undefined);
const pay = usePayInvoice();
const cancel = useCancelInvoice();
const { data: inv, isLoading } = useQuery(
api.warehouses.invoice.queryOptions({
input: { id: id ?? '' },
enabled: Boolean(id),
}),
);
const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
const [payAmount, setPayAmount] = useState<number | ''>('');
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
@@ -217,7 +239,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
style={{ flex: 1 }}
/>
<Button color="green" leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
</Group>

View File

@@ -1,10 +1,10 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Center, Container, Group, Loader, Stack, Text, Title } from '@mantine/core';
import { Button, Card, Center, Loader, Stack, Text } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { Plus } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { PageContainer, PageHeader } from '@/components/page';
import {
CreateWarehouseModal,
WarehouseCardView,
@@ -42,44 +42,38 @@ export default function WarehouseListPage() {
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse dashboard', href: '/dashboard/warehouses' }, { label: 'Warehouses' }]} />
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Warehouses</Title>
<Text c="dimmed" size="sm">
Manage warehouses, yards and zones.
</Text>
</div>
<PageContainer>
<PageHeader
title="Warehouses"
subtitle="Manage warehouses, yards and zones."
action={
<Button leftSection={<Plus size={16} />} onClick={openCreate}>
Create Warehouse
</Button>
</Group>
}
/>
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
<Card>
<Stack gap="md">
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : isError ? (
<Text c="red" ta="center" py="xl">
Failed to load warehouses.
</Text>
) : view === 'table' ? (
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
)}
</Stack>
</Card>
</Stack>
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : isError ? (
<Text c="red" ta="center" py="xl">
Failed to load warehouses.
</Text>
) : view === 'table' ? (
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
)}
</Stack>
</Card>
<CreateWarehouseModal opened={modalOpen} onClose={() => setModalOpen(false)} warehouse={editing} />
</Container>
</PageContainer>
);
}

View File

@@ -5,22 +5,20 @@ import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Tabs,
Table,
Text,
TextInput,
} from '@mantine/core';
import { Info, Plus, Trash2 } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { PageContainer, PageHeader } from '@/components/page';
import { useToast } from '@/hooks/use-toast';
import {
useAllWarehouseYards,
@@ -48,41 +46,36 @@ const CURRENCIES = [
];
const clean = (s: string) => s.trim() || undefined;
const currencyLabel = (currency: string) => (currency === 'ETB' ? 'Birr (ETB)' : 'USD');
const selectValue = (value: string | null, fallback = '') => value ?? fallback;
const numberValue = (value: string | number, fallback = 0) => {
const next = Number(value);
return Number.isFinite(next) ? next : fallback;
};
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
const dash = '-';
export default function WarehouseRulesPage() {
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse rules' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="warehouse"
secondaryVariant="container"
title="Allocation & Fee Rules"
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
/>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="allocation">
<Tabs.List>
<Tabs.Tab value="allocation">Allocation Rules</Tabs.Tab>
<Tabs.Tab value="fees">Storage / Demurrage Fees</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="allocation" pt="md">
<AllocationRules />
</Tabs.Panel>
<Tabs.Panel value="fees" pt="md">
<FeeRules />
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
</Container>
<PageContainer>
<PageHeader
title="Allocation & Fee Rules"
subtitle="Configure yard allocation and storage or demurrage free time and rates."
/>
<Card>
<Tabs defaultValue="allocation">
<Tabs.List>
<Tabs.Tab value="allocation">Allocation Rules</Tabs.Tab>
<Tabs.Tab value="fees">Storage / Demurrage Fees</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="allocation" pt="md">
<AllocationRules />
</Tabs.Panel>
<Tabs.Panel value="fees" pt="md">
<FeeRules />
</Tabs.Panel>
</Tabs>
</Card>
</PageContainer>
);
}
@@ -103,6 +96,7 @@ function AllocationRules() {
targetYardCode: '',
storageType: '',
});
const rules = data ?? [];
const yardOptions = yards
.filter((yard) => yard.code)
@@ -111,11 +105,24 @@ function AllocationRules() {
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
}));
const resetForm = () =>
setForm({
name: '',
priority: 100,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
containerStatus: '',
targetYardCode: '',
storageType: '',
});
const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) {
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
toast({ variant: 'destructive', title: 'Name and target yard are required' });
return;
}
await create.mutateAsync({
name: form.name.trim(),
priority: form.priority,
@@ -129,46 +136,68 @@ function AllocationRules() {
} as never);
toast({ title: 'Allocation rule created' });
setOpen(false);
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
resetForm();
};
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) matched by ascending priority</Text>
<Button color="orange" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
<Text c="dimmed" size="sm">
{rules.length} rule(s) matched by ascending priority
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
New allocation rule
</Button>
</Group>
<Alert icon={<Info size={16} />} color="orange" variant="light" mb="md">
<Text size="sm">
Allocation rules tell the system where to place a booking when it enters the warehouse. When a booking
matches the selected freight, trade, cargo, or container conditions, it is sent to the target yard.
Allocation rules tell the system where to place a booking when it enters the warehouse.
Lower priority numbers are checked first.
</Text>
</Alert>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Priority</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Cargo code</Table.Th><Table.Th>Target yard</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
<Table.Th>Priority</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th>
<Table.Th>Cargo code</Table.Th>
<Table.Th>Target yard</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.priority}</Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.cargoTypeCode ?? '—'}</Table.Td>
<Table.Td><Badge variant="light">{r.targetYardCode}</Badge></Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
{rules.map((rule) => (
<Table.Tr key={rule.id}>
<Table.Td>{rule.priority}</Table.Td>
<Table.Td>{rule.name}</Table.Td>
<Table.Td>{rule.freightType ?? dash}</Table.Td>
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
<Table.Td>
<Badge variant="light">{rule.targetYardCode}</Badge>
</Table.Td>
<Table.Td>
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
{rule.isActive ? 'Yes' : 'No'}
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
@@ -181,12 +210,6 @@ function AllocationRules() {
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Think of this as a routing instruction: if a booking matches these conditions, allocate it to the
selected yard.
</Text>
</Alert>
<Card withBorder radius="md" padding="sm" bg="gray.0">
<Stack gap={4}>
<Text size="xs" c="dimmed" fw={700} tt="uppercase">
@@ -204,15 +227,13 @@ function AllocationRules() {
<Group grow>
<TextInput
label="Rule name"
description="A clear name for warehouse operators."
placeholder="e.g. Import containers to Indode open yard"
placeholder="e.g. Import containers to open yard"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
/>
<NumberInput
label="Priority"
description="Lower number runs first."
value={form.priority}
onChange={(v) => setForm((f) => ({ ...f, priority: numberValue(v, 100) || 100 }))}
/>
@@ -220,7 +241,6 @@ function AllocationRules() {
<Group grow>
<Select
label="Freight type"
description="Leave empty to match all freight."
placeholder="Any freight"
data={FREIGHT}
value={form.freightType || null}
@@ -229,7 +249,6 @@ function AllocationRules() {
/>
<Select
label="Trade direction"
description="Leave empty to match import and export."
placeholder="Any direction"
data={TRADE}
value={form.tradeDirection || null}
@@ -240,14 +259,12 @@ function AllocationRules() {
<Group grow>
<TextInput
label="Cargo type code"
description="Optional, for cargo-specific yards."
placeholder="e.g. COFFEE"
value={form.cargoTypeCode}
onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))}
/>
<TextInput
label="Container status"
description="Optional, for empty/maintenance/special containers."
placeholder="e.g. MAINTENANCE"
value={form.containerStatus}
onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))}
@@ -256,7 +273,6 @@ function AllocationRules() {
<Group grow>
<Select
label="Target yard"
description="The yard where matching bookings will be allocated."
required
searchable
clearable
@@ -269,15 +285,18 @@ function AllocationRules() {
/>
<TextInput
label="Storage type"
description="Optional internal category."
placeholder="e.g. OPEN_STACK"
value={form.storageType}
onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))}
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="orange" loading={create.isPending} onClick={submit}>Create</Button>
<Button variant="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
</Button>
</Group>
</Stack>
</Modal>
@@ -308,6 +327,7 @@ function FeeRules() {
toast({ variant: 'destructive', title: 'Name is required' });
return;
}
await create.mutateAsync({
name: form.name.trim(),
ruleType: form.ruleType,
@@ -326,33 +346,60 @@ function FeeRules() {
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) most specific match applies</Text>
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
<Text c="dimmed" size="sm">
{rules.length} rule(s) - most specific match applies
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
New fee rule
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Free days</Table.Th><Table.Th>Rate / day</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th>
<Table.Th>Free days</Table.Th>
<Table.Th>Rate / day</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Badge color={r.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">{r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}</Badge></Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.freeDays}</Table.Td>
<Table.Td>{Number(r.ratePerDay).toLocaleString()} {currencyLabel(r.currency)}</Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
{rules.map((rule) => (
<Table.Tr key={rule.id}>
<Table.Td>
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
</Badge>
</Table.Td>
<Table.Td>{rule.name}</Table.Td>
<Table.Td>{rule.freightType ?? dash}</Table.Td>
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
<Table.Td>{rule.freeDays}</Table.Td>
<Table.Td>
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
</Table.Td>
<Table.Td>
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
{rule.isActive ? 'Yes' : 'No'}
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
@@ -366,17 +413,62 @@ function FeeRules() {
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (selectValue(v, 'DEMURRAGE_FEE') as FeeRuleType) }))} allowDeselect={false} />
<TextInput
label="Name"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
/>
<Select
label="Rule type"
data={FEE_RULE_TYPES.map((type) => ({
value: type,
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
}))}
value={form.ruleType}
onChange={(value) =>
setForm((f) => ({
...f,
ruleType: selectValue(value, 'DEMURRAGE_FEE') as FeeRuleType,
}))
}
allowDeselect={false}
/>
</Group>
<Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: selectValue(v) }))} clearable />
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: selectValue(v) }))} clearable />
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
<Select
label="Freight type"
data={FREIGHT}
value={form.freightType || null}
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))}
clearable
/>
<Select
label="Trade direction"
data={TRADE}
value={form.tradeDirection || null}
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
clearable
/>
<TextInput
label="Cargo type code"
value={form.cargoTypeCode}
onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))}
/>
</Group>
<Group grow>
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: numberValue(v) }))} />
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: numberValue(v) }))} />
<NumberInput
label="Free days"
min={0}
value={form.freeDays}
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
/>
<NumberInput
label="Rate / day"
min={0}
value={form.ratePerDay}
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
/>
<Select
label="Currency"
data={CURRENCIES}
@@ -386,8 +478,12 @@ function FeeRules() {
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="teal" loading={create.isPending} onClick={submit}>Create</Button>
<Button variant="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
</Button>
</Group>
</Stack>
</Modal>