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> { async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id); const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`); if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
return company; return company;
} }

View File

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

View File

@@ -9,7 +9,7 @@
"preview": "vite preview --port 5183", "preview": "vite preview --port 5183",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run", "test": "vitest run",
"type-check": "tsc --noEmit" "type-check": "tsc -b"
}, },
"dependencies": { "dependencies": {
"@edr/types": "workspace:*", "@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, BookingListSummaryTabs,
} from "@/services/bookings.service"; } 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 = { const CARD_STYLE = {
background: "var(--mantine-color-gray-0)", background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)", border: "1px solid var(--mantine-color-gray-2)",
@@ -62,7 +48,12 @@ export function BookingRequestsHeader({
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Group justify="flex-end" gap="sm"> <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 Create booking
</Button> </Button>
<Button <Button
@@ -90,7 +81,9 @@ export function BookingRequestsHeader({
label="Needs action" label="Needs action"
value={val(metrics?.needsAction)} value={val(metrics?.needsAction)}
hint="Submitted or pending" hint="Submitted or pending"
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0} ratio={
metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0
}
accent="orange" accent="orange"
/> />
<HeroStat <HeroStat
@@ -137,12 +130,18 @@ function HeroStat({
accent?: OverviewAccent; accent?: OverviewAccent;
variant?: "area" | "line"; 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 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 ( 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}> <Stack gap={8}>
<Group gap="sm" wrap="nowrap" align="center"> <Group gap="sm" wrap="nowrap" align="center">
<Box <Box
@@ -161,7 +160,13 @@ function HeroStat({
<Icon size={20} strokeWidth={2} /> <Icon size={20} strokeWidth={2} />
</Box> </Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}> <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} {value}
</Text> </Text>
<Text size="xs" fw={600} c="dimmed" truncate> <Text size="xs" fw={600} c="dimmed" truncate>
@@ -179,66 +184,15 @@ function HeroStat({
</Group> </Group>
{pct == null ? ( {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} ) : null}
</Stack> </Stack>
</Paper> </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, Wallet,
Weight, Weight,
} from "lucide-react"; } 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 { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
@@ -77,7 +85,12 @@ export function BookingRequestHero({
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg"> <Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}> <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 Booking reference
</Text> </Text>
<Group gap="sm" align="center" wrap="wrap"> <Group gap="sm" align="center" wrap="wrap">
@@ -99,8 +112,14 @@ export function BookingRequestHero({
<Group gap="lg" mt={4}> <Group gap="lg" mt={4}>
<MetaItem icon={Building2} text={customerLabel} strong /> <MetaItem icon={Building2} text={customerLabel} strong />
<MetaItem icon={Calendar} text={`Scheduled ${booking.scheduledDate}`} /> <MetaItem
<MetaItem icon={Clock} text={`Created ${formatDate(booking.createdAt)}`} /> icon={Calendar}
text={`Scheduled ${booking.scheduledDate}`}
/>
<MetaItem
icon={Clock}
text={`Created ${formatDate(booking.createdAt)}`}
/>
</Group> </Group>
</Stack> </Stack>
</Group> </Group>
@@ -110,7 +129,10 @@ export function BookingRequestHero({
radius="lg" radius="lg"
p={4} p={4}
maw={640} 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} /> <NextStepBanner nextStep={booking.nextStep} />
</Paper> </Paper>
@@ -120,13 +142,22 @@ export function BookingRequestHero({
<HeroTile <HeroTile
icon={Wallet} icon={Wallet}
label="Total value" label="Total value"
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, { value={`${booking.paymentCurrency} ${amount.toLocaleString(
minimumFractionDigits: 2, undefined,
})}`} {
minimumFractionDigits: 2,
},
)}`}
hint={booking.paymentStatus} hint={booking.paymentStatus}
accent="edr-green" 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 <HeroTile
icon={ContainerIcon} icon={ContainerIcon}
label="Containers" label="Containers"
@@ -195,7 +226,13 @@ function HeroTile({
<Icon size={18} /> <Icon size={18} />
</ThemeIcon> </ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}> <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} {label}
</Text> </Text>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}> <Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>

View File

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

View File

