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

This commit is contained in:
marshal
2026-06-24 01:22:31 +03:00
53 changed files with 1797 additions and 2638 deletions

View File

@@ -285,6 +285,7 @@ export class CompaniesService {
async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
return company;
}

View File

@@ -2152,6 +2152,7 @@ export class TrainSchedulingService {
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
})) ?? [],
};
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -662,7 +662,7 @@ export default function NewBookingPage() {
placeholder="Select bulk cargo type"
data={cargoData}
value={cargoTypeId}
onChange={setCargoTypeId}
onChange={(value) => setCargoTypeId(value as string | null)}
searchable
disabled={isLoading}
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,49 +1,49 @@
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { api } from '@/services/api';
import {
ActionIcon,
Badge as MantineBadge,
Box,
Button as MantineButton,
Group,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
Select as MantineSelect,
SimpleGrid,
Stack,
Table as MantineTable,
Text,
TextInput,
Title,
ActionIcon,
Box,
Group,
Badge as MantineBadge,
Button as MantineButton,
Select as MantineSelect,
Table as MantineTable,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useToast } from '@/hooks/use-toast';
import type { Cargo } from '@/services/cargoService';
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
import type { Container } from '@/services/containerService';
import type { Locomotive } from '@/services/locomotives.service';
import type { Train } from '@/services/trains.service';
import type { Wagon } from '@/services/wagon.service';
import type { WagonType } from '@/services/wagon-types.service';
import type { Wagon } from '@/services/wagon.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
type FormValue = string | number | boolean | string[];
@@ -331,7 +331,7 @@ function FleetCrudPage<T extends { id: string }>({
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={String(column.key)}>
{column.render ? column.render(item) : String((item as Record<string, unknown>)[column.key] ?? '-')}
{column.render ? column.render(item) : String((item as Record<string, unknown>)[String(column.key)] ?? '-')}
</TableCell>
))}
<TableCell>
@@ -410,7 +410,7 @@ function FleetCrudPage<T extends { id: string }>({
...current,
[field.key]: selectedValue,
...(field.onValueChange?.(selectedValue, current) ?? {}),
}))
}) as Record<string, FormValue>)
}
>
<SelectTrigger id={field.key}>
@@ -480,12 +480,6 @@ function FleetCrudPage<T extends { id: string }>({
const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'}</Badge>;
const activeBadge = (isActive?: boolean) => (
<Badge variant={isActive === false ? 'secondary' : 'outline'}>
{isActive === false ? 'Inactive' : 'Active'}
</Badge>
);
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
options.find((option) => option.value === value)?.label ?? value ?? '-';

View File

@@ -1,19 +1,10 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import {
Archive,
Circle,
CircleCheck,
CircleSlash,
Layers,
Link2,
Plus,
Wrench,
type LucideIcon,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
@@ -23,37 +14,20 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast";
import {
FLEET_SELECT_NONE,
getFleetResource,
getFleetSlugFromPath,
type FleetFormFieldDef,
type FleetResourceSlug,
FLEET_SELECT_NONE,
getFleetResource,
getFleetSlugFromPath,
type FleetFormFieldDef,
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
const FLEET_STATUS_META: Record<
string,
{ label: string; icon: LucideIcon; color: string }
> = {
AVAILABLE: { label: "Available", icon: CircleCheck, color: "edr-green" },
ASSIGNED: { label: "Assigned", icon: Link2, color: "blue" },
MAINTENANCE: { label: "Maintenance", icon: Wrench, color: "yellow" },
OUT_OF_SERVICE: { label: "Out of service", icon: CircleSlash, color: "red" },
RETIRED: { label: "Retired", icon: Archive, color: "gray" },
};
const humanizeStatus = (status: string) => {
const text = status.replace(/_/g, " ").toLowerCase();
return text.charAt(0).toUpperCase() + text.slice(1);
};
const FleetResourcePage = () => {
const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
@@ -113,6 +87,9 @@ const FleetResourcePage = () => {
const { data: yards = [], isLoading: yardsLoading } = useQuery(
api.routes.yards.queryOptions(),
);
const { data: drivers = [] } = useQuery(
api.fleet.list.queryOptions({ input: { slug: "drivers" } }),
);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
@@ -301,39 +278,6 @@ const FleetResourcePage = () => {
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
const kpiItems = useMemo(() => {
const items = [
{
label: `Total ${config?.label.toLowerCase() ?? ""}`,
value: allRows.length,
icon: Layers,
color: "edr-green",
},
];
if (hasStatusColumn) {
const counts = new Map<string, number>();
for (const row of allRows) {
const status = String(
(row as unknown as Record<string, unknown>).status ?? "",
);
if (status) counts.set(status, (counts.get(status) ?? 0) + 1);
}
const top = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
for (const [status, count] of top) {
const meta = FLEET_STATUS_META[status];
items.push({
label: meta?.label ?? humanizeStatus(status),
value: count,
icon: meta?.icon ?? Circle,
color: meta?.color ?? "gray",
});
}
}
return items.slice(0, 5);
}, [allRows, hasStatusColumn, config?.label]);
if (!config) {
return <Navigate to="/dashboard/locomotives" replace />;
}
@@ -376,7 +320,7 @@ const FleetResourcePage = () => {
const handleAssignDriver = async () => {
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
try {
const selectedDriverRecord = (drivers as Array<Record<string, unknown>>).find(
const selectedDriverRecord = (drivers as unknown as Array<Record<string, unknown>>).find(
(d) => String(d.id) === selectedDriver
);
if (!selectedDriverRecord) return;
@@ -384,6 +328,7 @@ const FleetResourcePage = () => {
const driverName = `${selectedDriverRecord.firstName} ${selectedDriverRecord.lastName}`;
await update.mutateAsync({
slug,
id: String(assigningDriver.id),
data: {
assignedDriverId: selectedDriver,
@@ -608,7 +553,7 @@ const FleetResourcePage = () => {
clearable
value={selectedDriver}
onChange={(value) => setSelectedDriver(value || "")}
data={(drivers as Array<Record<string, unknown>>).map((driver) => ({
data={(drivers as unknown as Array<Record<string, unknown>>).map((driver) => ({
value: String(driver.id || ""),
label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`,
}))}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
@@ -378,6 +375,7 @@ export interface TrainScheduleDetail {
weightTons: number;
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
}>;
warnings?: string[];
}

View File

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

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"]

View File

@@ -1,5 +1,4 @@
import { Input } from "@mantine/core";
import { forwardRef } from "react";
import { Input, TextInput } from "@mantine/core";
import {
Controller,
type Control,
@@ -32,17 +31,6 @@ export const toEthiopianE164 = (raw?: string | null): string => {
return `+251${digits}`;
};
/**
* The text input rendered inside react-phone-number-input, styled to match the
* portal's Mantine fields (44px height, 10px radius, edr border). Must forward
* the ref and accept native input props for the library to drive it.
*/
const StyledInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
function StyledInput(props, ref) {
return <input {...props} ref={ref} className="edr-phone-input" />;
},
);
export interface PhoneFieldProps {
label?: string;
value?: string;
@@ -75,10 +63,10 @@ export function PhoneField({
required={required}
error={error}
styles={{
label: { fontWeight: 600, fontSize: 13, color: "#10202F", marginBottom: 6 },
label: { fontWeight: 600, fontSize: 14, color: "#10202F", },
}}
>
<div className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}>
<div className={`edr-phone-wrapper ${error ? " edr-phone-wrapper--error" : ""}`}>
<RPNInput
international
defaultCountry="ET"
@@ -86,10 +74,9 @@ export function PhoneField({
addInternationalOption
value={value}
onChange={onChange}
onBlur={onBlur}
inputComponent={TextInput}
disabled={disabled}
placeholder={placeholder}
inputComponent={StyledInput}
/>
</div>
</Input.Wrapper>

View File

@@ -1,8 +1,33 @@
import { Modal, ScrollArea, Stack, Text } from "@mantine/core";
import {
Box,
Button,
Group,
Modal,
ScrollArea,
Stack,
Text,
Title,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
Building2,
CheckCircle2,
FileText,
Globe2,
UploadCloud,
User,
UserCheck,
} from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
import useAuth from "@/hooks/useAuth";
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
import NationalitySelect from "@/pages/settings/NationalitySelect";
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
import { api } from "@/services/api";
import type {
CompanyNationality,
@@ -12,12 +37,8 @@ import type {
import { companiesService } from "@/services/companies.service";
import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result";
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
import NationalitySelect from "@/pages/settings/NationalitySelect";
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
/** Form steps shared by CompanyProfileForm and ForwarderForm. */
/** Form steps rendered by CompanyProfileForm. */
type FormStep =
| "company"
| "personnel"
@@ -34,6 +55,58 @@ const FORM_STEPS: FormStep[] = [
"additional",
];
/** The full onboarding journey: the two pre-form phases + the form steps. */
type WizardStep = "nationality" | "role" | FormStep;
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS];
/** Icon + title + description shown in the global dialog header per step. */
const STEP_META: Record<
WizardStep,
{ icon: ReactNode; title: string; description: string }
> = {
nationality: {
icon: <Globe2 size={20} />,
title: "Where is your company registered?",
description: "This determines the documents we'll ask you to provide.",
},
role: {
icon: <Building2 size={20} />,
title: "What does your company do?",
description:
"Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.",
},
company: {
icon: <Building2 size={20} />,
title: "Company Information",
description: "Tell us about your company and its registration details.",
},
personnel: {
icon: <User size={20} />,
title: "General Manager",
description: "Who is the general manager of the company?",
},
contact: {
icon: <UserCheck size={20} />,
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",
description: "Optionally add a representative with power of attorney.",
},
documents: {
icon: <UploadCloud size={20} />,
title: "Upload Documents",
description: "Provide the required company documents.",
},
additional: {
icon: <CheckCircle2 size={20} />,
title: "Business License",
description: "Upload a business license for each operational profile.",
},
};
interface OnboardingWizardDialogProps {
opened: boolean;
/** Dismiss the dialog (user clicked the close icon). */
@@ -98,6 +171,9 @@ export default function OnboardingWizardDialog({
// Newly-selected business-license files per company_profile id.
const [licenseFiles, setLicenseFiles] = useState<Record<string, File[]>>({});
const [startError, setStartError] = useState<string | null>(null);
// Mirror of CompanyProfileForm's active step so the global header + progress
// pill can reflect it (the form no longer renders its own stepper).
const [formStep, setFormStep] = useState<FormStep>(resumeFormStep);
// Saved profile data, for rehydrating the form fields after a refresh.
const profileQuery = useQuery(
@@ -164,6 +240,15 @@ export default function OnboardingWizardDialog({
api.companies.setOnboardingStep.call({ step }).catch(() => {});
}, []);
// Mirror the form's step locally (for the header/pill) and persist it.
const handleStepChange = useCallback(
(step: string) => {
setFormStep(step as FormStep);
persistStep(step);
},
[persistStep],
);
// The company query may resolve AFTER this dialog mounts (it's kept mounted by
// the gate), so the phase/roles/nationality initial state can be stale — a
// draft that already exists would otherwise leave us stuck on the first
@@ -239,12 +324,10 @@ export default function OnboardingWizardDialog({
existingFiles: p.licenseFiles ?? [],
}));
const titleHint =
phase === "nationality"
? "Where is your company registered?"
: phase === "role"
? "Tell us what your company does to get started."
: "Set up your company profile to finish.";
// The active step across the whole journey, driving the header + progress pill.
const activeStep: WizardStep = phase === "form" ? formStep : phase;
const stepMeta = STEP_META[activeStep];
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
const formProps = {
documentSettingCode: documentSettingCode(effectiveNationality),
@@ -257,7 +340,7 @@ export default function OnboardingWizardDialog({
hideFirstStepBack: true,
initialStep: resumeFormStep,
resyncOpen: opened,
onStepChange: persistStep,
onStepChange: handleStepChange,
onSaveStep: saveStep,
rehydrate: profileQuery.data ?? null,
roleProfiles,
@@ -279,63 +362,98 @@ export default function OnboardingWizardDialog({
keepMounted
scrollAreaComponent={ScrollArea.Autosize}
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
styles={{
header: {
alignItems:"flex-start"
},
title: {
flex: 1
}
}}
title={
<Stack gap={2}>
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
Complete your onboarding
</Text>
<Text size="sm" c="edr-muted">
{titleHint}
</Text>
<Stack gap="md">
<Box>
<Group gap="sm" mb={4}>
{stepMeta.icon}
<Title order={3}>{stepMeta.title}</Title>
</Group>
<Text c="edr-muted" size="sm">
{stepMeta.description}
</Text>
</Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
</Stack>
}
>
{phase === "nationality" ? (
<Stack gap="lg">
<NationalitySelect value={nationality} onChange={setNationality} />
<RoleContinueBar
disabled={!nationality}
onClick={handleNationalityContinue}
/>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect value={roles} onChange={setRoles} />
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<RoleContinueBar
disabled={!rolesValid}
loading={startMutation.isPending}
onClick={handleRolesContinue}
/>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
<Stack gap="xl">
{phase === "nationality" ? (
<Stack gap="lg">
<NationalitySelect
value={nationality}
onChange={setNationality}
embedded
/>
<Group justify="flex-end" pt="xs">
<Button
color="edr-green"
onClick={handleNationalityContinue}
disabled={!nationality}
rightSection={<ArrowRight size={16} />}
>
Continue
</Button>
</Group>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<Group justify="space-between" pt="xs">
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => setPhase("nationality")}
>
Back
</Button>
<Button
color="edr-green"
onClick={handleRolesContinue}
disabled={!rolesValid}
loading={startMutation.isPending}
rightSection={
startMutation.isPending ? undefined : <ArrowRight size={16} />
}
>
Continue
</Button>
</Group>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
</Stack>
</Modal>
);
}
function RoleContinueBar({
disabled,
loading,
onClick,
}: {
disabled: boolean;
loading?: boolean;
onClick: () => void;
}) {
/**
* Continuous progress pill: a single rounded track that fills left-to-right as
* the user advances, with faint ticks marking each step boundary.
*/
function ProgressPill({ current, total }: { current: number; total: number }) {
const pct = total > 0 ? ((current + 1) / total) * 100 : 0;
return (
<button
type="button"
disabled={disabled || loading}
onClick={onClick}
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Setting up…" : "Continue"}
</button>
<Box className="relative h-1.5 w-full overflow-hidden rounded-full bg-edr-border">
<Box
className="absolute inset-y-0 left-0 rounded-full bg-[var(--mantine-color-edr-green-6)] transition-[width] duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</Box>
);
}

View File

@@ -11,9 +11,9 @@
.edr-phone-wrapper .PhoneInputCountry {
margin: 0;
padding: 0 10px;
height: 44px;
border: 1px solid #e6ecf2;
border-radius: 10px;
height: 2.25rem;
border: 0.0625rem solid #b0bfce;
border-radius: 6px;
background: #fff;
display: flex;
align-items: center;

View File

@@ -1,6 +1,5 @@
import {
Alert,
Box,
Button,
Checkbox,
Divider,
@@ -10,7 +9,6 @@ import {
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -18,12 +16,6 @@ import {
AlertCircle,
ArrowLeft,
ArrowRight,
Building2,
CheckCircle2,
ChevronLeft,
FileText,
UploadCloud,
User,
UserCheck,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
@@ -559,7 +551,6 @@ export default function CompanyProfileForm({
"documents",
"additional",
];
const totalSteps = stepOrder.length;
const currentIdx = stepOrder.indexOf(step);
/** Validate + persist the current step, returning whether we may advance. */
@@ -617,79 +608,8 @@ export default function CompanyProfileForm({
// selection); otherwise always available.
const showBack = !(hideFirstStepBack && step === "company");
const STEP_ICONS: Record<CompanyStep, React.ReactNode> = {
company: <Building2 size={18} />,
personnel: <User size={18} />,
contact: <UserCheck size={18} />,
poa: <FileText size={18} />,
documents: <UploadCloud size={18} />,
additional: <CheckCircle2 size={18} />,
};
const STEP_TITLES: Record<CompanyStep, string> = {
company: "Company Information",
personnel: "General Manager",
contact: "Contact Person",
poa: "Power of Attorney (Optional)",
documents: "Upload Documents",
additional: "Business License",
};
const stepLabel = `Step ${currentIdx + 1} of ${totalSteps}${STEP_TITLES[step]}`;
return (
<>
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
>
Change account type
</Button>
<Group
justify="space-between"
align="center"
className="relative max-w-lg mx-auto px-2"
>
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{stepOrder.map((key, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon
key={key}
size={40}
radius="xl"
variant="filled"
color="edr-green"
className="relative z-10"
>
{done ? <CheckCircle2 size={18} /> : STEP_ICONS[key]}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{STEP_ICONS[key]}
</Box>
);
})}
</Group>
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{stepLabel}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (

View File

@@ -1,580 +0,0 @@
import { Alert, Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Building2,
CheckCircle2,
ChevronLeft,
FileText,
UploadCloud,
User,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api";
import RoleLicenseStep, {
type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep";
type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional";
const forwarderSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z
.string()
.min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
});
type FormData = z.infer<typeof forwarderSchema>;
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"],
poa: [],
documents: [],
additional: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
},
};
}
/** Map one wizard step's form values to the profile-update payload it saves. */
function stepPayload(step: ForwarderStep, d: FormData): Partial<UpdateProfilePayload> {
switch (step) {
case "company":
return {
companyName: d.companyName,
companyEmail: d.companyEmail,
companyPhone: d.companyPhone,
companyLocation: d.companyLocation,
companyAddress: d.companyAddress,
tin: d.tinNumber,
vatNumber: d.vatNumber,
fanNumber: d.fanNumber,
};
case "personnel":
return {
contactPersonName: d.contactPersonName,
contactPersonPhone: d.contactPersonPhone,
generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: d.generalManagerPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
default:
return {};
}
}
/** Seed the form from previously-saved profile data. */
function toFormValues(p: ProfileResponse): FormData {
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
return {
companyName: p.companyName ?? "",
companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "",
companyLocation: p.companyLocation ?? "",
companyAddress: p.companyAddress ?? "",
tinNumber: tin,
vatNumber: p.vatNumber ?? "",
fanNumber: p.fanNumber ?? "",
contactPersonName: p.contactPersonName ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
poaName: p.poaName ?? "",
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "",
};
}
export default function ForwarderForm({
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
initialStep,
resyncOpen,
hideFirstStepBack,
onStepChange,
onSaveStep,
rehydrate,
roleProfiles,
licenseFiles,
onLicenseChange,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
/** Step to resume at (defaults to "company"). */
initialStep?: ForwarderStep;
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
resyncOpen?: boolean;
/** Hide the Back button on the first step (onboarding can't go back to role pick). */
hideFirstStepBack?: boolean;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: ForwarderStep) => void;
/** Persist the current step's data before advancing; returns an error to show. */
onSaveStep?: (
data: Partial<UpdateProfilePayload>,
) => Promise<{ ok: true } | { ok: false; error: string }>;
/** Saved profile to seed the form with (rehydration after refresh). */
rehydrate?: ProfileResponse | null;
/** Operational profiles for the final per-role license step. */
roleProfiles?: RoleLicenseProfile[];
/** Newly-selected license files per profile id. */
licenseFiles?: Record<string, File[]>;
onLicenseChange?: (value: Record<string, File[]>) => void;
}) {
const [step, setStep] = useState<ForwarderStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Report each step change up so the wizard can persist it for resume.
useEffect(() => {
onStepChange?.(step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
// On reopen, jump to the furthest step reached so progress never resets.
const wasOpen = useRef(resyncOpen);
useEffect(() => {
if (resyncOpen && !wasOpen.current && initialStep) {
setStep(initialStep);
setSaveError(null);
}
wasOpen.current = resyncOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resyncOpen]);
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
);
const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(forwarderSchema),
defaultValues: {
companyName: "", companyEmail: "", companyPhone: "",
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
contactPersonName: "", contactPersonPhone: "",
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "",
poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "",
},
// Rehydrate from previously-saved data (RHF re-syncs when `values` change).
values: rehydrate ? toFormValues(rehydrate) : undefined,
});
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
/** Validate + persist the current step, returning whether we may advance. */
const saveCurrentStep = async (): Promise<boolean> => {
setSaveError(null);
const isValid = await trigger(stepFields[step]);
if (!isValid) return false;
if (!onSaveStep) return true;
setSaving(true);
try {
const res = await onSaveStep(stepPayload(step, watch()));
if (!res.ok) {
setSaveError(res.error);
return false;
}
return true;
} finally {
setSaving(false);
}
};
// Every role needs at least one license file (existing or newly selected).
const licenseComplete = (roleProfiles ?? []).every(
(p) =>
(licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0,
);
const nextStep = async () => {
if (step === "additional") {
if (!licenseComplete) {
setSaveError(
"Please upload a business license for each of your operational profiles.",
);
return;
}
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
if (step === "documents") { setStep("additional"); return; }
const ok = await saveCurrentStep();
if (!ok) return;
setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents");
};
const skipDocuments = () => setStep("additional");
const prevStep = () => {
setSaveError(null);
if (step === "company") onBack();
else if (step === "personnel") setStep("company");
else if (step === "poa") setStep("personnel");
else if (step === "documents") setStep("poa");
else setStep("documents");
};
const showBack = !(hideFirstStepBack && step === "company");
const STEPS: { key: ForwarderStep; icon: React.ReactNode }[] = [
{ key: "company", icon: <Building2 size={18} /> },
{ key: "personnel", icon: <User size={18} /> },
{ key: "poa", icon: <FileText size={18} /> },
{ key: "documents", icon: <UploadCloud size={18} /> },
{ key: "additional", icon: <CheckCircle2 size={18} /> },
];
const STEP_LABELS: Record<ForwarderStep, string> = {
company: `Step 1 of ${totalSteps} — Company Information`,
personnel: `Step 2 of ${totalSteps} — Personnel Details`,
poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`,
documents: `Step 4 of ${totalSteps} — Upload Documents`,
additional: `Step 5 of ${totalSteps} — Business License`,
};
const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "additional"];
const currentIdx = stepOrder.indexOf(step);
return (
<>
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
>
Change account type
</Button>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{icon}
</Box>
);
})}
</Group>
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{STEP_LABELS[step]}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<>
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
<TextInput
label="VAT Number"
placeholder="VAT-12345"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</SimpleGrid>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</>
)}
{step === "personnel" && (
<>
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone"
required
/>
</SimpleGrid>
<Divider color="edr-border" />
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
{step === "poa" && (
<>
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
</Text>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
)}
</>
)}
{step === "additional" && (
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => {})}
/>
)}
{saveError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
title={step === "additional" ? "Business license required" : "Couldn't save this step"}
>
{saveError}
</Alert>
)}
<Group justify="space-between" pt="xs">
{showBack ? (
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "additional" ? "Back to Documents" : "Back"}
</Button>
) : (
<span />
)}
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending || saving}>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={nextStep}
disabled={isPending || saving || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending || saving}
rightSection={!isPending && !saving && step !== "additional" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{step === "documents" ? "Continue" : step === "additional" ? "Finish onboarding" : "Save & Continue"}
</Button>
</Group>
</Group>
</Stack>
</form>
</>
);
}

View File

@@ -1,300 +0,0 @@
import {
Box,
Group,
SimpleGrid,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowDownToLine,
ArrowUpFromLine,
Building2,
ChevronRight,
} from "lucide-react";
import { useState } from "react";
import AuthLayout from "@/components/auth/AuthLayout";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type { CreateCompanyPayload } from "@/services/companies.service";
import { companiesService } from "@/services/companies.service";
import CompanyProfileForm from "./CompanyProfileForm";
import DjiboutiAgentForm from "./DjiboutiAgentForm";
import ForwarderForm from "./ForwarderForm";
import TransporterForm from "./TransporterForm";
import type { OnboardingUserType } from "./types";
const USER_TYPE_CARDS: {
id: OnboardingUserType;
label: string;
description: string;
icon: React.ReactNode;
}[] = [
{
id: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
},
{
id: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 size={22} />,
},
// {
// id: "freight-forwarder-dj",
// label: "FF Agent (Djibouti)",
// description: "Djibouti-based agent coordinating cross-border logistics.",
// icon: <Ship size={22} />,
// },
// {
// id: "transporter",
// label: "Transporter",
// description: "Trucking company providing first/last-mile services.",
// icon: <Truck size={22} />,
// },
];
const USER_TYPE_LEFT_MAP: Record<
OnboardingUserType,
{ badge: string; title: string; description: string }
> = {
importer: {
badge: "Importer Registration",
title: "Register as an Importer",
description:
"Set up your company profile to manage imports, track shipments, and streamline customs clearance across the Ethiopia-Djibouti corridor.",
},
exporter: {
badge: "Exporter Registration",
title: "Register as an Exporter",
description:
"Set up your company profile to manage exports, coordinate outbound logistics, and access rail transport services.",
},
"freight-forwarder-et": {
badge: "Freight Forwarder Registration (Ethiopia)",
title: "Register Your Forwarding Company",
description:
"Complete your company profile and Power of Attorney to handle cargo on behalf of importers and exporters.",
},
"freight-forwarder-dj": {
badge: "FF Agent Registration (Djibouti)",
title: "Register as a Djibouti Agent",
description:
"Register your company details and representative information to coordinate cross-border freight operations.",
},
transporter: {
badge: "Transporter Registration",
title: "Register Your Transport Services",
description:
"Provide your vehicle and fleet details to offer first-mile and last-mile trucking services integrated with rail.",
},
};
const PREFLIGHT_LEFT = {
badge: "Get Started",
title: "Choose your account type",
description:
"Select the profile that best matches your role in the logistics chain. Each account type provides a tailored onboarding experience.",
features: [
"Importers & Exporters",
"Freight Forwarders (Ethiopia & Djibouti)",
"Transporters & Fleet Operators",
],
stats: {
label: "Active Customers",
value: "500+",
footer: "And growing",
progress: "w-[95%]",
},
};
const DOCUMENT_SETTING_CODE_MAP: Record<OnboardingUserType, string> = {
importer: "company_onboarding_documents_customer",
exporter: "company_onboarding_documents_customer",
"freight-forwarder-et": "company_onboarding_documents_forwarder",
"freight-forwarder-dj": "company_onboarding_documents_forwarder_dj",
transporter: "company_onboarding_documents_transporter",
};
export default function OnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
importer: "customer",
exporter: "customer",
"freight-forwarder-et": "forwarder",
"freight-forwarder-dj": "forwarder",
transporter: "transporter",
};
const createCompanyMutation = useMutation({
mutationFn: (payload: CreateCompanyPayload) =>
api.companies.create.call(payload),
onSuccess: async (data) => {
const hasFiles = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
);
if (hasFiles) {
await companiesService.uploadDocuments(data.company.id, documentFiles);
}
await queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
if (!user) return null;
const handleSubmit = (payload: CreateCompanyPayload) => {
const enriched: CreateCompanyPayload = {
...payload,
companyType: COMPANY_TYPE_MAP[userType!],
};
createCompanyMutation.mutate(enriched);
};
const handleSelectType = (type: OnboardingUserType) => setUserType(type);
const handleBack = () => setUserType(null);
if (!userType) {
return (
<AuthLayout left={PREFLIGHT_LEFT}>
<Stack gap="lg">
<Box>
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
Select Account Type
</Text>
<Text size="sm" c="edr-muted" mt={4}>
Choose the account type that fits your role.
</Text>
</Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{USER_TYPE_CARDS.map((card) => (
<UnstyledButton
key={card.id}
onClick={() => handleSelectType(card.id)}
className="group block rounded-lg shadow-lg! border! border-edr-border! bg-edr-card! p-5! text-left transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft hover:shadow-[0_12px_28px_-14px_rgba(14,163,83,0.55)]"
>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant="light"
color="edr-green"
className="shrink-0 transition-colors group-hover:!bg-[var(--mantine-color-edr-green-6)] group-hover:!text-white"
>
{card.icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{card.label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{card.description}
</Text>
</Box>
<ChevronRight
size={18}
className="shrink-0 text-[var(--mantine-color-edr-muted-6)] transition-all group-hover:translate-x-0.5 group-hover:text-[var(--mantine-color-edr-green-6)]"
/>
</Group>
</UnstyledButton>
))}
</SimpleGrid>
</Stack>
</AuthLayout>
);
}
const leftConfig = USER_TYPE_LEFT_MAP[userType];
const leftProps = {
...leftConfig,
features:
userType === "transporter"
? [
"Vehicle & fleet registration",
"TIN & FAN verification",
"First-mile / Last-mile eligibility",
]
: userType === "freight-forwarder-dj"
? [
"Company details",
"Representative information",
"Cross-border operations",
]
: [
"Company registration details",
"Contact and management personnel",
"Power of Attorney (optional)",
],
stats: {
label: "Active Customers",
value: "500+",
footer: "And growing",
progress: "w-[95%]",
},
};
return (
<AuthLayout left={leftProps}>
{userType === "transporter" ? (
<TransporterForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : userType === "freight-forwarder-dj" ? (
<DjiboutiAgentForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : userType === "freight-forwarder-et" ? (
<ForwarderForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : (
<CompanyProfileForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
)}
</AuthLayout>
);
}

View File

@@ -7,6 +7,8 @@ import RoleCard from "./RoleCard";
interface NationalitySelectProps {
value: CompanyNationality | null;
onChange: (next: CompanyNationality) => void;
/** Render only the option grid — the wizard supplies its own header/card. */
embedded?: boolean;
}
/**
@@ -18,7 +20,29 @@ interface NationalitySelectProps {
export default function NationalitySelect({
value,
onChange,
embedded = false,
}: NationalitySelectProps) {
const grid = (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Ethiopian Company"
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
icon={<MapPin size={22} />}
selected={value === "ethiopian"}
onClick={() => onChange("ethiopian")}
/>
<RoleCard
label="Foreign Company"
description="Registered abroad. You'll provide a passport and investment license."
icon={<Globe2 size={22} />}
selected={value === "foreign"}
onClick={() => onChange("foreign")}
/>
</SimpleGrid>
);
if (embedded) return grid;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
@@ -28,23 +52,7 @@ export default function NationalitySelect({
<Text c="edr-muted" size="sm" mb="lg">
This determines the documents we'll ask you to provide.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Ethiopian Company"
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
icon={<MapPin size={22} />}
selected={value === "ethiopian"}
onClick={() => onChange("ethiopian")}
/>
<RoleCard
label="Foreign Company"
description="Registered abroad. You'll provide a passport and investment license."
icon={<Globe2 size={22} />}
selected={value === "foreign"}
onClick={() => onChange("foreign")}
/>
</SimpleGrid>
{grid}
</Card>
);
}

View File

@@ -7,6 +7,8 @@ interface OnboardingRoleSelectProps {
/** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */
value: string[];
onChange: (next: string[]) => void;
/** Render only the option grid — the wizard supplies its own header/card. */
embedded?: boolean;
}
/**
@@ -19,6 +21,7 @@ interface OnboardingRoleSelectProps {
export default function OnboardingRoleSelect({
value,
onChange,
embedded = false,
}: OnboardingRoleSelectProps) {
const selected = new Set(value);
@@ -29,6 +32,23 @@ export default function OnboardingRoleSelect({
onChange([...next]);
};
const grid = (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
<RoleCard
key={role.type}
label={role.label}
description={role.description}
icon={role.icon}
selected={selected.has(role.type)}
onClick={() => toggleRole(role.type)}
/>
))}
</SimpleGrid>
);
if (embedded) return grid;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
@@ -39,19 +59,7 @@ export default function OnboardingRoleSelect({
Pick any combination of Importer, Exporter and Freight Forwarder each
is set up with its own business license.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
<RoleCard
key={role.type}
label={role.label}
description={role.description}
icon={role.icon}
selected={selected.has(role.type)}
onClick={() => toggleRole(role.type)}
/>
))}
</SimpleGrid>
{grid}
</Card>
);
}

View File

@@ -1,27 +1,26 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import {
Card,
Group,
Stack,
Title,
Text,
TextInput,
Button,
Grid,
} from "@mantine/core";
import { api } from "@/services/api";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile";
import { api } from "@/services/api";
import type {
CreateCompanyPayload,
CompanyProfileInput,
CompanyProfileInput,
CreateCompanyPayload,
} from "@/services/companies.service";
import CompanyRolesCard from "./CompanyRolesCard";
import type { ProfileResponse } from "@/types/profile";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Button,
Card,
Grid,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
export const COMPANY_PROFILE_SCHEMA = z.object({
@@ -143,9 +142,7 @@ export default function TabCompanyProfile({
value={selectedRoles}
onChange={setSelectedRoles}
/>
) : (
profile && <CompanyRolesCard profile={profile} />
)}
) : null}
{showForm && (
<Card padding="lg">
<Group gap="sm" mb="xs">

View File

@@ -1,40 +1,40 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
AuthUser,
GenerateVerificationCodePayload,
LoginPayload,
LoginResponse,
OtpPayload,
OtpResponse,
SetPasswordPayload,
SignupPayload,
SignupResponse,
} from "@/types/auth";
import { client } from "@/utils/api";
import { ApiResponse } from "@edr/types";
import type {
AuthUser,
GenerateVerificationCodePayload,
LoginPayload,
LoginResponse,
OtpPayload,
OtpResponse,
SetPasswordPayload,
SignupPayload,
SignupResponse,
} from "@/types/auth";
export const authService = {
login: async (body: LoginPayload) => {
const res = await client.post<ApiResponse<LoginResponse>>(
const res = await client.post<LoginResponse>(
URL_CONSTANTS.AUTH.LOGIN,
body,
);
return res.data.data;
return res.data;
},
createUser: async (body: SignupPayload) => {
const res = await client.post<ApiResponse<SignupResponse>>(
const res = await client.post<SignupResponse & ApiResponse<void>> (
URL_CONSTANTS.USERS.SIGN_UP,
body,
);
return res.data.data;
return res.data;
},
getMyInfo: async () => {
const res = await client.get<ApiResponse<AuthUser>>(
const res = await client.get<AuthUser>(
URL_CONSTANTS.USERS.ME,
);
return res.data.data;
return res.data;
},
generateVerificationCode: async (body: GenerateVerificationCodePayload) => {

View File

@@ -0,0 +1,42 @@
export const flatResponseModules: string[] = [
"/api/file-settings",
"/api/auth",
"/api/sessions",
"/api/users",
"/api/roles",
"/api/user-roles",
"/api/permissions",
"/api/role-permissions",
"/api/user-documents",
"/api/documentary-requirements",
"/api/account-configurations",
"/api/applications",
"/api/organization-types",
"/api/default-units",
"/api/default-positions",
"/api/organizations",
"/api/units",
"/api/unit-settings",
"/api/organization-configurations",
"/api/positions",
"/api/employees",
"/api/employee-positions",
"/api/migrate",
"/api/projects",
"/api/position-permissions",
"/api/position-type-permissions",
"/api/position-types",
"/api/position-configurations",
"/api/organization-global-configurations",
"/api/global-unit-configurations",
"/api/position-type-configurations",
"/api/organization-settings",
"/api/location-types",
"/api/locations",
"/api/unit-clusters",
"/api/seals",
"/api/headers",
"/api/footers",
"/api/employee-signatures",
"/api/employee-stamps",
];

View File

@@ -8,6 +8,7 @@ import {
import { Readable } from "stream";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { flatResponseModules } from "../constants/apiModules";
export interface StandardResponse<T> {
success: true;
@@ -33,6 +34,25 @@ export class ResponseTransformInterceptor<T> implements NestInterceptor<
) {
return data;
}
const request = _context.switchToHttp().getRequest();
const path = request.route?.path ?? request.originalUrl ?? "";
const shouldFlatten = flatResponseModules.some((module) =>
path.startsWith(module),
);
if (
shouldFlatten &&
data &&
typeof data === "object" &&
!Array.isArray(data)
) {
return {
success: true,
...data,
timestamp: new Date().toISOString(),
};
}
return {
success: true,