fix: type errors

This commit is contained in:
ghost2023
2026-06-23 15:02:25 +03:00
parent 18989c97e6
commit cdfe7c1f5b
27 changed files with 1379 additions and 1377 deletions

View File

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

View File

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

View File

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

View File

@@ -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>
);

View File

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

View File

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

View File

@@ -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 = () => {

View File

@@ -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 }}
/>

View File

@@ -2,8 +2,18 @@ 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,
Card,
Button,
Modal,
Stack,
Group,
Text,
List,
Loader,
} from "@mantine/core";
import { Plus } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
@@ -60,16 +70,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 +101,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 +131,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 +153,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 +163,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 +191,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 +209,6 @@ const RuleEngineResourcePage = () => {
}));
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
const handleApproveRate = useCallback(
(record: RuleEngineRecord) => {
approve.mutate(String(record.id));
@@ -259,7 +268,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 +281,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 +369,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 +397,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 +448,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 +462,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 +510,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 +548,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 ?? "—")}

View File

@@ -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>