@@ -14,9 +14,11 @@ import {
TextInput, TextInput,
ActionIcon, ActionIcon,
} from "@mantine/core"; } 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"; import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetFormDialogProps { export interface FleetFormDialogProps {
@@ -87,12 +89,19 @@ const FleetFormDialog = ({
const stringValue = const stringValue =
typeof value === "string" ? value.trim() : String(value ?? ""); 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`; next[field.name] = `${field.label} is required`;
} }
// Validate date format (YYYY-MM-DD) - DatePicker ensures this // 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}$/; const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(stringValue)) { if (!dateRegex.test(stringValue)) {
next[field.name] = `${field.label} must be a valid date`; next[field.name] = `${field.label} must be a valid date`;
@@ -115,7 +124,8 @@ const FleetFormDialog = ({
const payload = Object.fromEntries( const payload = Object.fromEntries(
Object.entries(values) Object.entries(values)
.map(([key, value]) => { .map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "") return [key, undefined]; if (value === FLEET_SELECT_NONE || value === "")
return [key, undefined];
return [key, value]; return [key, value];
}) })
.filter(([, value]) => value !== undefined), .filter(([, value]) => value !== undefined),
@@ -133,20 +143,34 @@ const FleetFormDialog = ({
key={field.name} key={field.name}
label={field.label} label={field.label}
data={field.options ?? []} 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) => onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next ?? "" })) setValues((current) => ({ ...current, [field.name]: next ?? "" }))
} }
error={error} error={error}
searchable searchable
disabled={selectOptionsLoading} disabled={selectOptionsLoading}
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined} rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
) : undefined
}
/> />
); );
} }
if (field.type === "multiselect") { 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 ( return (
<MultiSelect <MultiSelect
@@ -162,7 +186,11 @@ const FleetFormDialog = ({
searchable searchable
clearable clearable
disabled={selectOptionsLoading} 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} placeholder={field.placeholder}
value={String(value ?? "")} value={String(value ?? "")}
onChange={(e) => onChange={(e) =>
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value })) setValues((current) => ({
...current,
[field.name]: e.currentTarget?.value,
}))
} }
error={error} error={error}
minRows={3} minRows={3}
@@ -221,7 +252,7 @@ const FleetFormDialog = ({
disabled={field.disabled} disabled={field.disabled}
description={field.description || "Select a date"} description={field.description || "Select a date"}
rightSection={ rightSection={
<ActionIcon size="sm" variant="subtle" color="green" pointer={false}> <ActionIcon size="sm" variant="subtle" color="green">
<Calendar size={16} /> <Calendar size={16} />
</ActionIcon> </ActionIcon>
} }
@@ -250,7 +281,10 @@ const FleetFormDialog = ({
description={field.description} description={field.description}
value={String(value ?? "")} value={String(value ?? "")}
onChange={(e) => onChange={(e) =>
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value })) setValues((current) => ({
...current,
[field.name]: e.currentTarget?.value,
}))
} }
error={error} error={error}
disabled={field.disabled} disabled={field.disabled}
@@ -276,7 +310,11 @@ const FleetFormDialog = ({
<Button variant="default" onClick={() => onOpenChange(false)}> <Button variant="default" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button color="edr-green" loading={isSubmitting} onClick={handleSubmit}> <Button
color="edr-green"
loading={isSubmitting}
onClick={handleSubmit}
>
Save Save
</Button> </Button>
</Group> </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 { 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 { useNavigate } from "react-router-dom";
import type { FleetResourceConfig } from "@/pages/fleet/config/resources"; import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
@@ -37,30 +37,39 @@ const FleetRecordActions = ({
<Menu position="bottom-end" withinPortal shadow="md"> <Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target> <Menu.Target>
<Tooltip label="Actions"> <Tooltip label="Actions">
<ActionIcon <ActionIcon variant="subtle" color="gray" size="sm">
variant="subtle"
color="gray"
size="sm"
>
<MoreVertical size={16} strokeWidth={2} /> <MoreVertical size={16} strokeWidth={2} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{isVehicle && onAssignDriver ? ( {isVehicle && onAssignDriver ? (
<MenuItem onClick={() => onAssignDriver(record)} leftSection={<Users size={14} strokeWidth={2} />}> <MenuItem
onClick={() => onAssignDriver(record)}
leftSection={<Users size={14} strokeWidth={2} />}
>
Assign Driver Assign Driver
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem onClick={() => onEdit(record)} leftSection={<Edit2 size={14} strokeWidth={2} />}> <MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit Edit
</MenuItem> </MenuItem>
{showDetail ? ( {showDetail ? (
<MenuItem onClick={handleDetail} leftSection={<Eye size={14} strokeWidth={2} />}> <MenuItem
onClick={handleDetail}
leftSection={<Eye size={14} strokeWidth={2} />}
>
View details View details
</MenuItem> </MenuItem>
) : null} ) : 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} {removeLabel}
</MenuItem> </MenuItem>
</Menu.Dropdown> </Menu.Dropdown>
@@ -72,30 +81,39 @@ const FleetRecordActions = ({
<Menu position="bottom-end" withinPortal shadow="md"> <Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target> <Menu.Target>
<Tooltip label="Actions"> <Tooltip label="Actions">
<ActionIcon <ActionIcon variant="subtle" color="gray" size="sm">
variant="subtle"
color="gray"
size="sm"
>
<MoreVertical size={16} strokeWidth={2} /> <MoreVertical size={16} strokeWidth={2} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{isVehicle && onAssignDriver ? ( {isVehicle && onAssignDriver ? (
<MenuItem onClick={() => onAssignDriver(record)} leftSection={<Users size={14} strokeWidth={2} />}> <MenuItem
onClick={() => onAssignDriver(record)}
leftSection={<Users size={14} strokeWidth={2} />}
>
Assign Driver Assign Driver
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem onClick={() => onEdit(record)} leftSection={<Edit2 size={14} strokeWidth={2} />}> <MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit Edit
</MenuItem> </MenuItem>
{showDetail ? ( {showDetail ? (
<MenuItem onClick={handleDetail} leftSection={<Eye size={14} strokeWidth={2} />}> <MenuItem
onClick={handleDetail}
leftSection={<Eye size={14} strokeWidth={2} />}
>
View details View details
</MenuItem> </MenuItem>
) : null} ) : 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} {removeLabel}
</MenuItem> </MenuItem>
</Menu.Dropdown> </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 { Stack, Group, Text, Card, SimpleGrid, Skeleton } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources"; import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
@@ -85,8 +85,15 @@ const RuleEngineCardGrid = ({
if (status === "error") { if (status === "error") {
return ( return (
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}> <Stack
<Text size="lg" fw={600} c="red">Failed to load data</Text> 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"> <Text size="sm" c="dimmed">
Please refresh the page or try again later. Please refresh the page or try again later.
</Text> </Text>
@@ -118,8 +125,15 @@ const RuleEngineCardGrid = ({
if (status === "success" && rows.length === 0) { if (status === "success" && rows.length === 0) {
return ( return (
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}> <Stack
<Text size="lg" fw={600}>{emptyMessage}</Text> align="center"
justify="center"
p="xl"
style={{ minHeight: "400px" }}
>
<Text size="lg" fw={600}>
{emptyMessage}
</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Try adjusting your search or add a new record. Try adjusting your search or add a new record.
</Text> </Text>
@@ -197,7 +211,10 @@ const RuleEngineCardGrid = ({
{subtitle && ( {subtitle && (
<Group gap="xs"> <Group gap="xs">
<Text size="xs" c="dimmed" fw={500}> <Text size="xs" c="dimmed" fw={500}>
{presentation.subtitleKey === "stepOrder" ? "Step" : "Type"}: {presentation.subtitleKey === "stepOrder"
? "Step"
: "Type"}
:
</Text> </Text>
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
{subtitle} {subtitle}
@@ -207,7 +224,12 @@ const RuleEngineCardGrid = ({
{presentation.detailColumns.map((col) => { {presentation.detailColumns.map((col) => {
const displayValue = getSmartValue(record, col.accessorKey); const displayValue = getSmartValue(record, col.accessorKey);
return ( 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}> <Text size="xs" c="dimmed" fw={500}>
{col.header}: {col.header}:
</Text> </Text>
@@ -220,14 +242,19 @@ const RuleEngineCardGrid = ({
</Stack> </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 <RuleEngineRecordActions
record={record} record={record}
config={config} config={config}
layout="compact" layout="compact"
readOnly={readOnly} readOnly={readOnly}
onEdit={onEdit ?? (() => {})} onEdit={onEdit ?? (() => { })}
onDelete={onDelete ?? (() => {})} onDelete={onDelete ?? (() => { })}
onViewChain={onViewChain} onViewChain={onViewChain}
onSubmitRate={onSubmitRate} onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate} 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"; import { Group, Pagination, Select, Text } from "@mantine/core";
export interface RuleEngineListFooterProps { export interface RuleEngineListFooterProps {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
import { useMemo } from 'react'; import { DataTable, type ColumnDef } from "@edr/ui-common";
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react'; import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
import { DataTable, type ColumnDef } from '@edr/ui-common'; import { useMemo } from "react";
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; import {
import { getNextInventoryAction } from '@/types/warehouse'; INVENTORY_NEXT_ACTION,
import { InventoryStatusBadge } from './badges'; type InventoryAction,
import { formatDate, formatNumber, humanizeEnum } from './options'; type WarehouseInventoryItem,
} from "@/types/warehouse";
import { InventoryStatusBadge } from "./badges";
import { formatDate, formatNumber, humanizeEnum } from "./options";
interface WarehouseInventoryTableProps { interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[]; items: WarehouseInventoryItem[];
@@ -27,21 +30,21 @@ interface WarehouseInventoryTableProps {
} }
const itemKind = (item: WarehouseInventoryItem) => { const itemKind = (item: WarehouseInventoryItem) => {
if (item.containerId) return { label: 'Container', color: 'blue' }; if (item.containerId) return { label: "Container", color: "blue" };
if (item.cargoId) return { label: 'Cargo', color: 'grape' }; if (item.cargoId) return { label: "Cargo", color: "grape" };
if (item.goodsId) return { label: 'Goods', color: 'orange' }; if (item.goodsId) return { label: "Goods", color: "orange" };
return { label: '—', color: 'gray' }; return { label: "—", color: "gray" };
}; };
const actionColor: Record<InventoryAction, string> = { const actionColor: Record<InventoryAction, string> = {
store: 'blue', store: "blue",
reserve: 'grape', reserve: "grape",
'ready-for-loading': 'cyan', "ready-for-loading": "cyan",
load: 'teal', load: "teal",
dispatch: 'edr-green', dispatch: "edr-green",
'ready-for-pickup': 'orange', "ready-for-pickup": "orange",
release: 'yellow', release: "yellow",
deliver: 'green', deliver: "green",
}; };
export function WarehouseInventoryTable({ export function WarehouseInventoryTable({
@@ -52,18 +55,12 @@ export function WarehouseInventoryTable({
onHistory, onHistory,
onInspect, onInspect,
onFeePreview, onFeePreview,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) { }: WarehouseInventoryTableProps) {
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>( const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
() => [ () => [
{ {
id: 'booking', id: "booking",
header: 'Booking', header: "Booking",
cell: ({ row }) => cell: ({ row }) =>
row.original.bookingId ? ( row.original.bookingId ? (
<Tooltip label={row.original.bookingId} withArrow> <Tooltip label={row.original.bookingId} withArrow>
@@ -78,16 +75,28 @@ export function WarehouseInventoryTable({
), ),
}, },
{ {
id: 'facility', id: "facility",
header: 'Facility', header: "Facility",
cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—', 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', id: "warehouse",
header: 'Item', 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 }) => { cell: ({ row }) => {
const kind = itemKind(row.original); const kind = itemKind(row.original);
return ( 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', id: "qty",
header: 'Status', header: "Qty",
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />, cell: ({ row }) => formatNumber(row.original.quantity),
}, },
{ {
id: 'arrived', id: "weight",
header: 'Arrived', header: "Weight",
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>, cell: ({ row }) => formatNumber(row.original.weight),
}, },
{ {
id: 'actions', id: "status",
header: '', 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 }) => { cell: ({ row }) => {
const item = row.original; const item = row.original;
const busy = busyId === item.id; const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status]; const nextAction = INVENTORY_NEXT_ACTION[item.status];
return ( 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 && ( {nextAction && (
<Button <Button
size="compact-xs" size="compact-xs"
@@ -126,32 +152,48 @@ export function WarehouseInventoryTable({
loading={busy} loading={busy}
onClick={() => onAdvance(item, nextAction)} onClick={() => onAdvance(item, nextAction)}
> >
{humanizeEnum(nextAction.replace(/-/g, '_'))} {humanizeEnum(nextAction.replace(/-/g, "_"))}
</Button> </Button>
)} )}
{item.status !== 'DISPATCHED' && ( {item.status !== "DISPATCHED" && (
<Tooltip label="Move" withArrow> <Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}> <ActionIcon
variant="subtle"
color="gray"
onClick={() => onMove(item)}
>
<ArrowRightLeft size={16} /> <ArrowRightLeft size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{onInspect && ( {onInspect && (
<Tooltip label="Inspection / Report" withArrow> <Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}> <ActionIcon
variant="subtle"
color="orange"
onClick={() => onInspect(item)}
>
<ClipboardList size={16} /> <ClipboardList size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{onFeePreview && ( {onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow> <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} /> <Coins size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
<Tooltip label="History" withArrow> <Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}> <ActionIcon
variant="subtle"
color="gray"
onClick={() => onHistory(item)}
>
<History size={16} /> <History size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </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) => const humanize = (value: string) =>
value value
.toLowerCase() .toLowerCase()
.split('_') .split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' '); .join(" ");
const badgeStyle = { const badgeStyle = {
fontSize: '0.7rem', fontSize: "0.7rem",
letterSpacing: '0.04em', letterSpacing: "0.04em",
whiteSpace: 'nowrap' as const, whiteSpace: "nowrap" as const,
}; };
export function WarehouseTypeBadge({ type }: { type: WarehouseType }) { export function WarehouseTypeBadge({ type }: { type: WarehouseType }) {
const color = type === 'CLOSED_WAREHOUSE' ? 'indigo' : 'teal'; const color = type === "CLOSED_WAREHOUSE" ? "indigo" : "teal";
return ( 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)} {humanize(type)}
</Badge> </Badge>
); );
} }
export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) { export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
const color = status === 'ACTIVE' ? 'edr-green' : 'gray'; const color = status === "ACTIVE" ? "edr-green" : "gray";
return ( 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} {status}
</Badge> </Badge>
); );
} }
const inventoryStatusColor: Record<InventoryStatus, string> = { const inventoryStatusColor: Record<InventoryStatus, string> = {
UNLOADED: 'indigo', UNLOADED: "indigo",
RECEIVED: 'yellow', RECEIVED: "yellow",
STORED: 'blue', STORED: "blue",
RESERVED: 'grape', RESERVED: "grape",
READY_FOR_LOADING: 'cyan', READY_FOR_LOADING: "cyan",
LOADED: 'teal', LOADED: "teal",
DISPATCHED: 'edr-green', READY_FOR_PICKUP: "teal",
DELIVERED: 'edr-green', DISPATCHED: "edr-green",
DELIVERED: "edr-green",
}; };
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) { export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
const color = inventoryStatusColor[status] ?? 'gray'; const color = inventoryStatusColor[status] ?? "gray";
return ( 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)} {humanize(status)}
</Badge> </Badge>
); );

View File

@@ -55,7 +55,11 @@ export interface BookingActionDef {
export type BookingActionContext = Pick< export type BookingActionContext = Pick<
BookingDetail, BookingDetail,
"status" | "paymentCurrency" | "approvalSteps" | "reference" | "schedulingStatus" | "status"
| "paymentCurrency"
| "approvalSteps"
| "reference"
| "schedulingStatus"
>; >;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([ 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 ( return (
booking.status === "PAID" && booking.status === "PAID" &&
ALLOCATABLE_SCHEDULING_STATUSES.has(booking.schedulingStatus ?? undefined) ALLOCATABLE_SCHEDULING_STATUSES.has(booking.schedulingStatus ?? undefined)
@@ -286,10 +292,18 @@ export function getBookingActions(
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION]; actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break; break;
case "FULLY_EXECUTED": case "FULLY_EXECUTED":
actions = [{ ...VIEW_CONTRACT_ACTION, label: "View executed contract", primary: true }]; actions = [
{
...VIEW_CONTRACT_ACTION,
label: "View executed contract",
primary: true,
},
];
break; break;
case "PAID": case "PAID":
if (canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })) { if (
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
) {
actions = [ actions = [
{ {
id: "allocateBooking", id: "allocateBooking",
@@ -384,7 +398,7 @@ export function listRowHasActions(
paymentCurrency: row.paymentCurrency, paymentCurrency: row.paymentCurrency,
reference: "", reference: "",
approvalSteps: row.approvalSteps ?? undefined, approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.schedulingStatus, schedulingStatus: row.status,
}, },
user, 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( const approvedCount = approvalSteps.filter(
(s) => s.status === "APPROVED", (s) => s.status === "APPROVED",
).length; ).length;
const totalSteps = approvalSteps.length;
return ( return (
<div style={detailStyles.page}> <div style={detailStyles.page}>

View File

@@ -34,7 +34,10 @@ import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { downloadBookingFile } from "@/services/files.service"; 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"; import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not // 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() { export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); 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 mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => { const handleDownloadFile = async (file: BookingFileView) => {
@@ -79,7 +88,13 @@ export default function BookingRequestDetailPage() {
return ( return (
<PageContainer> <PageContainer>
<Container size="sm" py="xl"> <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> <Center>
<Box <Box
style={{ style={{
@@ -137,75 +152,83 @@ export default function BookingRequestDetailPage() {
/> />
<Stack gap="lg"> <Stack gap="lg">
<BookingRequestHero <BookingRequestHero
booking={booking} booking={booking}
customerLabel={row.customerLabel} customerLabel={row.customerLabel}
onBack={() => navigate("/dashboard/booking-requests")} onBack={() => navigate("/dashboard/booking-requests")}
onRefresh={() => refetch()} onRefresh={() => refetch()}
isFetching={isFetching} isFetching={isFetching}
/> />
<BookingWorkflowStepper <BookingWorkflowStepper
status={booking.status} status={booking.status}
title={statusMeta.title} title={statusMeta.title}
description={statusMeta.description} description={statusMeta.description}
titleColor={statusMeta.color} titleColor={statusMeta.color}
/> />
{booking.status === "PENDING_CONSOLIDATION" && ( {booking.status === "PENDING_CONSOLIDATION" && (
<ConsolidationWaitingBanner bookingId={booking.id} /> <ConsolidationWaitingBanner bookingId={booking.id} />
)} )}
<Grid gutter="lg"> <Grid gap="lg">
{/* LEFT — primary content */} {/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg"> <Stack gap="lg">
<BookingRouteServiceCard <BookingRouteServiceCard
booking={booking} booking={booking}
originLabel={row.originLabel} originLabel={row.originLabel}
destinationLabel={row.destinationLabel} destinationLabel={row.destinationLabel}
/> />
<BookingMileServicesCard booking={booking} /> <BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} /> <BookingCargoCard booking={booking} />
{booking.contractSummary && ( {booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} /> <BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)} )}
<BookingDocumentsCard onDownload={handleDownloadFile}
files={(booking.files ?? []).filter( />
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""), </Stack>
)} </Grid.Col>
onDownload={handleDownloadFile}
/>
</Stack>
</Grid.Col>
{/* RIGHT — sticky action / summary rail */} {/* RIGHT — sticky action / summary rail */}
<Grid.Col span={{ base: 12, lg: 4 }}> <Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}> <Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg"> <Stack gap="lg">
<BookingCompanyCard booking={booking} /> <BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} /> <BookingPricingSummary booking={booking} />
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} /> <WarehouseInfoCard
<BookingActionsToolbar booking={booking} mutations={mutations} /> bookingId={booking.id}
{showContractButton && ( bookingReference={booking.reference}
<Button />
fullWidth <BookingActionsToolbar
color="edr-green" booking={booking}
leftSection={<FileSignature size={16} />} mutations={mutations}
onClick={() => />
navigate(`/dashboard/booking-requests/${booking.id}/contract`) {showContractButton && (
} <Button
> fullWidth
View & sign contract color="edr-green"
</Button> leftSection={<FileSignature size={16} />}
)} onClick={() =>
{showApprovalCard && ( navigate(
<ApprovalStepsCard booking={booking} mutations={mutations} /> `/dashboard/booking-requests/${booking.id}/contract`,
)} )
</Stack> }
</Box> >
</Grid.Col> View & sign contract
</Grid> </Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
</Stack> </Stack>
</PageContainer> </PageContainer>
); );

View File

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

View File

@@ -93,7 +93,7 @@ const OverviewPage = () => {
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => { const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
if (!summary?.kpis) return 0; 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; return group[tab.metricKey] ?? 0;
}; };

View File

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

View File

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

View File

@@ -17,15 +17,7 @@ import { Textarea } from "@/components/ui/textarea";
import { FileUploadEntity } from "@edr/types/freight"; import { FileUploadEntity } from "@edr/types/freight";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { FileUploadSetting } from "@/types/fileUploadSettings";
// import type {
// FileUploadEntity,
// FileUploadSetting,
// } from "@/types/fileUploadSettings";
// import {
// useCreateFileUploadSetting,
// useUpdateFileUploadSetting,
// } from "@/hooks/useFileUploadSettings";
export interface EditFileUploadSettingDialogProps { export interface EditFileUploadSettingDialogProps {
mode?: "create" | "edit"; mode?: "create" | "edit";
@@ -33,19 +25,6 @@ export interface EditFileUploadSettingDialogProps {
children: ReactNode; 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({ export default function EditFileUploadSettingDialog({
mode = "create", mode = "create",
setting, setting,
@@ -62,8 +41,12 @@ export default function EditFileUploadSettingDialog({
const [description, setDescription] = useState(setting?.description ?? ""); const [description, setDescription] = useState(setting?.description ?? "");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const createMutation = useMutation(api.fileUploadSettings.create.mutationOptions()); const createMutation = useMutation(
const updateMutation = useMutation(api.fileUploadSettings.update.mutationOptions()); api.fileUploadSettings.create.mutationOptions(),
);
const updateMutation = useMutation(
api.fileUploadSettings.update.mutationOptions(),
);
const pending = createMutation.isPending || updateMutation.isPending; const pending = createMutation.isPending || updateMutation.isPending;
const reset = () => { const reset = () => {

View File

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

View File

@@ -1,19 +1,10 @@
import type { ColumnDef } from "@edr/ui-common"; 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 { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { import Breadcrumbs from "@/components/ui/Breadcrumbs";
Archive, import { Plus } from "lucide-react";
Circle,
CircleCheck,
CircleSlash,
Layers,
Link2,
Plus,
Wrench,
type LucideIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
@@ -23,37 +14,20 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar"; import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { import {
FLEET_SELECT_NONE, FLEET_SELECT_NONE,
getFleetResource, getFleetResource,
getFleetSlugFromPath, getFleetSlugFromPath,
type FleetFormFieldDef, type FleetFormFieldDef,
type FleetResourceSlug, type FleetResourceSlug,
} from "@/pages/fleet/config/resources"; } from "@/pages/fleet/config/resources";
import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service"; import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; 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 FleetResourcePage = () => {
const location = useLocation(); const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
@@ -113,6 +87,9 @@ const FleetResourcePage = () => {
const { data: yards = [], isLoading: yardsLoading } = useQuery( const { data: yards = [], isLoading: yardsLoading } = useQuery(
api.routes.yards.queryOptions(), api.routes.yards.queryOptions(),
); );
const { data: drivers = [] } = useQuery(
api.fleet.list.queryOptions({ input: { slug: "drivers" } }),
);
useEffect(() => { useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
@@ -301,39 +278,6 @@ const FleetResourcePage = () => {
const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; 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) { if (!config) {
return <Navigate to="/dashboard/locomotives" replace />; return <Navigate to="/dashboard/locomotives" replace />;
} }
@@ -376,7 +320,7 @@ const FleetResourcePage = () => {
const handleAssignDriver = async () => { const handleAssignDriver = async () => {
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return; if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
try { 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 (d) => String(d.id) === selectedDriver
); );
if (!selectedDriverRecord) return; if (!selectedDriverRecord) return;
@@ -384,6 +328,7 @@ const FleetResourcePage = () => {
const driverName = `${selectedDriverRecord.firstName} ${selectedDriverRecord.lastName}`; const driverName = `${selectedDriverRecord.firstName} ${selectedDriverRecord.lastName}`;
await update.mutateAsync({ await update.mutateAsync({
slug,
id: String(assigningDriver.id), id: String(assigningDriver.id),
data: { data: {
assignedDriverId: selectedDriver, assignedDriverId: selectedDriver,
@@ -608,7 +553,7 @@ const FleetResourcePage = () => {
clearable clearable
value={selectedDriver} value={selectedDriver}
onChange={(value) => setSelectedDriver(value || "")} 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 || ""), value: String(driver.id || ""),
label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`, label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`,
}))} }))}

View File

@@ -3,6 +3,7 @@ import {
Box, Box,
Card, Card,
Group, Group,
Badge,
Badge as MantineBadge, Badge as MantineBadge,
Select, Select,
Stack, Stack,
@@ -28,7 +29,6 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import { import {
Badge,
DataTable, DataTable,
DataTableFooter, DataTableFooter,
usePagination, usePagination,
@@ -36,7 +36,12 @@ import {
} from "@edr/ui-common"; } from "@edr/ui-common";
const STATUS_TABS = [ 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: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
{ {
key: "processing", key: "processing",
@@ -44,7 +49,12 @@ const STATUS_TABS = [
statuses: "processing,action-required", statuses: "processing,action-required",
icon: Loader2, 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 }, { key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
] as const; ] as const;
@@ -81,13 +91,14 @@ function formatDate(iso: string | null): string {
return Number.isNaN(d.getTime()) return Number.isNaN(d.getTime())
? "—" ? "—"
: d.toLocaleDateString(undefined, { : d.toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", 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() { export default function PaymentsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -124,9 +135,9 @@ export default function PaymentsPage() {
summary === undefined summary === undefined
? undefined ? undefined
: (summary.success ?? 0) + : (summary.success ?? 0) +
(summary.processing ?? 0) + (summary.processing ?? 0) +
(summary.failed ?? 0) + (summary.failed ?? 0) +
(summary.refunded ?? 0), (summary.refunded ?? 0),
success: summary?.success, success: summary?.success,
processing: summary?.processing, processing: summary?.processing,
failed: summary?.failed, failed: summary?.failed,
@@ -285,7 +296,10 @@ export default function PaymentsPage() {
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}} }}
rightSection={ rightSection={
query && ( query && (
@@ -309,7 +323,10 @@ export default function PaymentsPage() {
value={method} value={method}
onChange={(value) => { onChange={(value) => {
setMethod(value); setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}} }}
style={{ minWidth: 180 }} 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 { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions"; import { canAccessRuleEngineResource } from "@/lib/permissions";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@edr/ui-common";
import { Box, Card, Button, Modal, Stack, Group, Text, List, Loader } from "@mantine/core"; import {
Box,
Button,
Card,
Group,
List,
Loader,
Modal,
Stack,
Text,
} from "@mantine/core";
import { Plus } from "lucide-react"; 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 { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls"; import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions"; import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar"; import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat"; import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode"; import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
import { import {
DEFAULT_CONFIGURATION_SLUG, useApprovalChain,
DEFAULT_RULES_SLUG, useCargoTypeParentOptions,
RULE_ENGINE_CATEGORY_BASE_PATH, useContainerTypeOptions,
RULE_ENGINE_SELECT_NONE, useLiveRateOptions,
getRuleEngineResource, useRateWorkflow,
type RuleEngineNavCategory, useRuleEngineList,
} from "@/pages/ruleEngine/config/resources"; useRuleEngineMutations,
import { useRuleEngineOrderList,
useApprovalChain, useRuleEngineOrderMutations,
useCargoTypeParentOptions,
useContainerTypeOptions,
useLiveRateOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
useRuleEngineOrderList,
useRuleEngineOrderMutations,
} from "@/hooks/rule-engine/useRuleEngine"; } 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 type { RuleEngineRecord } from "@/types/rule-engine";
import { import {
DataTable, DataTable,
DataTableFooter, DataTableFooter,
getCoreRowModel, usePagination,
usePagination,
useReactTable,
} from "@edr/ui-common"; } from "@edr/ui-common";
const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => { const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
@@ -60,16 +68,17 @@ const RuleEngineResourcePage = () => {
const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined; const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined;
const defaultPath = category const defaultPath = category
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${ ? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG }`
}`
: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`; : `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`;
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RuleEngineRecord | null>(null); 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 [chainOpen, setChainOpen] = useState(false);
const [orderDialogOpen, setOrderDialogOpen] = useState(false); const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const { viewMode, setViewMode } = useRuleEngineViewMode( const { viewMode, setViewMode } = useRuleEngineViewMode(
@@ -90,9 +99,9 @@ const RuleEngineResourcePage = () => {
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
...(config?.orderConfig ...(config?.orderConfig
? { ? {
sortBy: config.orderConfig.field, sortBy: config.orderConfig.field,
sortOrder: "ASC" as const, sortOrder: "ASC" as const,
} }
: {}), : {}),
}), }),
[ [
@@ -120,11 +129,12 @@ const RuleEngineResourcePage = () => {
const { reorder, moveOrder } = useRuleEngineOrderMutations( const { reorder, moveOrder } = useRuleEngineOrderMutations(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG, config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
); );
const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList( const { data: orderListData, isLoading: orderListLoading } =
config?.slug ?? DEFAULT_CONFIGURATION_SLUG, useRuleEngineOrderList(
Boolean(orderDialogOpen && config?.orderConfig), config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
config?.orderConfig?.field, Boolean(orderDialogOpen && config?.orderConfig),
); config?.orderConfig?.field,
);
const { submit, approve } = useRateWorkflow(); const { submit, approve } = useRateWorkflow();
const { data: chainData, isLoading: chainLoading } = useApprovalChain( const { data: chainData, isLoading: chainLoading } = useApprovalChain(
chainOpen && config?.slug === "approval-rules", chainOpen && config?.slug === "approval-rules",
@@ -141,10 +151,7 @@ const RuleEngineResourcePage = () => {
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } = const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types"); useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } = const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions( useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
config?.slug === "rates",
usesContainerTypeField,
);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } = const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField); useLiveRateOptions(usesLiveRateField);
@@ -154,10 +161,9 @@ const RuleEngineResourcePage = () => {
if (config.slug === "cargo-types" && field.name === "parentGroupId") { if (config.slug === "cargo-types" && field.name === "parentGroupId") {
return { return {
...field, ...field,
options: options: cargoParentOptions ?? [
cargoParentOptions ?? [ { label: "None", value: RULE_ENGINE_SELECT_NONE },
{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ],
],
}; };
} }
if (field.name === "containerTypeId") { if (field.name === "containerTypeId") {
@@ -183,14 +189,16 @@ const RuleEngineResourcePage = () => {
const pageCount = meta?.totalPages ?? 1; const pageCount = meta?.totalPages ?? 1;
const totalCount = meta?.total ?? rows.length; const totalCount = meta?.total ?? rows.length;
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList( const { data: createPositionList, isLoading: createPositionLoading } =
config?.slug ?? DEFAULT_CONFIGURATION_SLUG, useRuleEngineOrderList(
Boolean(formOpen && !editing && config?.orderConfig), config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
config?.orderConfig?.field, Boolean(formOpen && !editing && config?.orderConfig),
); config?.orderConfig?.field,
);
const createPositionOptions = useMemo(() => { const createPositionOptions = useMemo(() => {
if (!config?.orderConfig || !createPositionList?.data?.length) return undefined; if (!config?.orderConfig || !createPositionList?.data?.length)
return undefined;
return createPositionList.data return createPositionList.data
.filter((row) => row.id) .filter((row) => row.id)
.map((row) => ({ .map((row) => ({
@@ -199,7 +207,6 @@ const RuleEngineResourcePage = () => {
})); }));
}, [config?.orderConfig, config?.slug, createPositionList?.data]); }, [config?.orderConfig, config?.slug, createPositionList?.data]);
const handleApproveRate = useCallback( const handleApproveRate = useCallback(
(record: RuleEngineRecord) => { (record: RuleEngineRecord) => {
approve.mutate(String(record.id)); approve.mutate(String(record.id));
@@ -259,7 +266,9 @@ const RuleEngineResourcePage = () => {
}} }}
onDelete={setDeleteTarget} onDelete={setDeleteTarget}
onViewChain={ onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
} }
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined} onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined} onApproveRate={canManage ? handleApproveRate : undefined}
@@ -270,7 +279,15 @@ const RuleEngineResourcePage = () => {
}); });
return base; 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"; const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -350,15 +367,20 @@ const RuleEngineResourcePage = () => {
onSearchChange={ onSearchChange={
config.supportsSearch config.supportsSearch
? (v) => { ? (v) => {
setSearch(v); setSearch(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({
} pageIndex: 0,
pageSize: pagination.pageSize,
});
}
: undefined : undefined
} }
showSearch={Boolean(config.supportsSearch)} showSearch={Boolean(config.supportsSearch)}
searchPlaceholder={config.searchPlaceholder} searchPlaceholder={config.searchPlaceholder}
onManageOrder={ onManageOrder={
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined canManage && config.orderConfig
? () => setOrderDialogOpen(true)
: undefined
} }
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
@@ -373,10 +395,12 @@ const RuleEngineResourcePage = () => {
error={ error={
isError isError
? { ? {
message: "Failed to load data", message: "Failed to load data",
description: description:
error instanceof Error ? error.message : "Unknown error", error instanceof Error
} ? error.message
: "Unknown error",
}
: undefined : undefined
} }
emptyMessage={`No ${itemLabel} found.`} emptyMessage={`No ${itemLabel} found.`}
@@ -422,7 +446,9 @@ const RuleEngineResourcePage = () => {
onEdit={canManage ? openEdit : undefined} onEdit={canManage ? openEdit : undefined}
onDelete={canManage ? setDeleteTarget : undefined} onDelete={canManage ? setDeleteTarget : undefined}
onViewChain={ onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
} }
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined} onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined} onApproveRate={canManage ? handleApproveRate : undefined}
@@ -434,7 +460,11 @@ const RuleEngineResourcePage = () => {
<RuleEngineFormDialog <RuleEngineFormDialog
open={formOpen} open={formOpen}
onOpenChange={setFormOpen} 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={ description={
editing editing
? `Update this ${config.label.toLowerCase()} record.` ? `Update this ${config.label.toLowerCase()} record.`
@@ -478,7 +508,8 @@ const RuleEngineResourcePage = () => {
> >
<Stack gap="md"> <Stack gap="md">
<Text size="sm"> <Text size="sm">
This will soft-delete the selected {config.label.toLowerCase()} record. This will soft-delete the selected {config.label.toLowerCase()}{" "}
record.
</Text> </Text>
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}> <Button variant="default" onClick={() => setDeleteTarget(null)}>
@@ -515,14 +546,17 @@ const RuleEngineResourcePage = () => {
) : ( ) : (
<> <>
{(chainData ?? []).length === 0 ? ( {(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"> <List spacing="md">
{(chainData ?? []).map((step, index) => ( {(chainData ?? []).map((step, index) => (
<List.Item key={String(step.id ?? index)}> <List.Item key={String(step.id ?? index)}>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={500}> <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>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Role: {String(step.requiredRole ?? "—")} Role: {String(step.requiredRole ?? "—")}

View File

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

View File

@@ -24,6 +24,7 @@ function mapCompany(dto: Record<string, unknown>): Company {
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {}; const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
return { return {
...(dto as unknown as Company), ...(dto as unknown as Company),
companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [],
contactPersonName: (attrs.contactPersonName as string | null) ?? null, contactPersonName: (attrs.contactPersonName as string | null) ?? null,
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null, contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
generalManagerName: (attrs.generalManagerName 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, total: dataLength,
page, page,
pageSize, pageSize,
@@ -79,7 +83,7 @@ const isPaginatedListResult = <T extends RuleEngineRecord>(
): value is RuleEngineListResult<T> => ): value is RuleEngineListResult<T> =>
Boolean(value) && Boolean(value) &&
typeof value === "object" && typeof value === "object" &&
"data" in value && "data" in (value ?? {}) &&
Array.isArray((value as RuleEngineListResult<T>).data); Array.isArray((value as RuleEngineListResult<T>).data);
const normalizeList = <T extends RuleEngineRecord>( const normalizeList = <T extends RuleEngineRecord>(
@@ -104,7 +108,10 @@ const normalizeList = <T extends RuleEngineRecord>(
} }
if (Array.isArray(body)) { 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) }; return { data: [], meta: defaultMeta(0, page, pageSize) };
@@ -161,7 +168,10 @@ export const ruleEngineService = {
return normalizeEntity<T>(response.data); 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)); await client.delete(byIdPath(resource, id));
}, },
@@ -181,24 +191,36 @@ export const ruleEngineService = {
}, },
submitRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => { 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); return normalizeEntity<T>(response.data);
}, },
approveRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => { 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); return normalizeEntity<T>(response.data);
}, },
getApprovalChain: async ( getApprovalChain: async (
requiresDirectorApproval = true, requiresDirectorApproval = true,
): Promise<RuleEngineRecord[]> => { ): Promise<RuleEngineRecord[]> => {
const response = await client.get(URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_CHAIN, { const response = await client.get(
params: { requiresDirectorApproval }, URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_CHAIN,
}); {
params: { requiresDirectorApproval },
},
);
const body = unwrap(response.data) as unknown; const body = unwrap(response.data) as unknown;
if (Array.isArray(body)) return body as RuleEngineRecord[]; 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 (body as { data: RuleEngineRecord[] }).data;
} }
return []; return [];

View File

@@ -1,13 +1,12 @@
import { api as client } from '../auth/http'; import { api as client } from "../auth/http";
import { unwrap } from '@/utils/endpoint'; import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from '@/constants/URLS'; import { URL_CONSTANTS } from "@/constants/URLS";
import type { import type {
BatchBoardSchedule, BatchBoardSchedule,
BatchBoardScheduleDetail, BatchBoardScheduleDetail,
BookableSchedule, BookableSchedule,
AssignBookingsPayload, AssignBookingsPayload,
CompositionRemovalEntry, CompositionRemovalEntry,
CompositionUnassignedBooking,
UnassignedBookingsResponse, UnassignedBookingsResponse,
CreateTrainSchedulePayload, CreateTrainSchedulePayload,
EligibleContainerBookingsResponse, EligibleContainerBookingsResponse,
@@ -24,7 +23,7 @@ import type {
TrainTrackResponse, TrainTrackResponse,
WagonAllocationAttemptResult, WagonAllocationAttemptResult,
YardOption, YardOption,
} from '@/types/trainScheduling'; } from "@/types/trainScheduling";
interface BookingReferenceDataResponse { interface BookingReferenceDataResponse {
yard?: Array<YardOption & { label?: string }>; yard?: Array<YardOption & { label?: string }>;
@@ -58,7 +57,9 @@ export const trainSchedulingService = {
): Promise<TrainSchedulePreviewResponse> => { ): Promise<TrainSchedulePreviewResponse> => {
const useUnified = !freightType || freightType === "MIXED"; const useUnified = !freightType || freightType === "MIXED";
const response = await client.post<TrainSchedulePreviewResponse>( 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, payload,
); );
return unwrap(response.data); return unwrap(response.data);
@@ -91,7 +92,9 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
getBatchBoardDetail: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => { getBatchBoardDetail: async (
scheduleId: string,
): Promise<BatchBoardScheduleDetail> => {
const response = await client.get<BatchBoardScheduleDetail>( const response = await client.get<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD_DETAIL(scheduleId), URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD_DETAIL(scheduleId),
); );
@@ -132,7 +135,9 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
runAllocation: async (scheduleId: string): Promise<WagonAllocationAttemptResult> => { runAllocation: async (
scheduleId: string,
): Promise<WagonAllocationAttemptResult> => {
const response = await client.post<WagonAllocationAttemptResult>( const response = await client.post<WagonAllocationAttemptResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_ALLOCATION(scheduleId), URL_CONSTANTS.TRAIN_SCHEDULING.RUN_ALLOCATION(scheduleId),
{}, {},
@@ -152,20 +157,29 @@ export const trainSchedulingService = {
}, },
markBookingPaid: async (bookingId: string): Promise<void> => { 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> => { 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 ( moveBookingSchedule: async (
bookingId: string, bookingId: string,
trainScheduleId: string, trainScheduleId: string,
): Promise<void> => { ): Promise<void> => {
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_BOOKING_SCHEDULE(bookingId), { await client.post(
trainScheduleId, URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_BOOKING_SCHEDULE(bookingId),
}); {
trainScheduleId,
},
);
}, },
getScheduleById: async ( getScheduleById: async (
@@ -173,7 +187,9 @@ export const trainSchedulingService = {
freightType?: FreightType, freightType?: FreightType,
): Promise<TrainScheduleDetail> => { ): Promise<TrainScheduleDetail> => {
const response = await client.get<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); return unwrap(response.data);
}, },
@@ -225,7 +241,9 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
finalizeSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => { finalizeSchedule: async (
scheduleId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>( const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.FINALIZE(scheduleId), URL_CONSTANTS.TRAIN_SCHEDULING.FINALIZE(scheduleId),
{}, {},
@@ -233,7 +251,9 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
dispatchSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => { dispatchSchedule: async (
scheduleId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>( const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId), URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
{}, {},
@@ -272,13 +292,17 @@ export const trainSchedulingService = {
freightType: FreightType = "CONTAINER", freightType: FreightType = "CONTAINER",
): Promise<TrainScheduleDetail> => { ): Promise<TrainScheduleDetail> => {
const response = await client.post<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); return unwrap(response.data);
}, },
getAvailableLocomotives: async (routeId?: string): Promise<LocomotiveRecord[]> => { getAvailableLocomotives: async (
routeId?: string,
): Promise<LocomotiveRecord[]> => {
if (routeId) { if (routeId) {
const response = await client.get<LocomotiveRecord[]>( const response = await client.get<LocomotiveRecord[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_LOCOMOTIVES, URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_LOCOMOTIVES,
@@ -286,9 +310,12 @@ export const trainSchedulingService = {
); );
return unwrap(response.data); return unwrap(response.data);
} }
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, { const response = await client.get<LocomotiveRecord[]>(
params: { status: 'AVAILABLE' }, URL_CONSTANTS.LOCOMOTIVES.BASE,
}); {
params: { status: "AVAILABLE" },
},
);
return unwrap(response.data); 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>( const response = await client.delete<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.REMOVE_WAGON_SLOT(scheduleId, wagonId), URL_CONSTANTS.TRAIN_SCHEDULING.REMOVE_WAGON_SLOT(scheduleId, wagonId),
); );
@@ -392,7 +422,10 @@ export const trainSchedulingService = {
itemId: string, itemId: string,
payload: { containerNumber: string | null }, payload: { containerNumber: string | null },
): Promise<{ id: string; 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), URL_CONSTANTS.TRAIN_SCHEDULING.UPDATE_CONTAINER_ITEM(scheduleId, itemId),
payload, 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; origin: string | null;
destination: string | null; destination: string | null;
freightType?: FreightType | null; freightType?: FreightType | null;
locomotive: locomotive: {
| { id: string;
id: string; code: string;
code: string; name?: string | null;
name?: string | null; currentYardId?: string | null;
currentYardId?: string | null; } | null;
}
| null;
wagonCount: number; wagonCount: number;
totalWeightTons: number; totalWeightTons: number;
totalLengthMeters: number; totalLengthMeters: number;
@@ -316,7 +314,6 @@ export interface TrainScheduleWagonAllocation {
export interface TrainScheduleDetail { export interface TrainScheduleDetail {
id: string; id: string;
status: TrainScheduleStatus | string; status: TrainScheduleStatus | string;
warnings?: string[];
deferredBookings?: DeferredBookingRow[]; deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null; freightType?: FreightType | null;
trainNumber?: string | null; trainNumber?: string | null;
@@ -378,6 +375,7 @@ export interface TrainScheduleDetail {
weightTons: number; weightTons: number;
status: string | null; status: string | null;
schedulingStatus?: SchedulingStatus | null; schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
}>; }>;
warnings?: string[]; warnings?: string[];
} }

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { Input } from "@mantine/core"; import { Input, TextInput } from "@mantine/core";
import { forwardRef } from "react";
import { import {
Controller, Controller,
type Control, type Control,
@@ -32,17 +31,6 @@ export const toEthiopianE164 = (raw?: string | null): string => {
return `+251${digits}`; 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 { export interface PhoneFieldProps {
label?: string; label?: string;
value?: string; value?: string;
@@ -75,10 +63,10 @@ export function PhoneField({
required={required} required={required}
error={error} error={error}
styles={{ 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 <RPNInput
international international
defaultCountry="ET" defaultCountry="ET"
@@ -86,10 +74,9 @@ export function PhoneField({
addInternationalOption addInternationalOption
value={value} value={value}
onChange={onChange} onChange={onChange}
onBlur={onBlur} inputComponent={TextInput}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
inputComponent={StyledInput}
/> />
</div> </div>
</Input.Wrapper> </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 { 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 { useCallback, useEffect, useRef, useState } from "react";
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
import useAuth from "@/hooks/useAuth"; 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 { api } from "@/services/api";
import type { import type {
CompanyNationality, CompanyNationality,
@@ -12,12 +37,8 @@ import type {
import { companiesService } from "@/services/companies.service"; import { companiesService } from "@/services/companies.service";
import type { UpdateProfilePayload } from "@/types/profile"; import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result"; 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 = type FormStep =
| "company" | "company"
| "personnel" | "personnel"
@@ -34,6 +55,58 @@ const FORM_STEPS: FormStep[] = [
"additional", "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 { interface OnboardingWizardDialogProps {
opened: boolean; opened: boolean;
/** Dismiss the dialog (user clicked the close icon). */ /** 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. // Newly-selected business-license files per company_profile id.
const [licenseFiles, setLicenseFiles] = useState<Record<string, File[]>>({}); const [licenseFiles, setLicenseFiles] = useState<Record<string, File[]>>({});
const [startError, setStartError] = useState<string | null>(null); 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. // Saved profile data, for rehydrating the form fields after a refresh.
const profileQuery = useQuery( const profileQuery = useQuery(
@@ -164,6 +240,15 @@ export default function OnboardingWizardDialog({
api.companies.setOnboardingStep.call({ step }).catch(() => {}); 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 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 // 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 // draft that already exists would otherwise leave us stuck on the first
@@ -239,12 +324,10 @@ export default function OnboardingWizardDialog({
existingFiles: p.licenseFiles ?? [], existingFiles: p.licenseFiles ?? [],
})); }));
const titleHint = // The active step across the whole journey, driving the header + progress pill.
phase === "nationality" const activeStep: WizardStep = phase === "form" ? formStep : phase;
? "Where is your company registered?" const stepMeta = STEP_META[activeStep];
: phase === "role" const activeIdx = WIZARD_STEPS.indexOf(activeStep);
? "Tell us what your company does to get started."
: "Set up your company profile to finish.";
const formProps = { const formProps = {
documentSettingCode: documentSettingCode(effectiveNationality), documentSettingCode: documentSettingCode(effectiveNationality),
@@ -257,7 +340,7 @@ export default function OnboardingWizardDialog({
hideFirstStepBack: true, hideFirstStepBack: true,
initialStep: resumeFormStep, initialStep: resumeFormStep,
resyncOpen: opened, resyncOpen: opened,
onStepChange: persistStep, onStepChange: handleStepChange,
onSaveStep: saveStep, onSaveStep: saveStep,
rehydrate: profileQuery.data ?? null, rehydrate: profileQuery.data ?? null,
roleProfiles, roleProfiles,
@@ -279,63 +362,98 @@ export default function OnboardingWizardDialog({
keepMounted keepMounted
scrollAreaComponent={ScrollArea.Autosize} scrollAreaComponent={ScrollArea.Autosize}
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
styles={{
header: {
alignItems:"flex-start"
},
title: {
flex: 1
}
}}
title={ title={
<Stack gap={2}> <Stack gap="md">
<Text fz={20} fw={800} c="edr-text" className="tracking-tight"> <Box>
Complete your onboarding <Group gap="sm" mb={4}>
</Text> {stepMeta.icon}
<Text size="sm" c="edr-muted"> <Title order={3}>{stepMeta.title}</Title>
{titleHint} </Group>
</Text> <Text c="edr-muted" size="sm">
{stepMeta.description}
</Text>
</Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
</Stack> </Stack>
} }
> >
{phase === "nationality" ? ( <Stack gap="xl">
<Stack gap="lg">
<NationalitySelect value={nationality} onChange={setNationality} /> {phase === "nationality" ? (
<RoleContinueBar <Stack gap="lg">
disabled={!nationality} <NationalitySelect
onClick={handleNationalityContinue} value={nationality}
/> onChange={setNationality}
</Stack> embedded
) : phase === "role" ? ( />
<Stack gap="lg"> <Group justify="flex-end" pt="xs">
<OnboardingRoleSelect value={roles} onChange={setRoles} /> <Button
{startError && ( color="edr-green"
<Text size="sm" c="red"> onClick={handleNationalityContinue}
{startError} disabled={!nationality}
</Text> rightSection={<ArrowRight size={16} />}
)} >
<RoleContinueBar Continue
disabled={!rolesValid} </Button>
loading={startMutation.isPending} </Group>
onClick={handleRolesContinue} </Stack>
/> ) : phase === "role" ? (
</Stack> <Stack gap="lg">
) : ( <OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
<CompanyProfileForm {...formProps} /> {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> </Modal>
); );
} }
function RoleContinueBar({ /**
disabled, * Continuous progress pill: a single rounded track that fills left-to-right as
loading, * the user advances, with faint ticks marking each step boundary.
onClick, */
}: { function ProgressPill({ current, total }: { current: number; total: number }) {
disabled: boolean; const pct = total > 0 ? ((current + 1) / total) * 100 : 0;
loading?: boolean;
onClick: () => void;
}) {
return ( return (
<button <Box className="relative h-1.5 w-full overflow-hidden rounded-full bg-edr-border">
type="button" <Box
disabled={disabled || loading} className="absolute inset-y-0 left-0 rounded-full bg-[var(--mantine-color-edr-green-6)] transition-[width] duration-500 ease-out"
onClick={onClick} style={{ width: `${pct}%` }}
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" />
> </Box>
{loading ? "Setting up…" : "Continue"}
</button>
); );
} }

View File

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

View File

@@ -1,6 +1,5 @@
import { import {
Alert, Alert,
Box,
Button, Button,
Checkbox, Checkbox,
Divider, Divider,
@@ -10,7 +9,6 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -18,12 +16,6 @@ import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Building2,
CheckCircle2,
ChevronLeft,
FileText,
UploadCloud,
User,
UserCheck, UserCheck,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -559,7 +551,6 @@ export default function CompanyProfileForm({
"documents", "documents",
"additional", "additional",
]; ];
const totalSteps = stepOrder.length;
const currentIdx = stepOrder.indexOf(step); const currentIdx = stepOrder.indexOf(step);
/** Validate + persist the current step, returning whether we may advance. */ /** Validate + persist the current step, returning whether we may advance. */
@@ -617,79 +608,8 @@ export default function CompanyProfileForm({
// selection); otherwise always available. // selection); otherwise always available.
const showBack = !(hideFirstStepBack && step === "company"); 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 ( 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()}> <form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md"> <Stack gap="md">
{step === "company" && ( {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 { interface NationalitySelectProps {
value: CompanyNationality | null; value: CompanyNationality | null;
onChange: (next: CompanyNationality) => void; 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({ export default function NationalitySelect({
value, value,
onChange, onChange,
embedded = false,
}: NationalitySelectProps) { }: 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 ( return (
<Card padding="lg"> <Card padding="lg">
<Group gap="sm" mb="xs"> <Group gap="sm" mb="xs">
@@ -28,23 +52,7 @@ export default function NationalitySelect({
<Text c="edr-muted" size="sm" mb="lg"> <Text c="edr-muted" size="sm" mb="lg">
This determines the documents we'll ask you to provide. This determines the documents we'll ask you to provide.
</Text> </Text>
{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>
</Card> </Card>
); );
} }

View File

@@ -7,6 +7,8 @@ interface OnboardingRoleSelectProps {
/** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */ /** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */
value: string[]; value: string[];
onChange: (next: string[]) => void; 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({ export default function OnboardingRoleSelect({
value, value,
onChange, onChange,
embedded = false,
}: OnboardingRoleSelectProps) { }: OnboardingRoleSelectProps) {
const selected = new Set(value); const selected = new Set(value);
@@ -29,6 +32,23 @@ export default function OnboardingRoleSelect({
onChange([...next]); 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 ( return (
<Card padding="lg"> <Card padding="lg">
<Group gap="sm" mb="xs"> <Group gap="sm" mb="xs">
@@ -39,19 +59,7 @@ export default function OnboardingRoleSelect({
Pick any combination of Importer, Exporter and Freight Forwarder each Pick any combination of Importer, Exporter and Freight Forwarder each
is set up with its own business license. is set up with its own business license.
</Text> </Text>
{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>
</Card> </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 { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile"; import { api } from "@/services/api";
import type { import type {
CreateCompanyPayload, CompanyProfileInput,
CompanyProfileInput, CreateCompanyPayload,
} from "@/services/companies.service"; } 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"; import OnboardingRoleSelect from "./OnboardingRoleSelect";
export const COMPANY_PROFILE_SCHEMA = z.object({ export const COMPANY_PROFILE_SCHEMA = z.object({
@@ -143,9 +142,7 @@ export default function TabCompanyProfile({
value={selectedRoles} value={selectedRoles}
onChange={setSelectedRoles} onChange={setSelectedRoles}
/> />
) : ( ) : null}
profile && <CompanyRolesCard profile={profile} />
)}
{showForm && ( {showForm && (
<Card padding="lg"> <Card padding="lg">
<Group gap="sm" mb="xs"> <Group gap="sm" mb="xs">

View File

@@ -1,40 +1,40 @@
import { URL_CONSTANTS } from "@/constants/URLS"; 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 { client } from "@/utils/api";
import { ApiResponse } from "@edr/types"; import { ApiResponse } from "@edr/types";
import type {
AuthUser,
GenerateVerificationCodePayload,
LoginPayload,
LoginResponse,
OtpPayload,
OtpResponse,
SetPasswordPayload,
SignupPayload,
SignupResponse,
} from "@/types/auth";
export const authService = { export const authService = {
login: async (body: LoginPayload) => { login: async (body: LoginPayload) => {
const res = await client.post<ApiResponse<LoginResponse>>( const res = await client.post<LoginResponse>(
URL_CONSTANTS.AUTH.LOGIN, URL_CONSTANTS.AUTH.LOGIN,
body, body,
); );
return res.data.data; return res.data;
}, },
createUser: async (body: SignupPayload) => { createUser: async (body: SignupPayload) => {
const res = await client.post<ApiResponse<SignupResponse>>( const res = await client.post<SignupResponse & ApiResponse<void>> (
URL_CONSTANTS.USERS.SIGN_UP, URL_CONSTANTS.USERS.SIGN_UP,
body, body,
); );
return res.data.data; return res.data;
}, },
getMyInfo: async () => { getMyInfo: async () => {
const res = await client.get<ApiResponse<AuthUser>>( const res = await client.get<AuthUser>(
URL_CONSTANTS.USERS.ME, URL_CONSTANTS.USERS.ME,
); );
return res.data.data; return res.data;
}, },
generateVerificationCode: async (body: GenerateVerificationCodePayload) => { 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 { Readable } from "stream";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { map } from "rxjs/operators"; import { map } from "rxjs/operators";
import { flatResponseModules } from "../constants/apiModules";
export interface StandardResponse<T> { export interface StandardResponse<T> {
success: true; success: true;
@@ -33,6 +34,25 @@ export class ResponseTransformInterceptor<T> implements NestInterceptor<
) { ) {
return data; 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 { return {
success: true, success: true,