mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/profile
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 />;
|
||||
}
|
||||
@@ -104,7 +104,6 @@ const BookingDetailPage = () => {
|
||||
const approvedCount = approvalSteps.filter(
|
||||
(s) => s.status === "APPROVED",
|
||||
).length;
|
||||
const totalSteps = approvalSteps.length;
|
||||
|
||||
return (
|
||||
<div style={detailStyles.page}>
|
||||
|
||||
@@ -34,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
|
||||
@@ -49,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) => {
|
||||
@@ -79,7 +88,13 @@ export default function BookingRequestDetailPage() {
|
||||
return (
|
||||
<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={{
|
||||
@@ -137,75 +152,83 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
<BookingRequestHero
|
||||
booking={booking}
|
||||
customerLabel={row.customerLabel}
|
||||
onBack={() => navigate("/dashboard/booking-requests")}
|
||||
onRefresh={() => refetch()}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
<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="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>
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
@@ -662,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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,15 +17,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { FileUploadEntity } from "@edr/types/freight";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
// import type {
|
||||
// FileUploadEntity,
|
||||
// FileUploadSetting,
|
||||
// } from "@/types/fileUploadSettings";
|
||||
// import {
|
||||
// useCreateFileUploadSetting,
|
||||
// useUpdateFileUploadSetting,
|
||||
// } from "@/hooks/useFileUploadSettings";
|
||||
import { FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
|
||||
export interface EditFileUploadSettingDialogProps {
|
||||
mode?: "create" | "edit";
|
||||
@@ -33,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,
|
||||
@@ -62,8 +41,12 @@ export default function EditFileUploadSettingDialog({
|
||||
const [description, setDescription] = useState(setting?.description ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation(api.fileUploadSettings.create.mutationOptions());
|
||||
const updateMutation = useMutation(api.fileUploadSettings.update.mutationOptions());
|
||||
const createMutation = useMutation(
|
||||
api.fileUploadSettings.create.mutationOptions(),
|
||||
);
|
||||
const updateMutation = useMutation(
|
||||
api.fileUploadSettings.update.mutationOptions(),
|
||||
);
|
||||
const pending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const reset = () => {
|
||||
|
||||
@@ -1,49 +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 { useToast } from '@/hooks/use-toast';
|
||||
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[];
|
||||
|
||||
@@ -331,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>
|
||||
@@ -410,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}>
|
||||
@@ -480,12 +480,6 @@ 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 ?? '-';
|
||||
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core";
|
||||
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 {
|
||||
Archive,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleSlash,
|
||||
Layers,
|
||||
Link2,
|
||||
Plus,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -23,37 +14,20 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
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 { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
|
||||
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
|
||||
|
||||
const FLEET_STATUS_META: Record<
|
||||
string,
|
||||
{ label: string; icon: LucideIcon; color: string }
|
||||
> = {
|
||||
AVAILABLE: { label: "Available", icon: CircleCheck, color: "edr-green" },
|
||||
ASSIGNED: { label: "Assigned", icon: Link2, color: "blue" },
|
||||
MAINTENANCE: { label: "Maintenance", icon: Wrench, color: "yellow" },
|
||||
OUT_OF_SERVICE: { label: "Out of service", icon: CircleSlash, color: "red" },
|
||||
RETIRED: { label: "Retired", icon: Archive, color: "gray" },
|
||||
};
|
||||
|
||||
const humanizeStatus = (status: string) => {
|
||||
const text = status.replace(/_/g, " ").toLowerCase();
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
const FleetResourcePage = () => {
|
||||
const location = useLocation();
|
||||
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
|
||||
@@ -113,6 +87,9 @@ const FleetResourcePage = () => {
|
||||
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 }));
|
||||
@@ -301,39 +278,6 @@ const FleetResourcePage = () => {
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
const kpiItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
label: `Total ${config?.label.toLowerCase() ?? ""}`,
|
||||
value: allRows.length,
|
||||
icon: Layers,
|
||||
color: "edr-green",
|
||||
},
|
||||
];
|
||||
if (hasStatusColumn) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of allRows) {
|
||||
const status = String(
|
||||
(row as unknown as Record<string, unknown>).status ?? "",
|
||||
);
|
||||
if (status) counts.set(status, (counts.get(status) ?? 0) + 1);
|
||||
}
|
||||
const top = [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4);
|
||||
for (const [status, count] of top) {
|
||||
const meta = FLEET_STATUS_META[status];
|
||||
items.push({
|
||||
label: meta?.label ?? humanizeStatus(status),
|
||||
value: count,
|
||||
icon: meta?.icon ?? Circle,
|
||||
color: meta?.color ?? "gray",
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.slice(0, 5);
|
||||
}, [allRows, hasStatusColumn, config?.label]);
|
||||
|
||||
if (!config) {
|
||||
return <Navigate to="/dashboard/locomotives" replace />;
|
||||
}
|
||||
@@ -376,7 +320,7 @@ const FleetResourcePage = () => {
|
||||
const handleAssignDriver = async () => {
|
||||
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
|
||||
try {
|
||||
const selectedDriverRecord = (drivers as Array<Record<string, unknown>>).find(
|
||||
const selectedDriverRecord = (drivers as unknown as Array<Record<string, unknown>>).find(
|
||||
(d) => String(d.id) === selectedDriver
|
||||
);
|
||||
if (!selectedDriverRecord) return;
|
||||
@@ -384,6 +328,7 @@ const FleetResourcePage = () => {
|
||||
const driverName = `${selectedDriverRecord.firstName} ${selectedDriverRecord.lastName}`;
|
||||
|
||||
await update.mutateAsync({
|
||||
slug,
|
||||
id: String(assigningDriver.id),
|
||||
data: {
|
||||
assignedDriverId: selectedDriver,
|
||||
@@ -608,7 +553,7 @@ const FleetResourcePage = () => {
|
||||
clearable
|
||||
value={selectedDriver}
|
||||
onChange={(value) => setSelectedDriver(value || "")}
|
||||
data={(drivers as Array<Record<string, unknown>>).map((driver) => ({
|
||||
data={(drivers as unknown as Array<Record<string, unknown>>).map((driver) => ({
|
||||
value: String(driver.id || ""),
|
||||
label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`,
|
||||
}))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Badge,
|
||||
Badge as MantineBadge,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -28,7 +29,6 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
|
||||
import {
|
||||
Badge,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
@@ -36,7 +36,12 @@ import {
|
||||
} 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",
|
||||
@@ -44,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;
|
||||
|
||||
@@ -81,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 });
|
||||
@@ -124,9 +135,9 @@ export default function PaymentsPage() {
|
||||
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,
|
||||
@@ -285,7 +296,10 @@ export default function PaymentsPage() {
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
rightSection={
|
||||
query && (
|
||||
@@ -309,7 +323,10 @@ export default function PaymentsPage() {
|
||||
value={method}
|
||||
onChange={(value) => {
|
||||
setMethod(value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
|
||||
@@ -1,48 +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 { Box, Card, Button, Modal, Stack, Group, Text, List, Loader } 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 => {
|
||||
@@ -60,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(
|
||||
@@ -90,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,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[
|
||||
@@ -120,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",
|
||||
@@ -141,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);
|
||||
|
||||
@@ -154,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") {
|
||||
@@ -183,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) => ({
|
||||
@@ -199,7 +207,6 @@ const RuleEngineResourcePage = () => {
|
||||
}));
|
||||
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
|
||||
|
||||
|
||||
const handleApproveRate = useCallback(
|
||||
(record: RuleEngineRecord) => {
|
||||
approve.mutate(String(record.id));
|
||||
@@ -259,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}
|
||||
@@ -270,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";
|
||||
|
||||
@@ -350,15 +367,20 @@ const RuleEngineResourcePage = () => {
|
||||
onSearchChange={
|
||||
config.supportsSearch
|
||||
? (v) => {
|
||||
setSearch(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}
|
||||
setSearch(v);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showSearch={Boolean(config.supportsSearch)}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
onManageOrder={
|
||||
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
|
||||
canManage && config.orderConfig
|
||||
? () => setOrderDialogOpen(true)
|
||||
: undefined
|
||||
}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
@@ -373,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.`}
|
||||
@@ -422,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}
|
||||
@@ -434,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.`
|
||||
@@ -478,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)}>
|
||||
@@ -515,14 +546,17 @@ const RuleEngineResourcePage = () => {
|
||||
) : (
|
||||
<>
|
||||
{(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 ?? "—")}
|
||||
|
||||
@@ -44,7 +44,10 @@ import { KpiStrip, PageContainer } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
|
||||
import {
|
||||
TrainConsistView,
|
||||
CompositionBookingTabs,
|
||||
} from "@/components/trainScheduling/compositionEditor";
|
||||
import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
@@ -67,10 +70,18 @@ const STATE_META: Record<
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
|
||||
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock },
|
||||
SELECTED_FOR_BATCH: {
|
||||
label: "Selected for batch",
|
||||
color: "orange",
|
||||
icon: Clock,
|
||||
},
|
||||
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
|
||||
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
PENDING_CONTRACT: {
|
||||
label: "Pending contract",
|
||||
color: "gray",
|
||||
icon: Hourglass,
|
||||
},
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
|
||||
@@ -93,13 +104,13 @@ const fmtMeters = (n: number) =>
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
const initials = (name: string) =>
|
||||
@@ -115,7 +126,12 @@ function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={meta.color}
|
||||
radius="sm"
|
||||
leftSection={<Icon size={11} />}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -232,7 +248,12 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
||||
{fmtDateTime(b.selectedForBatchAt)} EAT
|
||||
</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange.7" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="orange.7"
|
||||
fw={600}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -385,7 +406,9 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
|
||||
border: total ? "1px solid #FBD171" : "1px solid var(--mantine-color-gray-2)",
|
||||
border: total
|
||||
? "1px solid #FBD171"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
@@ -396,7 +419,9 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
{timeLabelOf(window.label)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
|
||||
{total
|
||||
? `${total} booking${total === 1 ? "" : "s"}`
|
||||
: "Empty window"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -443,7 +468,9 @@ export default function BatchScheduleDetailPage() {
|
||||
data?.windows.some((w) =>
|
||||
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
) ||
|
||||
data?.pendingContract.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
data?.pendingContract.bookings.some(
|
||||
(b) => b.allocationStatus === "ASSIGNED",
|
||||
),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
@@ -514,7 +541,9 @@ export default function BatchScheduleDetailPage() {
|
||||
group.hasIssues =
|
||||
group.hasIssues ||
|
||||
w.bookings.some(
|
||||
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
|
||||
(b) =>
|
||||
b.allocationStatus === "FAILED" ||
|
||||
b.allocationStatus === "DEFERRED",
|
||||
);
|
||||
}
|
||||
return [...byDate.values()];
|
||||
@@ -522,7 +551,10 @@ export default function BatchScheduleDetailPage() {
|
||||
|
||||
// Windows with bookings open by default (inside an expanded day).
|
||||
const openWindowKeys = useMemo(
|
||||
() => (data ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) : []),
|
||||
() =>
|
||||
data
|
||||
? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
@@ -541,7 +573,9 @@ export default function BatchScheduleDetailPage() {
|
||||
// day with bookings, else the first day. Keep the selection if still valid.
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!dayGroups.length) return;
|
||||
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
|
||||
@@ -562,7 +596,9 @@ export default function BatchScheduleDetailPage() {
|
||||
runAllocation
|
||||
.mutateAsync({ scheduleId: scheduleId ?? "" })
|
||||
.then((result) => {
|
||||
const failed = result.issues.filter((i) => i.status === "FAILED").length;
|
||||
const failed = result.issues.filter(
|
||||
(i) => i.status === "FAILED",
|
||||
).length;
|
||||
const deferred = result.deferred.length;
|
||||
toast({
|
||||
title: "Allocation run complete",
|
||||
@@ -589,15 +625,6 @@ export default function BatchScheduleDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const lengthPct =
|
||||
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
|
||||
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
|
||||
: null;
|
||||
const weightPct =
|
||||
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
|
||||
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
|
||||
: null;
|
||||
|
||||
const totalBookings = totalBookingCount(data.counts);
|
||||
|
||||
return (
|
||||
@@ -614,36 +641,49 @@ export default function BatchScheduleDetailPage() {
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="composition">
|
||||
Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`}
|
||||
Train Composition{" "}
|
||||
{scheduleDetailQuery.data?.trainSet?.wagons &&
|
||||
scheduleDetailQuery.data.trainSet.wagons.length > 0 &&
|
||||
`(${scheduleDetailQuery.data.trainSet.wagons.length})`}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={9} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
px="sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/operations/batch-board")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Title order={2} fw={800}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
<HeroChip>{data.status}</HeroChip>
|
||||
</Group>
|
||||
<RouteCorridor origin={data.origin} destination={data.destination} />
|
||||
<Group gap={6} wrap="wrap">
|
||||
<HeroChip icon={<CalendarDays size={12} />}>
|
||||
{data.scheduleDate
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
>
|
||||
<Stack gap={9} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
px="sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() =>
|
||||
navigate("/dashboard/operations/batch-board")
|
||||
}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Title order={2} fw={800}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
<HeroChip>{data.status}</HeroChip>
|
||||
</Group>
|
||||
<RouteCorridor
|
||||
origin={data.origin}
|
||||
destination={data.destination}
|
||||
/>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<HeroChip icon={<CalendarDays size={12} />}>
|
||||
{data.scheduleDate
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
@@ -653,317 +693,360 @@ export default function BatchScheduleDetailPage() {
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(data.scheduleDate)) + " EAT"
|
||||
: "No date"}
|
||||
</HeroChip>
|
||||
{data.locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
: "No date"}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!data.locomotive ? (
|
||||
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Allocated wagons",
|
||||
value: data.capacity.allocatedWagons,
|
||||
hint: "on this train",
|
||||
icon: Boxes,
|
||||
},
|
||||
{
|
||||
label: "Train length",
|
||||
value: data.capacity.maxLengthMeters
|
||||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||||
: fmtMeters(data.capacity.allocatedLengthMeters),
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
value: data.capacity.maxWeightTons
|
||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||
: fmtTons(data.capacity.usedWeightTons),
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
value: totalBookings,
|
||||
hint: `${data.counts.allocated} allocated · ${data.counts.expired} expired`,
|
||||
icon: Package,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Booking pipeline */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="md" variant="light" color="#F2A516">
|
||||
<Package size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Booking pipeline</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="dark.4">
|
||||
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<BookingPipeline counts={data.counts} size={14} />
|
||||
</Paper>
|
||||
|
||||
{data.allocationViolations.length ? (
|
||||
<Alert
|
||||
color="red"
|
||||
mt="lg"
|
||||
radius="lg"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Allocation constraints"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{data.allocationViolations.map((v) => (
|
||||
<Text key={v} size="sm">
|
||||
{v}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Batch windows */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
|
||||
<Clock size={19} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4}>Batch windows (EAT)</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
3-hour windows for every day from when the booking window opened through the
|
||||
departure date. Bookings appear under the date their contract was signed — open a
|
||||
day to see its windows.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{dayGroups.length && selectedDay ? (
|
||||
<>
|
||||
{/* Date stepper — page back/forward through each day in the range */}
|
||||
<Group justify="center" align="center" wrap="nowrap" gap="md" mt="md">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Previous day"
|
||||
disabled={selectedIndex <= 0}
|
||||
onClick={() => setSelectedDate(dayGroups[selectedIndex - 1]?.date ?? null)}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
radius="xl"
|
||||
px="xl"
|
||||
py="xs"
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 360,
|
||||
textAlign: "center",
|
||||
background: selectedDay.totalBookings ? "#FEF1D5" : "white",
|
||||
borderColor: selectedDay.totalBookings
|
||||
? "#FBD171"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="center" gap={8} wrap="nowrap">
|
||||
<CalendarDays size={15} color="#B26C09" />
|
||||
<Text
|
||||
fw={800}
|
||||
style={{ color: selectedDay.totalBookings ? "#8A5304" : "#0f172a" }}
|
||||
>
|
||||
{selectedDay.dateLabel}
|
||||
</Text>
|
||||
{selectedDay.date === todayEat ? (
|
||||
<Badge size="xs" variant="light" color="#F2A516">
|
||||
Today
|
||||
</Badge>
|
||||
{data.locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Loco {data.locomotive.code} ·{" "}
|
||||
{fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
</HeroChip>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{selectedDay.totalBookings
|
||||
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
|
||||
: `${selectedDay.windows.length} windows · no bookings`}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Next day"
|
||||
disabled={selectedIndex >= dayGroups.length - 1}
|
||||
onClick={() => setSelectedDate(dayGroups[selectedIndex + 1]?.date ?? null)}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center" mt="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Day {selectedIndex + 1} of {dayGroups.length}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{selectedDay.hasIssues ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<AlertTriangle size={10} />}
|
||||
>
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<WindowCountChips counts={selectedDay.counts} />
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
key={selectedDay.date}
|
||||
multiple
|
||||
defaultValue={openWindowKeys}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{selectedDay.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No batch windows for this schedule.
|
||||
</Text>
|
||||
)}
|
||||
{!data.locomotive ? (
|
||||
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={["pending-contract"]}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
<Accordion.Item value="pending-contract">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Allocated wagons",
|
||||
value: data.capacity.allocatedWagons,
|
||||
hint: "on this train",
|
||||
icon: Boxes,
|
||||
},
|
||||
{
|
||||
label: "Train length",
|
||||
value: data.capacity.maxLengthMeters
|
||||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||||
: fmtMeters(data.capacity.allocatedLengthMeters),
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
value: data.capacity.maxWeightTons
|
||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||
: fmtTons(data.capacity.usedWeightTons),
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
value: totalBookings,
|
||||
hint: `${data.counts.allocated} allocated · ${data.counts.expired} expired`,
|
||||
icon: Package,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Booking pipeline */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={32}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
>
|
||||
<Package size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Booking pipeline</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="dark.4">
|
||||
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<BookingPipeline counts={data.counts} size={14} />
|
||||
</Paper>
|
||||
|
||||
{data.allocationViolations.length ? (
|
||||
<Alert
|
||||
color="red"
|
||||
mt="lg"
|
||||
radius="lg"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Allocation constraints"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{data.allocationViolations.map((v) => (
|
||||
<Text key={v} size="sm">
|
||||
{v}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Batch windows */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
>
|
||||
<Clock size={19} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4}>Batch windows (EAT)</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
3-hour windows for every day from when the booking window
|
||||
opened through the departure date. Bookings appear under the
|
||||
date their contract was signed — open a day to see its
|
||||
windows.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{dayGroups.length && selectedDay ? (
|
||||
<>
|
||||
{/* Date stepper — page back/forward through each day in the range */}
|
||||
<Group
|
||||
justify="center"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="md"
|
||||
mt="md"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Previous day"
|
||||
disabled={selectedIndex <= 0}
|
||||
onClick={() =>
|
||||
setSelectedDate(
|
||||
dayGroups[selectedIndex - 1]?.date ?? null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
radius="xl"
|
||||
px="xl"
|
||||
py="xs"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
flex: 1,
|
||||
maxWidth: 360,
|
||||
textAlign: "center",
|
||||
background: selectedDay.totalBookings
|
||||
? "#FEF1D5"
|
||||
: "white",
|
||||
borderColor: selectedDay.totalBookings
|
||||
? "#FBD171"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<FileSignature size={16} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={700} size="sm">
|
||||
Pending contract
|
||||
<Group justify="center" gap={8} wrap="nowrap">
|
||||
<CalendarDays size={15} color="#B26C09" />
|
||||
<Text
|
||||
fw={800}
|
||||
style={{
|
||||
color: selectedDay.totalBookings
|
||||
? "#8A5304"
|
||||
: "#0f172a",
|
||||
}}
|
||||
>
|
||||
{selectedDay.dateLabel}
|
||||
</Text>
|
||||
{selectedDay.date === todayEat ? (
|
||||
<Badge size="xs" variant="light" color="#F2A516">
|
||||
Today
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{selectedDay.totalBookings
|
||||
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
|
||||
: `${selectedDay.windows.length} windows · no bookings`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Contract not signed yet — not in any window
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{data.pendingContract.bookings.length} booking
|
||||
{data.pendingContract.bookings.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={data.pendingContract.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Paper>
|
||||
</Paper>
|
||||
|
||||
{/* Train composition diagram */}
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Box mt="lg">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType="CONTAINER"
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Next day"
|
||||
disabled={selectedIndex >= dayGroups.length - 1}
|
||||
onClick={() =>
|
||||
setSelectedDate(
|
||||
dayGroups[selectedIndex + 1]?.date ?? null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center" mt="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Day {selectedIndex + 1} of {dayGroups.length}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{selectedDay.hasIssues ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<AlertTriangle size={10} />}
|
||||
>
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<WindowCountChips counts={selectedDay.counts} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
key={selectedDay.date}
|
||||
multiple
|
||||
defaultValue={openWindowKeys}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{selectedDay.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No batch windows for this schedule.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={["pending-contract"]}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
<Accordion.Item value="pending-contract">
|
||||
<Accordion.Control>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
pr="md"
|
||||
gap="sm"
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<FileSignature size={16} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={700} size="sm">
|
||||
Pending contract
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Contract not signed yet — not in any window
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{data.pendingContract.bookings.length} booking
|
||||
{data.pendingContract.bookings.length === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={data.pendingContract.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
{/* Train composition diagram */}
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Box mt="lg">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType="CONTAINER"
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={
|
||||
scheduleDetailQuery.data.trainSet?.totalLengthMeters
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user