fix: type errors

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

View File

@@ -9,7 +9,7 @@
"preview": "vite preview --port 5183",
"lint": "eslint src",
"test": "vitest run",
"type-check": "tsc --noEmit"
"type-check": "tsc -b"
},
"dependencies": {
"@edr/types": "workspace:*",

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

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

@@ -1,12 +1,14 @@
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 { 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 type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { getNextInventoryAction } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options';
import type {
InventoryAction,
WarehouseInventoryItem,
} from "@/types/warehouse";
import { InventoryStatusBadge } from "./badges";
import { formatDate, formatNumber, humanizeEnum } from "./options";
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
@@ -27,21 +29,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({
@@ -62,8 +64,8 @@ export function WarehouseInventoryTable({
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 +80,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 +111,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 +157,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

@@ -55,7 +55,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([
@@ -67,7 +71,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)
@@ -286,10 +292,18 @@ 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 "PAID":
if (canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })) {
if (
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
) {
actions = [
{
id: "allocateBooking",
@@ -384,7 +398,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

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import {
Box,
Card,
Group,
Badge,
Badge as MantineBadge,
Select,
Stack,
@@ -28,7 +29,6 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import {
Badge,
DataTable,
DataTableFooter,
usePagination,
@@ -36,7 +36,12 @@ import {
} from "@edr/ui-common";
const STATUS_TABS = [
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
{
key: "all",
label: "All",
statuses: undefined as string | undefined,
icon: LayoutGrid,
},
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
{
key: "processing",
@@ -44,7 +49,12 @@ const STATUS_TABS = [
statuses: "processing,action-required",
icon: Loader2,
},
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
{
key: "failed",
label: "Failed",
statuses: "failed,canceled",
icon: XCircle,
},
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
] as const;
@@ -81,13 +91,14 @@ function formatDate(iso: string | null): string {
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
year: "numeric",
month: "short",
day: "numeric",
});
}
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function PaymentsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -124,9 +135,9 @@ export default function PaymentsPage() {
summary === undefined
? undefined
: (summary.success ?? 0) +
(summary.processing ?? 0) +
(summary.failed ?? 0) +
(summary.refunded ?? 0),
(summary.processing ?? 0) +
(summary.failed ?? 0) +
(summary.refunded ?? 0),
success: summary?.success,
processing: summary?.processing,
failed: summary?.failed,
@@ -285,7 +296,10 @@ export default function PaymentsPage() {
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
rightSection={
query && (
@@ -309,7 +323,10 @@ export default function PaymentsPage() {
value={method}
onChange={(value) => {
setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
style={{ minWidth: 180 }}
/>

View File

@@ -2,8 +2,18 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import type { ColumnDef } from "@tanstack/react-table";
import { Box, Card, Button, Modal, Stack, Group, Text, List, Loader } from "@mantine/core";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
Card,
Button,
Modal,
Stack,
Group,
Text,
List,
Loader,
} from "@mantine/core";
import { Plus } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
@@ -60,16 +70,17 @@ const RuleEngineResourcePage = () => {
const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined;
const defaultPath = category
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${
category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
}`
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
}`
: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`;
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
null,
);
const [chainOpen, setChainOpen] = useState(false);
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const { viewMode, setViewMode } = useRuleEngineViewMode(
@@ -90,9 +101,9 @@ const RuleEngineResourcePage = () => {
pageSize: pagination.pageSize,
...(config?.orderConfig
? {
sortBy: config.orderConfig.field,
sortOrder: "ASC" as const,
}
sortBy: config.orderConfig.field,
sortOrder: "ASC" as const,
}
: {}),
}),
[
@@ -120,11 +131,12 @@ const RuleEngineResourcePage = () => {
const { reorder, moveOrder } = useRuleEngineOrderMutations(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(orderDialogOpen && config?.orderConfig),
config?.orderConfig?.field,
);
const { data: orderListData, isLoading: orderListLoading } =
useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(orderDialogOpen && config?.orderConfig),
config?.orderConfig?.field,
);
const { submit, approve } = useRateWorkflow();
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
chainOpen && config?.slug === "approval-rules",
@@ -141,10 +153,7 @@ const RuleEngineResourcePage = () => {
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions(
config?.slug === "rates",
usesContainerTypeField,
);
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
@@ -154,10 +163,9 @@ const RuleEngineResourcePage = () => {
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
return {
...field,
options:
cargoParentOptions ?? [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
],
options: cargoParentOptions ?? [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
],
};
}
if (field.name === "containerTypeId") {
@@ -183,14 +191,16 @@ const RuleEngineResourcePage = () => {
const pageCount = meta?.totalPages ?? 1;
const totalCount = meta?.total ?? rows.length;
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(formOpen && !editing && config?.orderConfig),
config?.orderConfig?.field,
);
const { data: createPositionList, isLoading: createPositionLoading } =
useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(formOpen && !editing && config?.orderConfig),
config?.orderConfig?.field,
);
const createPositionOptions = useMemo(() => {
if (!config?.orderConfig || !createPositionList?.data?.length) return undefined;
if (!config?.orderConfig || !createPositionList?.data?.length)
return undefined;
return createPositionList.data
.filter((row) => row.id)
.map((row) => ({
@@ -199,7 +209,6 @@ const RuleEngineResourcePage = () => {
}));
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
const handleApproveRate = useCallback(
(record: RuleEngineRecord) => {
approve.mutate(String(record.id));
@@ -259,7 +268,9 @@ const RuleEngineResourcePage = () => {
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
@@ -270,7 +281,15 @@ const RuleEngineResourcePage = () => {
});
return base;
}, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]);
}, [
canManage,
config,
submit,
handleApproveRate,
handleMoveOrder,
moveOrder.isPending,
totalCount,
]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -350,15 +369,20 @@ const RuleEngineResourcePage = () => {
onSearchChange={
config.supportsSearch
? (v) => {
setSearch(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}
setSearch(v);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}
: undefined
}
showSearch={Boolean(config.supportsSearch)}
searchPlaceholder={config.searchPlaceholder}
onManageOrder={
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
canManage && config.orderConfig
? () => setOrderDialogOpen(true)
: undefined
}
viewMode={viewMode}
onViewModeChange={setViewMode}
@@ -373,10 +397,12 @@ const RuleEngineResourcePage = () => {
error={
isError
? {
message: "Failed to load data",
description:
error instanceof Error ? error.message : "Unknown error",
}
message: "Failed to load data",
description:
error instanceof Error
? error.message
: "Unknown error",
}
: undefined
}
emptyMessage={`No ${itemLabel} found.`}
@@ -422,7 +448,9 @@ const RuleEngineResourcePage = () => {
onEdit={canManage ? openEdit : undefined}
onDelete={canManage ? setDeleteTarget : undefined}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
@@ -434,7 +462,11 @@ const RuleEngineResourcePage = () => {
<RuleEngineFormDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? `Edit ${config.label.replace(/s$/, "")}` : `Add ${config.label.replace(/s$/, "")}`}
title={
editing
? `Edit ${config.label.replace(/s$/, "")}`
: `Add ${config.label.replace(/s$/, "")}`
}
description={
editing
? `Update this ${config.label.toLowerCase()} record.`
@@ -478,7 +510,8 @@ const RuleEngineResourcePage = () => {
>
<Stack gap="md">
<Text size="sm">
This will soft-delete the selected {config.label.toLowerCase()} record.
This will soft-delete the selected {config.label.toLowerCase()}{" "}
record.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
@@ -515,14 +548,17 @@ const RuleEngineResourcePage = () => {
) : (
<>
{(chainData ?? []).length === 0 ? (
<Text size="sm" c="dimmed">No approval rules configured.</Text>
<Text size="sm" c="dimmed">
No approval rules configured.
</Text>
) : (
<List spacing="md">
{(chainData ?? []).map((step, index) => (
<List.Item key={String(step.id ?? index)}>
<Stack gap="xs">
<Text size="sm" fw={500}>
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
Step {String(step.stepOrder ?? index + 1)}:{" "}
{String(step.actionLabel ?? "")}
</Text>
<Text size="sm" c="dimmed">
Role: {String(step.requiredRole ?? "—")}

View File

@@ -44,7 +44,10 @@ import { KpiStrip, PageContainer } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
import {
TrainConsistView,
CompositionBookingTabs,
} from "@/components/trainScheduling/compositionEditor";
import {
BookingPipeline,
HeroChip,
@@ -67,10 +70,18 @@ const STATE_META: Record<
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock },
SELECTED_FOR_BATCH: {
label: "Selected for batch",
color: "orange",
icon: Clock,
},
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
PENDING_CONTRACT: {
label: "Pending contract",
color: "gray",
icon: Hourglass,
},
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
@@ -93,13 +104,13 @@ const fmtMeters = (n: number) =>
const fmtDateTime = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso))
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso))
: "—";
const initials = (name: string) =>
@@ -115,7 +126,12 @@ function StateBadge({ state }: { state: BatchBoardBookingState }) {
const meta = STATE_META[state];
const Icon = meta.icon;
return (
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
<Badge
variant="light"
color={meta.color}
radius="sm"
leftSection={<Icon size={11} />}
>
{meta.label}
</Badge>
);
@@ -232,7 +248,12 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
{fmtDateTime(b.selectedForBatchAt)} EAT
</Text>
{b.paymentDeadline ? (
<Text size="xs" c="orange.7" fw={600} style={{ whiteSpace: "nowrap" }}>
<Text
size="xs"
c="orange.7"
fw={600}
style={{ whiteSpace: "nowrap" }}
>
Pay by {fmtDateTime(b.paymentDeadline)} EAT
</Text>
) : null}
@@ -385,7 +406,9 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
borderRadius: 10,
flexShrink: 0,
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
border: total ? "1px solid #FBD171" : "1px solid var(--mantine-color-gray-2)",
border: total
? "1px solid #FBD171"
: "1px solid var(--mantine-color-gray-2)",
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
}}
>
@@ -396,7 +419,9 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
{timeLabelOf(window.label)}
</Text>
<Text size="xs" c="dimmed">
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
{total
? `${total} booking${total === 1 ? "" : "s"}`
: "Empty window"}
</Text>
</Box>
</Group>
@@ -443,7 +468,9 @@ export default function BatchScheduleDetailPage() {
data?.windows.some((w) =>
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
) ||
data?.pendingContract.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
data?.pendingContract.bookings.some(
(b) => b.allocationStatus === "ASSIGNED",
),
),
[data],
);
@@ -514,7 +541,9 @@ export default function BatchScheduleDetailPage() {
group.hasIssues =
group.hasIssues ||
w.bookings.some(
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
(b) =>
b.allocationStatus === "FAILED" ||
b.allocationStatus === "DEFERRED",
);
}
return [...byDate.values()];
@@ -522,7 +551,10 @@ export default function BatchScheduleDetailPage() {
// Windows with bookings open by default (inside an expanded day).
const openWindowKeys = useMemo(
() => (data ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) : []),
() =>
data
? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key)
: [],
[data],
);
@@ -541,7 +573,9 @@ export default function BatchScheduleDetailPage() {
// day with bookings, else the first day. Keep the selection if still valid.
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
null,
);
useEffect(() => {
if (!dayGroups.length) return;
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
@@ -562,7 +596,9 @@ export default function BatchScheduleDetailPage() {
runAllocation
.mutateAsync({ scheduleId: scheduleId ?? "" })
.then((result) => {
const failed = result.issues.filter((i) => i.status === "FAILED").length;
const failed = result.issues.filter(
(i) => i.status === "FAILED",
).length;
const deferred = result.deferred.length;
toast({
title: "Allocation run complete",
@@ -589,15 +625,6 @@ export default function BatchScheduleDetailPage() {
);
}
const lengthPct =
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
: null;
const weightPct =
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
: null;
const totalBookings = totalBookingCount(data.counts);
return (
@@ -614,36 +641,49 @@ export default function BatchScheduleDetailPage() {
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="composition">
Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`}
Train Composition{" "}
{scheduleDetailQuery.data?.trainSet?.wagons &&
scheduleDetailQuery.data.trainSet.wagons.length > 0 &&
`(${scheduleDetailQuery.data.trainSet.wagons.length})`}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={9} style={{ minWidth: 0 }}>
<Group gap="sm" wrap="wrap" align="center">
<Button
variant="subtle"
color="gray"
radius="md"
px="sm"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/operations/batch-board")}
>
Back
</Button>
<Title order={2} fw={800}>
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
<WindowStatusPill status={data.bookingWindowStatus} />
<HeroChip>{data.status}</HeroChip>
</Group>
<RouteCorridor origin={data.origin} destination={data.destination} />
<Group gap={6} wrap="wrap">
<HeroChip icon={<CalendarDays size={12} />}>
{data.scheduleDate
? new Intl.DateTimeFormat("en-GB", {
<Group
justify="space-between"
align="flex-start"
wrap="wrap"
gap="md"
>
<Stack gap={9} style={{ minWidth: 0 }}>
<Group gap="sm" wrap="wrap" align="center">
<Button
variant="subtle"
color="gray"
radius="md"
px="sm"
leftSection={<ArrowLeft size={16} />}
onClick={() =>
navigate("/dashboard/operations/batch-board")
}
>
Back
</Button>
<Title order={2} fw={800}>
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
<WindowStatusPill status={data.bookingWindowStatus} />
<HeroChip>{data.status}</HeroChip>
</Group>
<RouteCorridor
origin={data.origin}
destination={data.destination}
/>
<Group gap={6} wrap="wrap">
<HeroChip icon={<CalendarDays size={12} />}>
{data.scheduleDate
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
@@ -653,317 +693,360 @@ export default function BatchScheduleDetailPage() {
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
</HeroChip>
{data.locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
{data.locomotive.maxTrainLengthMeters} m
: "No date"}
</HeroChip>
) : null}
</Group>
</Stack>
<Group gap="sm">
<Button
variant="default"
radius="md"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PlayCircle size={16} />}
loading={runAllocation.isPending}
onClick={handleRunAllocation}
>
Run allocation
</Button>
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Layers size={16} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
}
>
Open schedule
</Button>
</Group>
</Group>
{!data.locomotive ? (
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
No locomotive assigned wagon allocation cannot run.
</Alert>
) : null}
<KpiStrip
items={[
{
label: "Allocated wagons",
value: data.capacity.allocatedWagons,
hint: "on this train",
icon: Boxes,
},
{
label: "Train length",
value: data.capacity.maxLengthMeters
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
: fmtMeters(data.capacity.allocatedLengthMeters),
icon: Ruler,
},
{
label: "Weight",
value: data.capacity.maxWeightTons
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
: fmtTons(data.capacity.usedWeightTons),
icon: Weight,
},
{
label: "Bookings",
value: totalBookings,
hint: `${data.counts.allocated} allocated · ${data.counts.expired} expired`,
icon: Package,
},
]}
/>
{/* Booking pipeline */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group justify="space-between" mb="sm">
<Group gap={8} wrap="nowrap">
<ThemeIcon size={32} radius="md" variant="light" color="#F2A516">
<Package size={16} />
</ThemeIcon>
<Text fw={700}>Booking pipeline</Text>
</Group>
<Text size="sm" fw={700} c="dark.4">
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
</Text>
</Group>
<BookingPipeline counts={data.counts} size={14} />
</Paper>
{data.allocationViolations.length ? (
<Alert
color="red"
mt="lg"
radius="lg"
icon={<AlertTriangle size={16} />}
title="Allocation constraints"
>
<Stack gap={4}>
{data.allocationViolations.map((v) => (
<Text key={v} size="sm">
{v}
</Text>
))}
</Stack>
</Alert>
) : null}
{/* Batch windows */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
<Clock size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Batch windows (EAT)</Title>
<Text size="sm" c="dimmed">
3-hour windows for every day from when the booking window opened through the
departure date. Bookings appear under the date their contract was signed open a
day to see its windows.
</Text>
</Box>
</Group>
{dayGroups.length && selectedDay ? (
<>
{/* Date stepper — page back/forward through each day in the range */}
<Group justify="center" align="center" wrap="nowrap" gap="md" mt="md">
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Previous day"
disabled={selectedIndex <= 0}
onClick={() => setSelectedDate(dayGroups[selectedIndex - 1]?.date ?? null)}
>
<ChevronLeft size={20} />
</ActionIcon>
<Paper
withBorder
radius="xl"
px="xl"
py="xs"
style={{
flex: 1,
maxWidth: 360,
textAlign: "center",
background: selectedDay.totalBookings ? "#FEF1D5" : "white",
borderColor: selectedDay.totalBookings
? "#FBD171"
: "var(--mantine-color-gray-2)",
}}
>
<Group justify="center" gap={8} wrap="nowrap">
<CalendarDays size={15} color="#B26C09" />
<Text
fw={800}
style={{ color: selectedDay.totalBookings ? "#8A5304" : "#0f172a" }}
>
{selectedDay.dateLabel}
</Text>
{selectedDay.date === todayEat ? (
<Badge size="xs" variant="light" color="#F2A516">
Today
</Badge>
{data.locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
Loco {data.locomotive.code} ·{" "}
{fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
{data.locomotive.maxTrainLengthMeters} m
</HeroChip>
) : null}
</Group>
<Text size="xs" c="dimmed" mt={2}>
{selectedDay.totalBookings
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
: `${selectedDay.windows.length} windows · no bookings`}
</Text>
</Paper>
</Stack>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Next day"
disabled={selectedIndex >= dayGroups.length - 1}
onClick={() => setSelectedDate(dayGroups[selectedIndex + 1]?.date ?? null)}
>
<ChevronRight size={20} />
</ActionIcon>
</Group>
<Group justify="space-between" align="center" mt="sm">
<Text size="xs" c="dimmed">
Day {selectedIndex + 1} of {dayGroups.length}
</Text>
<Group gap={6} wrap="nowrap">
{selectedDay.hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={selectedDay.counts} />
<Group gap="sm">
<Button
variant="default"
radius="md"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PlayCircle size={16} />}
loading={runAllocation.isPending}
onClick={handleRunAllocation}
>
Run allocation
</Button>
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Layers size={16} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`,
)
}
>
Open schedule
</Button>
</Group>
</Group>
<Accordion
key={selectedDay.date}
multiple
defaultValue={openWindowKeys}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
{selectedDay.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
</Accordion>
</>
) : (
<Text size="sm" c="dimmed" ta="center" py="lg">
No batch windows for this schedule.
</Text>
)}
{!data.locomotive ? (
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
No locomotive assigned wagon allocation cannot run.
</Alert>
) : null}
{data.pendingContract.bookings.length ? (
<Accordion
multiple
defaultValue={["pending-contract"]}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
<Accordion.Item value="pending-contract">
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
<Group gap="sm" wrap="nowrap">
<Box
<KpiStrip
items={[
{
label: "Allocated wagons",
value: data.capacity.allocatedWagons,
hint: "on this train",
icon: Boxes,
},
{
label: "Train length",
value: data.capacity.maxLengthMeters
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
: fmtMeters(data.capacity.allocatedLengthMeters),
icon: Ruler,
},
{
label: "Weight",
value: data.capacity.maxWeightTons
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
: fmtTons(data.capacity.usedWeightTons),
icon: Weight,
},
{
label: "Bookings",
value: totalBookings,
hint: `${data.counts.allocated} allocated · ${data.counts.expired} expired`,
icon: Package,
},
]}
/>
{/* Booking pipeline */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group justify="space-between" mb="sm">
<Group gap={8} wrap="nowrap">
<ThemeIcon
size={32}
radius="md"
variant="light"
color="#F2A516"
>
<Package size={16} />
</ThemeIcon>
<Text fw={700}>Booking pipeline</Text>
</Group>
<Text size="sm" fw={700} c="dark.4">
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
</Text>
</Group>
<BookingPipeline counts={data.counts} size={14} />
</Paper>
{data.allocationViolations.length ? (
<Alert
color="red"
mt="lg"
radius="lg"
icon={<AlertTriangle size={16} />}
title="Allocation constraints"
>
<Stack gap={4}>
{data.allocationViolations.map((v) => (
<Text key={v} size="sm">
{v}
</Text>
))}
</Stack>
</Alert>
) : null}
{/* Batch windows */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="#F2A516"
>
<Clock size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Batch windows (EAT)</Title>
<Text size="sm" c="dimmed">
3-hour windows for every day from when the booking window
opened through the departure date. Bookings appear under the
date their contract was signed open a day to see its
windows.
</Text>
</Box>
</Group>
{dayGroups.length && selectedDay ? (
<>
{/* Date stepper — page back/forward through each day in the range */}
<Group
justify="center"
align="center"
wrap="nowrap"
gap="md"
mt="md"
>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Previous day"
disabled={selectedIndex <= 0}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex - 1]?.date ?? null,
)
}
>
<ChevronLeft size={20} />
</ActionIcon>
<Paper
withBorder
radius="xl"
px="xl"
py="xs"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-6)",
flex: 1,
maxWidth: 360,
textAlign: "center",
background: selectedDay.totalBookings
? "#FEF1D5"
: "white",
borderColor: selectedDay.totalBookings
? "#FBD171"
: "var(--mantine-color-gray-2)",
}}
>
<FileSignature size={16} />
</Box>
<Box>
<Text fw={700} size="sm">
Pending contract
<Group justify="center" gap={8} wrap="nowrap">
<CalendarDays size={15} color="#B26C09" />
<Text
fw={800}
style={{
color: selectedDay.totalBookings
? "#8A5304"
: "#0f172a",
}}
>
{selectedDay.dateLabel}
</Text>
{selectedDay.date === todayEat ? (
<Badge size="xs" variant="light" color="#F2A516">
Today
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" mt={2}>
{selectedDay.totalBookings
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
: `${selectedDay.windows.length} windows · no bookings`}
</Text>
<Text size="xs" c="dimmed">
Contract not signed yet not in any window
</Text>
</Box>
</Group>
<Badge variant="outline" color="gray" size="sm">
{data.pendingContract.bookings.length} booking
{data.pendingContract.bookings.length === 1 ? "" : "s"}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={data.pendingContract.bookings} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
</Paper>
</Paper>
{/* Train composition diagram */}
{hasAssignedWagons && scheduleDetailQuery.data ? (
<Box mt="lg">
<TrainCompositionDiagram
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
freightType="CONTAINER"
trainNumber={scheduleDetailQuery.data.trainNumber}
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
/>
</Box>
) : null}
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Next day"
disabled={selectedIndex >= dayGroups.length - 1}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex + 1]?.date ?? null,
)
}
>
<ChevronRight size={20} />
</ActionIcon>
</Group>
<Group justify="space-between" align="center" mt="sm">
<Text size="xs" c="dimmed">
Day {selectedIndex + 1} of {dayGroups.length}
</Text>
<Group gap={6} wrap="nowrap">
{selectedDay.hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={selectedDay.counts} />
</Group>
</Group>
<Accordion
key={selectedDay.date}
multiple
defaultValue={openWindowKeys}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
{selectedDay.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
</Accordion>
</>
) : (
<Text size="sm" c="dimmed" ta="center" py="lg">
No batch windows for this schedule.
</Text>
)}
{data.pendingContract.bookings.length ? (
<Accordion
multiple
defaultValue={["pending-contract"]}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
<Accordion.Item value="pending-contract">
<Accordion.Control>
<Group
justify="space-between"
wrap="nowrap"
pr="md"
gap="sm"
>
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-6)",
}}
>
<FileSignature size={16} />
</Box>
<Box>
<Text fw={700} size="sm">
Pending contract
</Text>
<Text size="xs" c="dimmed">
Contract not signed yet not in any window
</Text>
</Box>
</Group>
<Badge variant="outline" color="gray" size="sm">
{data.pendingContract.bookings.length} booking
{data.pendingContract.bookings.length === 1
? ""
: "s"}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={data.pendingContract.bookings} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
</Paper>
{/* Train composition diagram */}
{hasAssignedWagons && scheduleDetailQuery.data ? (
<Box mt="lg">
<TrainCompositionDiagram
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
freightType="CONTAINER"
trainNumber={scheduleDetailQuery.data.trainNumber}
totalLengthMeters={
scheduleDetailQuery.data.trainSet?.totalLengthMeters
}
/>
</Box>
) : null}
</Stack>
</Tabs.Panel>

View File

@@ -67,7 +67,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,
@@ -79,7 +83,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>(
@@ -104,7 +108,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) };
@@ -161,7 +168,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));
},
@@ -181,24 +191,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

@@ -146,14 +146,12 @@ export interface TrainScheduleListItem {
origin: string | null;
destination: string | null;
freightType?: FreightType | null;
locomotive:
| {
id: string;
code: string;
name?: string | null;
currentYardId?: string | null;
}
| null;
locomotive: {
id: string;
code: string;
name?: string | null;
currentYardId?: string | null;
} | null;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
@@ -316,7 +314,6 @@ export interface TrainScheduleWagonAllocation {
export interface TrainScheduleDetail {
id: string;
status: TrainScheduleStatus | string;
warnings?: string[];
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;

View File

@@ -5,7 +5,8 @@
"useDefineForClassFields": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@tria-plc/iamui": ["./src/types/@tria-plc__iamui.d.ts"]
}
},
"include": ["src"]