Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
Marshal
2026-06-24 23:56:07 +00:00
88 changed files with 4215 additions and 3624 deletions

View File

@@ -1,330 +0,0 @@
// components/baselineRatematrix/RateMatrixForm.tsx
import React, { useState, useCallback } from 'react';
// import { useForm } from 'react-hook-form';
// import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react';
import { RateTypeSection } from './RateTypeSection';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ValidationSummary } from './ValidationSummary';
import { LoadingScreen } from '@/ui/LoadingScreen';
import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
import { useReferenceData } from '@/hooks/useReferenceData';
import { queryKeys } from '@/constants/queryKeys';
import { API_URLS } from '@/constants/apiUrls';
import {
RATE_TYPES,
RATE_TYPE_LABELS,
REQUIRED_RATE_TYPES
} from '@/constants/rateMatrixConstants';
import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules';
import type { RateEntry } from './types';
const formSchema = z.object({
matrixName: z.string().min(1, 'Matrix name is required').max(200),
effectiveDate: z.string().min(1, 'Effective date is required'),
expiryDate: z.string().optional(),
currency: z.string().min(1, 'Currency is required'),
});
type FormData = z.infer<typeof formSchema>;
const createInitialSections = (): RateEntry[] => {
return REQUIRED_RATE_TYPES.map(rateType => ({
rateType,
entries: [{
validFrom: '',
validTo: '',
}],
}));
};
export function RateMatrixForm() {
const [rateSections, setRateSections] = useState<RateEntry[]>(createInitialSections());
const [showConfirmation, setShowConfirmation] = useState(false);
const [savedMatrixId, setSavedMatrixId] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<any[]>([]);
const { isDirector } = useRateMatrixAuth();
const { data: referenceData, isLoading: isLoadingReference } = useReferenceData();
const queryClient = useQueryClient();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
matrixName: '',
effectiveDate: '',
expiryDate: '',
currency: 'USD',
},
});
// Save draft mutation
const saveDraftMutation = useMutation({
mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => {
const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to save draft');
return response.json();
},
onSuccess: (data) => {
setSavedMatrixId(data.id);
queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
toast.success('Draft saved successfully');
},
onError: (error) => {
toast.error('Failed to save draft');
},
});
// Submit for approval mutation
const submitMutation = useMutation({
mutationFn: async (matrixId: string) => {
const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), {
method: 'POST',
});
if (!response.ok) throw new Error('Failed to submit');
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
toast.success('Rate matrix submitted for executive approval and locked!');
setShowConfirmation(false);
},
onError: (error) => {
toast.error('Failed to submit for approval');
setShowConfirmation(false);
},
});
const handleValidate = useCallback(() => {
const validation = rateMatrixRulesEngine.validate(rateSections);
setValidationErrors([...validation.errors, ...validation.warnings]);
if (validation.isValid) {
toast.success('All validations passed!');
}
}, [rateSections]);
const handleSaveDraft = async () => {
const formData = form.getValues();
await saveDraftMutation.mutateAsync({
...formData,
rateSections,
});
};
const handleSubmitClick = async () => {
const isFormValid = await form.trigger();
if (!isFormValid) return;
const validation = rateMatrixRulesEngine.validate(rateSections);
setValidationErrors([...validation.errors, ...validation.warnings]);
if (!validation.isValid) {
toast.error('Please fix validation errors before submitting');
return;
}
setShowConfirmation(true);
};
const handleConfirmSubmit = async () => {
const formData = form.getValues();
try {
let matrixId = savedMatrixId;
if (!matrixId) {
const draftResult = await saveDraftMutation.mutateAsync({
...formData,
rateSections,
});
matrixId = draftResult.id;
}
await submitMutation.mutateAsync(matrixId!);
} catch (error) {
// Error handling done in mutations
}
};
if (isLoadingReference) {
return <LoadingScreen message="Loading reference data..." />;
}
if (!isDirector) {
return (
<div className="flex items-center justify-center min-h-screen">
<Alert variant="destructive" className="max-w-md">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>
Only Directors can access the rate matrix registration.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container mx-auto py-8 px-4 max-w-7xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">
Baseline Rate Matrix Registration
</h1>
<p className="text-muted-foreground mt-2">
Submit a comprehensive rate matrix for executive approval
</p>
</div>
{/* Director Warning */}
<Alert variant="warning" className="mb-6 border-amber-500 bg-amber-50">
<Shield className="h-4 w-4" />
<AlertTitle>Director Notice</AlertTitle>
<AlertDescription>
Once submitted, this matrix will be locked pending Chief Executive approval.
No edits can be made by any user until authorization is granted.
</AlertDescription>
</Alert>
<form onSubmit={(e) => e.preventDefault()}>
{/* Matrix Metadata */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Matrix Information</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label htmlFor="matrixName">Matrix Name *</Label>
<Input
id="matrixName"
{...form.register('matrixName')}
placeholder="e.g., Q4 2026 Baseline Matrix"
className={form.formState.errors.matrixName ? 'border-destructive' : ''}
/>
{form.formState.errors.matrixName && (
<p className="text-sm text-destructive">
{form.formState.errors.matrixName.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="effectiveDate">Effective Date *</Label>
<Input
id="effectiveDate"
type="date"
{...form.register('effectiveDate')}
className={form.formState.errors.effectiveDate ? 'border-destructive' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="expiryDate">Expiry Date</Label>
<Input
id="expiryDate"
type="date"
{...form.register('expiryDate')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency">Currency *</Label>
<select
id="currency"
{...form.register('currency')}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
>
{referenceData?.currencies?.map((currency: any) => (
<option key={currency.code} value={currency.code}>
{currency.code} - {currency.name}
</option>
))}
</select>
</div>
</div>
</CardContent>
</Card>
{/* Rate Type Sections */}
<div className="space-y-6">
{rateSections.map((section, index) => (
<RateTypeSection
key={section.rateType}
section={section}
sectionIndex={index}
onUpdate={(updatedSection) => {
const newSections = [...rateSections];
newSections[index] = updatedSection;
setRateSections(newSections);
}}
referenceData={referenceData}
/>
))}
</div>
{/* Validation Errors */}
{validationErrors.length > 0 && (
<div className="mt-6">
<ValidationSummary errors={validationErrors} />
</div>
)}
{/* Form Actions */}
<div className="sticky bottom-6 mt-8 p-6 bg-background border rounded-lg shadow-lg flex gap-4 justify-end">
<Button
type="button"
variant="outline"
onClick={handleSaveDraft}
disabled={saveDraftMutation.isPending}
>
{saveDraftMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save as Draft
</Button>
<Button
type="button"
variant="secondary"
onClick={handleValidate}
>
Validate All Rates
</Button>
<Button
type="button"
onClick={handleSubmitClick}
disabled={submitMutation.isPending}
>
<Send className="mr-2 h-4 w-4" />
Submit for Executive Approval
</Button>
</div>
</form>
{/* Confirmation Dialog */}
<ConfirmationDialog
open={showConfirmation}
onOpenChange={setShowConfirmation}
onConfirm={handleConfirmSubmit}
isLoading={submitMutation.isPending}
/>
</div>
);
}

View File

@@ -21,20 +21,6 @@ import type {
BookingListSummaryTabs,
} from "@/services/bookings.service";
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
const PIPELINE_STAGES: Array<{
key: keyof BookingListSummaryTabs;
label: string;
color: string;
}> = [
{ key: "intake", label: "Intake", color: "#38bdf8" },
{ key: "in_approval", label: "Approval", color: "#f59e0b" },
{ key: "approved_contract", label: "Contract", color: "#8b5cf6" },
{ key: "payment", label: "Payment", color: "#fb923c" },
{ key: "operations", label: "Operations", color: "#14b8a6" },
{ key: "completed", label: "Completed", color: "#22c55e" },
];
const CARD_STYLE = {
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
@@ -62,7 +48,12 @@ export function BookingRequestsHeader({
return (
<Stack gap="lg">
<Group justify="flex-end" gap="sm">
<Button color="edr-green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
<Button
color="edr-green"
radius="lg"
leftSection={<Plus size={18} />}
onClick={onCreate}
>
Create booking
</Button>
<Button
@@ -90,7 +81,9 @@ export function BookingRequestsHeader({
label="Needs action"
value={val(metrics?.needsAction)}
hint="Submitted or pending"
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
ratio={
metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0
}
accent="orange"
/>
<HeroStat
@@ -137,12 +130,18 @@ function HeroStat({
accent?: OverviewAccent;
variant?: "area" | "line";
}) {
const [, accentDeep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
const [, accentDeep] =
overviewAccentGradients[accent] ?? overviewAccentGradients.default;
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
const pct =
ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
return (
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
<Paper
p="md"
radius="lg"
style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}
>
<Stack gap={8}>
<Group gap="sm" wrap="nowrap" align="center">
<Box
@@ -161,7 +160,13 @@ function HeroStat({
<Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
<Text
fw={800}
size="24px"
lh={1.05}
style={{ color: "#0f172a" }}
truncate
>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
@@ -179,66 +184,15 @@ function HeroStat({
</Group>
{pct == null ? (
<MiniSparkline variant={variant} accent={accent} baseline={0.5} seed={label} height={22} />
<MiniSparkline
variant={variant}
accent={accent}
baseline={0.5}
seed={label}
height={22}
/>
) : null}
</Stack>
</Paper>
);
}
function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 }));
const total = segments.reduce((sum, s) => sum + s.count, 0);
return (
<Paper p="md" radius="lg" style={CARD_STYLE}>
<Group justify="space-between" mb={10}>
<Text size="sm" fw={700} style={{ color: "#0f172a" }}>
Booking pipeline
</Text>
<Text size="xs" c="dimmed">
{total} active
</Text>
</Group>
<Box
style={{
display: "flex",
height: 14,
borderRadius: 999,
overflow: "hidden",
background: "var(--mantine-color-gray-2)",
gap: 2,
}}
>
{total > 0 ? (
segments.map((s) =>
s.count > 0 ? (
<Box
key={s.key}
title={`${s.label}: ${s.count}`}
style={{ width: `${(s.count / total) * 100}%`, background: s.color, transition: "width 200ms ease" }}
/>
) : null,
)
) : (
<Box style={{ width: "100%" }} />
)}
</Box>
<Group gap="md" mt={10} wrap="wrap">
{segments.map((s) => (
<Group key={s.key} gap={6} wrap="nowrap">
<Box style={{ width: 9, height: 9, borderRadius: 3, background: s.color }} />
<Text size="xs" c="dimmed">
{s.label}
</Text>
<Text size="xs" fw={700} style={{ color: "#0f172a" }}>
{s.count}
</Text>
</Group>
))}
</Group>
</Paper>
);
}

View File

@@ -10,7 +10,15 @@ import {
Wallet,
Weight,
} from "lucide-react";
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import {
Button,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
@@ -77,7 +85,12 @@ export function BookingRequestHero({
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: 1, color: "#B26C09" }}>
<Text
size="xs"
fw={700}
tt="uppercase"
style={{ letterSpacing: 1, color: "#B26C09" }}
>
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
@@ -99,8 +112,14 @@ export function BookingRequestHero({
<Group gap="lg" mt={4}>
<MetaItem icon={Building2} text={customerLabel} strong />
<MetaItem icon={Calendar} text={`Scheduled ${booking.scheduledDate}`} />
<MetaItem icon={Clock} text={`Created ${formatDate(booking.createdAt)}`} />
<MetaItem
icon={Calendar}
text={`Scheduled ${booking.scheduledDate}`}
/>
<MetaItem
icon={Clock}
text={`Created ${formatDate(booking.createdAt)}`}
/>
</Group>
</Stack>
</Group>
@@ -110,7 +129,10 @@ export function BookingRequestHero({
radius="lg"
p={4}
maw={640}
style={{ background: "var(--mantine-color-gray-0)", border: "1px solid var(--mantine-color-gray-2)" }}
style={{
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
@@ -120,13 +142,22 @@ export function BookingRequestHero({
<HeroTile
icon={Wallet}
label="Total value"
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`}
value={`${booking.paymentCurrency} ${amount.toLocaleString(
undefined,
{
minimumFractionDigits: 2,
},
)}`}
hint={booking.paymentStatus}
accent="edr-green"
/>
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
<HeroTile
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint="VGM total"
accent="blue"
/>
<HeroTile
icon={ContainerIcon}
label="Containers"
@@ -195,7 +226,13 @@ function HeroTile({
<Icon size={18} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.4 }}
>
{label}
</Text>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>

View File

@@ -138,6 +138,8 @@ export interface BookingDetailView {
priorityScore: number;
cargoTotalWeightVgm: number;
pnrCode?: string | null;
consolidationPartnerId?: string | null;
consolidationPartner?: (BookingNamedRefView & { reference?: string }) | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
paymentDeadline?: string | null;
createdAt: string;

View File

@@ -14,9 +14,11 @@ import {
TextInput,
ActionIcon,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { FLEET_SELECT_NONE, type FleetFormFieldDef } from "@/pages/fleet/config/resources";
import {
FLEET_SELECT_NONE,
type FleetFormFieldDef,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetFormDialogProps {
@@ -87,12 +89,19 @@ const FleetFormDialog = ({
const stringValue =
typeof value === "string" ? value.trim() : String(value ?? "");
if (field.required && (stringValue === "" || stringValue === FLEET_SELECT_NONE)) {
if (
field.required &&
(stringValue === "" || stringValue === FLEET_SELECT_NONE)
) {
next[field.name] = `${field.label} is required`;
}
// Validate date format (YYYY-MM-DD) - DatePicker ensures this
if (field.type === "date" && stringValue && stringValue !== FLEET_SELECT_NONE) {
if (
field.type === "date" &&
stringValue &&
stringValue !== FLEET_SELECT_NONE
) {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(stringValue)) {
next[field.name] = `${field.label} must be a valid date`;
@@ -115,7 +124,8 @@ const FleetFormDialog = ({
const payload = Object.fromEntries(
Object.entries(values)
.map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "") return [key, undefined];
if (value === FLEET_SELECT_NONE || value === "")
return [key, undefined];
return [key, value];
})
.filter(([, value]) => value !== undefined),
@@ -133,20 +143,34 @@ const FleetFormDialog = ({
key={field.name}
label={field.label}
data={field.options ?? []}
value={value == null || value === "" ? (field.noneOption ? FLEET_SELECT_NONE : null) : String(value)}
value={
value == null || value === ""
? field.noneOption
? FLEET_SELECT_NONE
: null
: String(value)
}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
}
error={error}
searchable
disabled={selectOptionsLoading}
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
) : undefined
}
/>
);
}
if (field.type === "multiselect") {
const arrayValue = Array.isArray(value) ? value : (typeof value === "string" && value ? [value] : []);
const arrayValue = Array.isArray(value)
? value
: typeof value === "string" && value
? [value]
: [];
return (
<MultiSelect
@@ -162,7 +186,11 @@ const FleetFormDialog = ({
searchable
clearable
disabled={selectOptionsLoading}
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
) : undefined
}
/>
);
}
@@ -194,7 +222,10 @@ const FleetFormDialog = ({
placeholder={field.placeholder}
value={String(value ?? "")}
onChange={(e) =>
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value }))
setValues((current) => ({
...current,
[field.name]: e.currentTarget?.value,
}))
}
error={error}
minRows={3}
@@ -221,7 +252,7 @@ const FleetFormDialog = ({
disabled={field.disabled}
description={field.description || "Select a date"}
rightSection={
<ActionIcon size="sm" variant="subtle" color="green" pointer={false}>
<ActionIcon size="sm" variant="subtle" color="green">
<Calendar size={16} />
</ActionIcon>
}
@@ -250,7 +281,10 @@ const FleetFormDialog = ({
description={field.description}
value={String(value ?? "")}
onChange={(e) =>
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value }))
setValues((current) => ({
...current,
[field.name]: e.currentTarget?.value,
}))
}
error={error}
disabled={field.disabled}
@@ -276,7 +310,11 @@ const FleetFormDialog = ({
<Button variant="default" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button color="edr-green" loading={isSubmitting} onClick={handleSubmit}>
<Button
color="edr-green"
loading={isSubmitting}
onClick={handleSubmit}
>
Save
</Button>
</Group>
@@ -285,4 +323,4 @@ const FleetFormDialog = ({
);
};
export default FleetFormDialog;
export default FleetFormDialog;

View File

@@ -1,5 +1,5 @@
import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react";
import { ActionIcon, Group, Menu, MenuItem, Tooltip } from "@mantine/core";
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
@@ -37,30 +37,39 @@ const FleetRecordActions = ({
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<Tooltip label="Actions">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
>
<ActionIcon variant="subtle" color="gray" size="sm">
<MoreVertical size={16} strokeWidth={2} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{isVehicle && onAssignDriver ? (
<MenuItem onClick={() => onAssignDriver(record)} leftSection={<Users size={14} strokeWidth={2} />}>
<MenuItem
onClick={() => onAssignDriver(record)}
leftSection={<Users size={14} strokeWidth={2} />}
>
Assign Driver
</MenuItem>
) : null}
<MenuItem onClick={() => onEdit(record)} leftSection={<Edit2 size={14} strokeWidth={2} />}>
<MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit
</MenuItem>
{showDetail ? (
<MenuItem onClick={handleDetail} leftSection={<Eye size={14} strokeWidth={2} />}>
<MenuItem
onClick={handleDetail}
leftSection={<Eye size={14} strokeWidth={2} />}
>
View details
</MenuItem>
) : null}
<MenuItem color="red" onClick={() => onRemove(record)} leftSection={<Trash2 size={14} strokeWidth={2} />}>
<MenuItem
color="red"
onClick={() => onRemove(record)}
leftSection={<Trash2 size={14} strokeWidth={2} />}
>
{removeLabel}
</MenuItem>
</Menu.Dropdown>
@@ -72,30 +81,39 @@ const FleetRecordActions = ({
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<Tooltip label="Actions">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
>
<ActionIcon variant="subtle" color="gray" size="sm">
<MoreVertical size={16} strokeWidth={2} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{isVehicle && onAssignDriver ? (
<MenuItem onClick={() => onAssignDriver(record)} leftSection={<Users size={14} strokeWidth={2} />}>
<MenuItem
onClick={() => onAssignDriver(record)}
leftSection={<Users size={14} strokeWidth={2} />}
>
Assign Driver
</MenuItem>
) : null}
<MenuItem onClick={() => onEdit(record)} leftSection={<Edit2 size={14} strokeWidth={2} />}>
<MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit
</MenuItem>
{showDetail ? (
<MenuItem onClick={handleDetail} leftSection={<Eye size={14} strokeWidth={2} />}>
<MenuItem
onClick={handleDetail}
leftSection={<Eye size={14} strokeWidth={2} />}
>
View details
</MenuItem>
) : null}
<MenuItem color="red" onClick={() => onRemove(record)} leftSection={<Trash2 size={14} strokeWidth={2} />}>
<MenuItem
color="red"
onClick={() => onRemove(record)}
leftSection={<Trash2 size={14} strokeWidth={2} />}
>
{removeLabel}
</MenuItem>
</Menu.Dropdown>

View File

@@ -1,4 +1,4 @@
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
import { Stack, Group, Text, Card, SimpleGrid, Skeleton } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
@@ -85,8 +85,15 @@ const RuleEngineCardGrid = ({
if (status === "error") {
return (
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
<Text size="lg" fw={600} c="red">Failed to load data</Text>
<Stack
align="center"
justify="center"
p="xl"
style={{ minHeight: "400px" }}
>
<Text size="lg" fw={600} c="red">
Failed to load data
</Text>
<Text size="sm" c="dimmed">
Please refresh the page or try again later.
</Text>
@@ -118,8 +125,15 @@ const RuleEngineCardGrid = ({
if (status === "success" && rows.length === 0) {
return (
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
<Text size="lg" fw={600}>{emptyMessage}</Text>
<Stack
align="center"
justify="center"
p="xl"
style={{ minHeight: "400px" }}
>
<Text size="lg" fw={600}>
{emptyMessage}
</Text>
<Text size="sm" c="dimmed">
Try adjusting your search or add a new record.
</Text>
@@ -197,7 +211,10 @@ const RuleEngineCardGrid = ({
{subtitle && (
<Group gap="xs">
<Text size="xs" c="dimmed" fw={500}>
{presentation.subtitleKey === "stepOrder" ? "Step" : "Type"}:
{presentation.subtitleKey === "stepOrder"
? "Step"
: "Type"}
:
</Text>
<Text size="xs" fw={500}>
{subtitle}
@@ -207,7 +224,12 @@ const RuleEngineCardGrid = ({
{presentation.detailColumns.map((col) => {
const displayValue = getSmartValue(record, col.accessorKey);
return (
<Group key={col.id} justify="space-between" gap="xs" align="flex-start">
<Group
key={col.id}
justify="space-between"
gap="xs"
align="flex-start"
>
<Text size="xs" c="dimmed" fw={500}>
{col.header}:
</Text>
@@ -220,14 +242,19 @@ const RuleEngineCardGrid = ({
</Stack>
)}
<Group justify="flex-end" gap="xs" pt="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<Group
justify="flex-end"
gap="xs"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<RuleEngineRecordActions
record={record}
config={config}
layout="compact"
readOnly={readOnly}
onEdit={onEdit ?? (() => {})}
onDelete={onDelete ?? (() => {})}
onEdit={onEdit ?? (() => { })}
onDelete={onDelete ?? (() => { })}
onViewChain={onViewChain}
onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate}

View File

@@ -1,4 +1,4 @@
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
import { Group, Pagination, Select, Text } from "@mantine/core";
export interface RuleEngineListFooterProps {

View File

@@ -11,7 +11,7 @@ import {
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
export interface BookingDetailData {
bookingId: string;

View File

@@ -11,7 +11,7 @@ import {
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
interface InteractiveTrainConsistProps {

View File

@@ -1,7 +1,7 @@
import { Button, Group, Modal, Stack, Text, Badge } from "@mantine/core";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
type WagonWithAllocation = TrainScheduleDetail["trainSet"]["wagons"][number];
type WagonWithAllocation = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
interface RemoveBookingModalProps {
opened: boolean;
@@ -21,7 +21,6 @@ export const RemoveBookingModal = ({
if (!wagon || !wagon.allocations?.[0]) return null;
const allocation = wagon.allocations[0];
const booking = allocation.booking;
return (
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
@@ -32,12 +31,12 @@ export const RemoveBookingModal = ({
</Text>
<Stack gap={4}>
<Text size="sm">
<strong>Reference:</strong> {booking?.reference || "N/A"}
<strong>Reference:</strong> {allocation.bookingReference || "N/A"}
</Text>
<Text size="sm">
<strong>Freight Type:</strong>{" "}
<Badge size="sm" variant="light">
{booking?.freightType || "N/A"}
{allocation.loadType || "N/A"}
</Badge>
</Text>
<Text size="sm">

View File

@@ -10,7 +10,7 @@ import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
interface TrainConsistViewProps {
scheduleDetail: TrainScheduleDetail;
@@ -103,9 +103,9 @@ export const TrainConsistView = ({
<Stack gap="md" style={{ width: "100%" }}>
<TrainStatsBar
weightUsed={weightUsed}
weightMax={scheduleDetail.locomotive?.maxWeightTons ?? trainSet?.locomotive?.maxPullWeightTons ?? null}
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
lengthUsed={lengthUsed}
lengthMax={scheduleDetail.locomotive?.maxLengthMeters ?? trainSet?.locomotive?.maxTrainLengthMeters ?? null}
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
wagonCount={wagons.length}
wagonMax={maxWagons}
/>

View File

@@ -12,7 +12,7 @@ import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { ContainerNumberInput } from "./ContainerNumberInput";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
interface WagonCardProps {
wagon: Wagon;

View File

@@ -1,12 +1,15 @@
import { useMemo } from 'react';
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
import { useMemo } from "react";
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { getNextInventoryAction } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options';
import {
INVENTORY_NEXT_ACTION,
type InventoryAction,
type WarehouseInventoryItem,
} from "@/types/warehouse";
import { InventoryStatusBadge } from "./badges";
import { formatDate, formatNumber, humanizeEnum } from "./options";
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
@@ -27,21 +30,21 @@ interface WarehouseInventoryTableProps {
}
const itemKind = (item: WarehouseInventoryItem) => {
if (item.containerId) return { label: 'Container', color: 'blue' };
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
if (item.goodsId) return { label: 'Goods', color: 'orange' };
return { label: '—', color: 'gray' };
if (item.containerId) return { label: "Container", color: "blue" };
if (item.cargoId) return { label: "Cargo", color: "grape" };
if (item.goodsId) return { label: "Goods", color: "orange" };
return { label: "—", color: "gray" };
};
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'edr-green',
'ready-for-pickup': 'orange',
release: 'yellow',
deliver: 'green',
store: "blue",
reserve: "grape",
"ready-for-loading": "cyan",
load: "teal",
dispatch: "edr-green",
"ready-for-pickup": "orange",
release: "yellow",
deliver: "green",
};
export function WarehouseInventoryTable({
@@ -52,18 +55,12 @@ export function WarehouseInventoryTable({
onHistory,
onInspect,
onFeePreview,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) {
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
() => [
{
id: 'booking',
header: 'Booking',
id: "booking",
header: "Booking",
cell: ({ row }) =>
row.original.bookingId ? (
<Tooltip label={row.original.bookingId} withArrow>
@@ -78,16 +75,28 @@ export function WarehouseInventoryTable({
),
},
{
id: 'facility',
header: 'Facility',
cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—',
id: "facility",
header: "Facility",
cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—",
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
{
id: 'item',
header: 'Item',
id: "warehouse",
header: "Warehouse",
cell: ({ row }) => row.original.warehouse?.code ?? "—",
},
{
id: "yard",
header: "Yard",
cell: ({ row }) => row.original.yard?.code ?? "—",
},
{
id: "zone",
header: "Zone",
cell: ({ row }) => row.original.zone?.code ?? "—",
},
{
id: "item",
header: "Item",
cell: ({ row }) => {
const kind = itemKind(row.original);
return (
@@ -97,27 +106,44 @@ export function WarehouseInventoryTable({
);
},
},
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'status',
header: 'Status',
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
id: "qty",
header: "Qty",
cell: ({ row }) => formatNumber(row.original.quantity),
},
{
id: 'arrived',
header: 'Arrived',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
id: "weight",
header: "Weight",
cell: ({ row }) => formatNumber(row.original.weight),
},
{
id: 'actions',
header: '',
id: "status",
header: "Status",
cell: ({ row }) => (
<InventoryStatusBadge status={row.original.status} />
),
},
{
id: "arrived",
header: "Arrived",
cell: ({ row }) => (
<Text size="xs">{formatDate(row.original.arrivedAt)}</Text>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const item = row.original;
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Group
gap="xs"
justify="flex-end"
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{nextAction && (
<Button
size="compact-xs"
@@ -126,32 +152,48 @@ export function WarehouseInventoryTable({
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
{humanizeEnum(nextAction.replace(/-/g, "_"))}
</Button>
)}
{item.status !== 'DISPATCHED' && (
{item.status !== "DISPATCHED" && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => onMove(item)}
>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
<ActionIcon
variant="subtle"
color="orange"
onClick={() => onInspect(item)}
>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
<ActionIcon
variant="subtle"
color="teal"
onClick={() => onFeePreview(item)}
>
<Coins size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => onHistory(item)}
>
<History size={16} />
</ActionIcon>
</Tooltip>

View File

@@ -1,53 +1,80 @@
import { Badge } from '@mantine/core';
import { Badge } from "@mantine/core";
import type { InventoryStatus, WarehouseStatus, WarehouseType } from '@/types/warehouse';
import type {
InventoryStatus,
WarehouseStatus,
WarehouseType,
} from "@/types/warehouse";
const humanize = (value: string) =>
value
.toLowerCase()
.split('_')
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
.join(" ");
const badgeStyle = {
fontSize: '0.7rem',
letterSpacing: '0.04em',
whiteSpace: 'nowrap' as const,
fontSize: "0.7rem",
letterSpacing: "0.04em",
whiteSpace: "nowrap" as const,
};
export function WarehouseTypeBadge({ type }: { type: WarehouseType }) {
const color = type === 'CLOSED_WAREHOUSE' ? 'indigo' : 'teal';
const color = type === "CLOSED_WAREHOUSE" ? "indigo" : "teal";
return (
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
<Badge
color={color}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(type)}
</Badge>
);
}
export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
const color = status === 'ACTIVE' ? 'edr-green' : 'gray';
const color = status === "ACTIVE" ? "edr-green" : "gray";
return (
<Badge color={color} variant="light" size="sm" radius="md" tt="uppercase" fw={600} style={badgeStyle}>
<Badge
color={color}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
style={badgeStyle}
>
{status}
</Badge>
);
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
UNLOADED: 'indigo',
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',
READY_FOR_LOADING: 'cyan',
LOADED: 'teal',
DISPATCHED: 'edr-green',
DELIVERED: 'edr-green',
UNLOADED: "indigo",
RECEIVED: "yellow",
STORED: "blue",
RESERVED: "grape",
READY_FOR_LOADING: "cyan",
LOADED: "teal",
READY_FOR_PICKUP: "teal",
DISPATCHED: "edr-green",
DELIVERED: "edr-green",
};
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
const color = inventoryStatusColor[status] ?? 'gray';
const color = inventoryStatusColor[status] ?? "gray";
return (
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
<Badge
color={color}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(status)}
</Badge>
);

View File

@@ -69,6 +69,18 @@ export const QUERY_KEYS = {
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
},
FIRST_MILE: {
ROOT: ["first-mile"] as const,
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["first-mile", "detail", id] as const,
},
LAST_MILE: {
ROOT: ["last-mile"] as const,
list: (filter?: Record<string, unknown>) => ["last-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["last-mile", "detail", id] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>

View File

@@ -362,6 +362,18 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/vehicles/${id}`,
},
FIRST_MILE: {
BASE: '/first-mile',
BY_ID: (id: string) => `/first-mile/${id}`,
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
},
LAST_MILE: {
BASE: '/last-mile',
BY_ID: (id: string) => `/last-mile/${id}`,
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
},
DRIVERS: {
BASE: '/drivers',
BY_ID: (id: string) => `/drivers/${id}`,

View File

@@ -1,3 +1,3 @@
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -64,7 +64,11 @@ export interface BookingActionDef {
export type BookingActionContext = Pick<
BookingDetail,
"status" | "paymentCurrency" | "approvalSteps" | "reference" | "schedulingStatus"
| "status"
| "paymentCurrency"
| "approvalSteps"
| "reference"
| "schedulingStatus"
>;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
@@ -76,7 +80,9 @@ const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
"",
]);
export function canAllocateBooking(booking: Pick<BookingDetail, "status" | "schedulingStatus">) {
export function canAllocateBooking(
booking: Pick<BookingDetail, "status" | "schedulingStatus">,
) {
return (
booking.status === "PAID" &&
ALLOCATABLE_SCHEDULING_STATUSES.has(booking.schedulingStatus ?? undefined)
@@ -345,13 +351,21 @@ export function getBookingActions(
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break;
case "FULLY_EXECUTED":
actions = [{ ...VIEW_CONTRACT_ACTION, label: "View executed contract", primary: true }];
actions = [
{
...VIEW_CONTRACT_ACTION,
label: "View executed contract",
primary: true,
},
];
break;
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
case "PAID":
if (canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })) {
if (
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
) {
actions = [
{
id: "allocateBooking",
@@ -446,7 +460,7 @@ export function listRowHasActions(
paymentCurrency: row.paymentCurrency,
reference: "",
approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.schedulingStatus,
schedulingStatus: row.status,
},
user,
);

View File

@@ -1,45 +0,0 @@
import { customers, type Customer } from "@/pages/customers/customers.mock";
import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
import {
consignments,
type Consignment,
} from "@/pages/consignments/consignments.mock";
import {
shipments,
type Shipment,
} from "@/pages/tracking/shipments.mock";
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
/**
* Mock "logged-in customer". When auth integrates, replace this with the value
* pulled from `@edr/iamui-common` / the JWT context.
*/
const CURRENT_CUSTOMER_ID = 1;
export function getCurrentCustomer(): Customer {
return (
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
(customers[0] as Customer)
);
}
export function getMyBookings(): Booking[] {
const me = getCurrentCustomer();
return bookings.filter((b) => b.customerId === me.id);
}
export function getMyConsignments(): Consignment[] {
const me = getCurrentCustomer();
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
return consignments.filter((c) => myBookingIds.has(c.bookingId));
}
export function getMyShipments(): Shipment[] {
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
return shipments.filter((s) => myBookingIds.has(s.bookingId));
}
export function getMyInvoices(): Invoice[] {
const me = getCurrentCustomer();
return invoices.filter((inv) => inv.customerId === me.id);
}

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

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

View File

@@ -93,7 +93,7 @@ const OverviewPage = () => {
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
if (!summary?.kpis) return 0;
const group = summary.kpis[tab.kpiKey] as Record<string, number>;
const group = summary.kpis[tab.kpiKey] as unknown as Record<string, number>;
return group[tab.metricKey] ?? 0;
};

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

@@ -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 ?? '-';

View File

@@ -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})`,
}))}

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

@@ -1,49 +1,57 @@
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,
useCargoLeafOptions,
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
useRuleEngineOrderList,
useRuleEngineOrderMutations,
useApprovalChain,
useCargoLeafOptions,
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 => {
@@ -61,16 +69,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(
@@ -91,9 +100,9 @@ const RuleEngineResourcePage = () => {
pageSize: pagination.pageSize,
...(config?.orderConfig
? {
sortBy: config.orderConfig.field,
sortOrder: "ASC" as const,
}
sortBy: config.orderConfig.field,
sortOrder: "ASC" as const,
}
: {}),
}),
[
@@ -121,11 +130,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",
@@ -147,10 +157,7 @@ const RuleEngineResourcePage = () => {
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
useCargoLeafOptions(usesCargoTypeField);
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions(
config?.slug === "rates",
usesContainerTypeField,
);
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
@@ -160,10 +167,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") {
@@ -196,14 +202,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) => ({
@@ -212,7 +220,6 @@ const RuleEngineResourcePage = () => {
}));
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
const handleApproveRate = useCallback(
(record: RuleEngineRecord) => {
approve.mutate(String(record.id));
@@ -272,7 +279,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}
@@ -283,7 +292,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";
@@ -371,15 +388,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}
@@ -394,10 +416,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.`}
@@ -443,7 +467,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}
@@ -455,7 +481,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.`
@@ -500,7 +530,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)}>
@@ -537,14 +568,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>

View File

@@ -24,6 +24,7 @@ function mapCompany(dto: Record<string, unknown>): Company {
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
return {
...(dto as unknown as Company),
companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [],
contactPersonName: (attrs.contactPersonName as string | null) ?? null,
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
generalManagerName: (attrs.generalManagerName as string | null) ?? null,

View File

@@ -0,0 +1,64 @@
import { api } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
export const FIRST_MILE_STATUSES = [
'PAYMENT_PENDING',
'READY_TO_TRANSIT',
'IN_TRANSIT',
'RECEIVED_TO_PORT',
] as const;
export type FirstMileApiStatus = (typeof FIRST_MILE_STATUSES)[number];
export interface FirstMileBooking {
id: string;
reference: string;
firstMilePickupAddress?: string | null;
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;
totalAmount: number;
scheduledDate?: string | null;
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
serviceType?: { id: string; name?: string } | null;
originYard?: { id: string; name?: string } | null;
destinationYard?: { id: string; name?: string } | null;
cargoType?: { id: string; name?: string } | null;
}
export interface FirstMileVehicle {
id: string;
plateNumber: string;
manufacturer: string;
model: string;
}
export interface FirstMileRecord {
id: string;
bookingId: string;
status: FirstMileApiStatus;
advancedPayment: number;
remainingPayment: number;
estimatedKm?: number | null;
exactKm?: number | null;
vehicleId?: string | null;
booking?: FirstMileBooking | null;
vehicle?: FirstMileVehicle | null;
createdAt: string;
updatedAt: string;
}
export interface FirstMileListResponse {
data: FirstMileRecord[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}
const FM = URL_CONSTANTS.FIRST_MILE;
export const firstMileService = {
list: (pageSize = 1000) =>
api.get<FirstMileListResponse>(`${FM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get<FirstMileRecord>(FM.BY_ID(id)),
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null }) =>
api.patch<FirstMileRecord>(FM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
};

View File

@@ -0,0 +1,64 @@
import { api } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
'READY_TO_TRANSIT',
'IN_TRANSIT',
'DELIVERED',
] as const;
export type LastMileApiStatus = (typeof LAST_MILE_STATUSES)[number];
export interface LastMileBooking {
id: string;
reference: string;
lastMileDeliveryAddress?: string | null;
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;
totalAmount: number;
scheduledDate?: string | null;
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
serviceType?: { id: string; name?: string } | null;
originYard?: { id: string; name?: string } | null;
destinationYard?: { id: string; name?: string } | null;
cargoType?: { id: string; name?: string } | null;
}
export interface LastMileVehicle {
id: string;
plateNumber: string;
manufacturer: string;
model: string;
}
export interface LastMileRecord {
id: string;
bookingId: string;
status: LastMileApiStatus;
advancedPayment: number;
remainingPayment: number;
estimatedKm?: number | null;
exactKm?: number | null;
vehicleId?: string | null;
booking?: LastMileBooking | null;
vehicle?: LastMileVehicle | null;
createdAt: string;
updatedAt: string;
}
export interface LastMileListResponse {
data: LastMileRecord[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}
const LM = URL_CONSTANTS.LAST_MILE;
export const lastMileService = {
list: (pageSize = 1000) =>
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) =>
api.patch<LastMileRecord>(LM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post<LastMileRecord>(LM.ACCEPT(bookingReference)),
};

View File

@@ -64,7 +64,11 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
}
};
const defaultMeta = (dataLength: number, page = 1, pageSize = 10): RuleEngineListMeta => ({
const defaultMeta = (
dataLength: number,
page = 1,
pageSize = 10,
): RuleEngineListMeta => ({
total: dataLength,
page,
pageSize,
@@ -76,7 +80,7 @@ const isPaginatedListResult = <T extends RuleEngineRecord>(
): value is RuleEngineListResult<T> =>
Boolean(value) &&
typeof value === "object" &&
"data" in value &&
"data" in (value ?? {}) &&
Array.isArray((value as RuleEngineListResult<T>).data);
const normalizeList = <T extends RuleEngineRecord>(
@@ -101,7 +105,10 @@ const normalizeList = <T extends RuleEngineRecord>(
}
if (Array.isArray(body)) {
return { data: body as T[], meta: defaultMeta(body.length, page, pageSize) };
return {
data: body as T[],
meta: defaultMeta(body.length, page, pageSize),
};
}
return { data: [], meta: defaultMeta(0, page, pageSize) };
@@ -158,7 +165,10 @@ export const ruleEngineService = {
return normalizeEntity<T>(response.data);
},
remove: async (resource: RuleEngineResourceSlug, id: string): Promise<void> => {
remove: async (
resource: RuleEngineResourceSlug,
id: string,
): Promise<void> => {
await client.delete(byIdPath(resource, id));
},
@@ -178,24 +188,36 @@ export const ruleEngineService = {
},
submitRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id));
const response = await client.post(
URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id),
);
return normalizeEntity<T>(response.data);
},
approveRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id));
const response = await client.post(
URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id),
);
return normalizeEntity<T>(response.data);
},
getApprovalChain: async (
requiresDirectorApproval = true,
): Promise<RuleEngineRecord[]> => {
const response = await client.get(URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_CHAIN, {
params: { requiresDirectorApproval },
});
const response = await client.get(
URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_CHAIN,
{
params: { requiresDirectorApproval },
},
);
const body = unwrap(response.data) as unknown;
if (Array.isArray(body)) return body as RuleEngineRecord[];
if (body && typeof body === "object" && "data" in body && Array.isArray((body as { data: unknown }).data)) {
if (
body &&
typeof body === "object" &&
"data" in body &&
Array.isArray((body as { data: unknown }).data)
) {
return (body as { data: RuleEngineRecord[] }).data;
}
return [];

View File

@@ -1,13 +1,12 @@
import { api as client } from '../auth/http';
import { unwrap } from '@/utils/endpoint';
import { URL_CONSTANTS } from '@/constants/URLS';
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
BatchBoardSchedule,
BatchBoardScheduleDetail,
BookableSchedule,
AssignBookingsPayload,
CompositionRemovalEntry,
CompositionUnassignedBooking,
UnassignedBookingsResponse,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
@@ -24,7 +23,7 @@ import type {
TrainTrackResponse,
WagonAllocationAttemptResult,
YardOption,
} from '@/types/trainScheduling';
} from "@/types/trainScheduling";
interface BookingReferenceDataResponse {
yard?: Array<YardOption & { label?: string }>;
@@ -58,7 +57,9 @@ export const trainSchedulingService = {
): Promise<TrainSchedulePreviewResponse> => {
const useUnified = !freightType || freightType === "MIXED";
const response = await client.post<TrainSchedulePreviewResponse>(
useUnified ? URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW : pathsFor(freightType).PREVIEW,
useUnified
? URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW
: pathsFor(freightType).PREVIEW,
payload,
);
return unwrap(response.data);
@@ -91,7 +92,9 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getBatchBoardDetail: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
getBatchBoardDetail: async (
scheduleId: string,
): Promise<BatchBoardScheduleDetail> => {
const response = await client.get<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD_DETAIL(scheduleId),
);
@@ -132,7 +135,9 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
runAllocation: async (scheduleId: string): Promise<WagonAllocationAttemptResult> => {
runAllocation: async (
scheduleId: string,
): Promise<WagonAllocationAttemptResult> => {
const response = await client.post<WagonAllocationAttemptResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_ALLOCATION(scheduleId),
{},
@@ -152,20 +157,29 @@ export const trainSchedulingService = {
},
markBookingPaid: async (bookingId: string): Promise<void> => {
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId), {});
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),
{},
);
},
expireBooking: async (bookingId: string): Promise<void> => {
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.EXPIRE_BOOKING(bookingId), {});
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.EXPIRE_BOOKING(bookingId),
{},
);
},
moveBookingSchedule: async (
bookingId: string,
trainScheduleId: string,
): Promise<void> => {
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_BOOKING_SCHEDULE(bookingId), {
trainScheduleId,
});
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_BOOKING_SCHEDULE(bookingId),
{
trainScheduleId,
},
);
},
getScheduleById: async (
@@ -173,7 +187,9 @@ export const trainSchedulingService = {
freightType?: FreightType,
): Promise<TrainScheduleDetail> => {
const response = await client.get<TrainScheduleDetail>(
pathsFor(freightType === "MIXED" ? undefined : freightType).SCHEDULE_BY_ID(id),
pathsFor(
freightType === "MIXED" ? undefined : freightType,
).SCHEDULE_BY_ID(id),
);
return unwrap(response.data);
},
@@ -225,7 +241,9 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
finalizeSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
finalizeSchedule: async (
scheduleId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.FINALIZE(scheduleId),
{},
@@ -233,7 +251,9 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
dispatchSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
dispatchSchedule: async (
scheduleId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
{},
@@ -272,13 +292,17 @@ export const trainSchedulingService = {
freightType: FreightType = "CONTAINER",
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
pathsFor(freightType === "MIXED" ? undefined : freightType).CANCEL_SCHEDULE(id),
pathsFor(
freightType === "MIXED" ? undefined : freightType,
).CANCEL_SCHEDULE(id),
{},
);
return unwrap(response.data);
},
getAvailableLocomotives: async (routeId?: string): Promise<LocomotiveRecord[]> => {
getAvailableLocomotives: async (
routeId?: string,
): Promise<LocomotiveRecord[]> => {
if (routeId) {
const response = await client.get<LocomotiveRecord[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_LOCOMOTIVES,
@@ -286,9 +310,12 @@ export const trainSchedulingService = {
);
return unwrap(response.data);
}
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
params: { status: 'AVAILABLE' },
});
const response = await client.get<LocomotiveRecord[]>(
URL_CONSTANTS.LOCOMOTIVES.BASE,
{
params: { status: "AVAILABLE" },
},
);
return unwrap(response.data);
},
@@ -380,7 +407,10 @@ export const trainSchedulingService = {
}));
},
removeWagonSlot: async (scheduleId: string, wagonId: string): Promise<TrainScheduleDetail> => {
removeWagonSlot: async (
scheduleId: string,
wagonId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.delete<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.REMOVE_WAGON_SLOT(scheduleId, wagonId),
);
@@ -392,7 +422,10 @@ export const trainSchedulingService = {
itemId: string,
payload: { containerNumber: string | null },
): Promise<{ id: string; containerNumber: string | null }> => {
const response = await client.patch<{ id: string; containerNumber: string | null }>(
const response = await client.patch<{
id: string;
containerNumber: string | null;
}>(
URL_CONSTANTS.TRAIN_SCHEDULING.UPDATE_CONTAINER_ITEM(scheduleId, itemId),
payload,
);

View File

@@ -0,0 +1,55 @@
declare module "@tria-plc/iamui" {
import type { ComponentType } from "react";
export interface DesignConfig {
brand: { appName: string; logoUrl: string };
colors: Record<string, string>;
typography: {
fontFamily: string;
headingFontFamily: string;
baseFontSize: string;
fontWeight: string;
};
shape: { radius: string };
shadows: Record<string, string>;
components: {
buttonDefaultVariant?: string;
inputDefaultSize?: string;
inputRadius?: string;
modalRadius?: string;
tableHighlightOnHover?: boolean;
};
layout: Record<string, string | Record<string, unknown>>;
appearance: {
colorScheme: string;
slots: Record<string, { styles: Record<string, string> }>;
customCss: string;
};
}
export interface UserManagementRuntimeOptions {
basename: string;
apiBaseUrl: string;
apiUrl: string;
recordApiUrl: string;
chronicleUrl: string;
auditApiUrl: string;
}
export interface UserManagementSessionSeed {
token: string;
refreshToken?: string;
rememberMe: boolean;
}
export interface UserManagementAppProps {
config: DesignConfig;
runtime: UserManagementRuntimeOptions;
session: {
initialSession: UserManagementSessionSeed | null;
enableEmbeddedAuthBridge: boolean;
};
}
export const UserManagementApp: ComponentType<UserManagementAppProps>;
}

View File

@@ -323,7 +323,6 @@ export interface TrainScheduleWagonAllocation {
export interface TrainScheduleDetail {
id: string;
status: TrainScheduleStatus | string;
warnings?: string[];
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
@@ -395,6 +394,7 @@ export interface TrainScheduleDetail {
weightTons: number;
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
}>;
warnings?: string[];
}

View File

@@ -7,7 +7,7 @@ import {
export interface OperationsQueueGroups {
government: BookingListRow[];
commercial: ThreeHourBookingBucket[];
commercial: ThreeHourBookingBucket<BookingListRow>[];
}
export function groupBookingsForOperationsQueue(