mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
merge conflict fix
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -104,7 +104,6 @@ const BookingDetailPage = () => {
|
||||
const approvedCount = approvalSteps.filter(
|
||||
(s) => s.status === "APPROVED",
|
||||
).length;
|
||||
const totalSteps = approvalSteps.length;
|
||||
|
||||
return (
|
||||
<div style={detailStyles.page}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
"Max files" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
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;
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user