mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -285,6 +285,7 @@ export class CompaniesService {
|
||||
async findCompanyById(id: string): Promise<Company> {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||
return company;
|
||||
}
|
||||
|
||||
|
||||
@@ -2152,6 +2152,7 @@ export class TrainSchedulingService {
|
||||
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
|
||||
status: sb.booking?.status ?? null,
|
||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||
freightType: sb.booking?.freightType ?? null,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview --port 5183",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,20 +21,6 @@ import type {
|
||||
BookingListSummaryTabs,
|
||||
} from "@/services/bookings.service";
|
||||
|
||||
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
|
||||
const PIPELINE_STAGES: Array<{
|
||||
key: keyof BookingListSummaryTabs;
|
||||
label: string;
|
||||
color: string;
|
||||
}> = [
|
||||
{ key: "intake", label: "Intake", color: "#38bdf8" },
|
||||
{ key: "in_approval", label: "Approval", color: "#f59e0b" },
|
||||
{ key: "approved_contract", label: "Contract", color: "#8b5cf6" },
|
||||
{ key: "payment", label: "Payment", color: "#fb923c" },
|
||||
{ key: "operations", label: "Operations", color: "#14b8a6" },
|
||||
{ key: "completed", label: "Completed", color: "#22c55e" },
|
||||
];
|
||||
|
||||
const CARD_STYLE = {
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
@@ -62,7 +48,12 @@ export function BookingRequestsHeader({
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button color="edr-green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
leftSection={<Plus size={18} />}
|
||||
onClick={onCreate}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
@@ -90,7 +81,9 @@ export function BookingRequestsHeader({
|
||||
label="Needs action"
|
||||
value={val(metrics?.needsAction)}
|
||||
hint="Submitted or pending"
|
||||
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
|
||||
ratio={
|
||||
metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0
|
||||
}
|
||||
accent="orange"
|
||||
/>
|
||||
<HeroStat
|
||||
@@ -137,12 +130,18 @@ function HeroStat({
|
||||
accent?: OverviewAccent;
|
||||
variant?: "area" | "line";
|
||||
}) {
|
||||
const [, accentDeep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const [, accentDeep] =
|
||||
overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
|
||||
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
|
||||
const pct =
|
||||
ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box
|
||||
@@ -161,7 +160,13 @@ function HeroStat({
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
|
||||
<Text
|
||||
fw={800}
|
||||
size="24px"
|
||||
lh={1.05}
|
||||
style={{ color: "#0f172a" }}
|
||||
truncate
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||
@@ -179,66 +184,15 @@ function HeroStat({
|
||||
</Group>
|
||||
|
||||
{pct == null ? (
|
||||
<MiniSparkline variant={variant} accent={accent} baseline={0.5} seed={label} height={22} />
|
||||
<MiniSparkline
|
||||
variant={variant}
|
||||
accent={accent}
|
||||
baseline={0.5}
|
||||
seed={label}
|
||||
height={22}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 }));
|
||||
const total = segments.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="lg" style={CARD_STYLE}>
|
||||
<Group justify="space-between" mb={10}>
|
||||
<Text size="sm" fw={700} style={{ color: "#0f172a" }}>
|
||||
Booking pipeline
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} active
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
height: 14,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
background: "var(--mantine-color-gray-2)",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{total > 0 ? (
|
||||
segments.map((s) =>
|
||||
s.count > 0 ? (
|
||||
<Box
|
||||
key={s.key}
|
||||
title={`${s.label}: ${s.count}`}
|
||||
style={{ width: `${(s.count / total) * 100}%`, background: s.color, transition: "width 200ms ease" }}
|
||||
/>
|
||||
) : null,
|
||||
)
|
||||
) : (
|
||||
<Box style={{ width: "100%" }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group gap="md" mt={10} wrap="wrap">
|
||||
{segments.map((s) => (
|
||||
<Group key={s.key} gap={6} wrap="nowrap">
|
||||
<Box style={{ width: 9, height: 9, borderRadius: 3, background: s.color }} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={700} style={{ color: "#0f172a" }}>
|
||||
{s.count}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -77,7 +85,12 @@ export function BookingRequestHero({
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: 1, color: "#B26C09" }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "#B26C09" }}
|
||||
>
|
||||
Booking reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
@@ -99,8 +112,14 @@ export function BookingRequestHero({
|
||||
|
||||
<Group gap="lg" mt={4}>
|
||||
<MetaItem icon={Building2} text={customerLabel} strong />
|
||||
<MetaItem icon={Calendar} text={`Scheduled ${booking.scheduledDate}`} />
|
||||
<MetaItem icon={Clock} text={`Created ${formatDate(booking.createdAt)}`} />
|
||||
<MetaItem
|
||||
icon={Calendar}
|
||||
text={`Scheduled ${booking.scheduledDate}`}
|
||||
/>
|
||||
<MetaItem
|
||||
icon={Clock}
|
||||
text={`Created ${formatDate(booking.createdAt)}`}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -110,7 +129,10 @@ export function BookingRequestHero({
|
||||
radius="lg"
|
||||
p={4}
|
||||
maw={640}
|
||||
style={{ background: "var(--mantine-color-gray-0)", border: "1px solid var(--mantine-color-gray-2)" }}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<NextStepBanner nextStep={booking.nextStep} />
|
||||
</Paper>
|
||||
@@ -120,13 +142,22 @@ export function BookingRequestHero({
|
||||
<HeroTile
|
||||
icon={Wallet}
|
||||
label="Total value"
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: 2,
|
||||
},
|
||||
)}`}
|
||||
hint={booking.paymentStatus}
|
||||
accent="edr-green"
|
||||
/>
|
||||
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
|
||||
<HeroTile
|
||||
icon={Weight}
|
||||
label="Cargo weight"
|
||||
value={`${weight} T`}
|
||||
hint="VGM total"
|
||||
accent="blue"
|
||||
/>
|
||||
<HeroTile
|
||||
icon={ContainerIcon}
|
||||
label="Containers"
|
||||
@@ -195,7 +226,13 @@ function HeroTile({
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: 0.4 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
|
||||
|
||||
@@ -138,6 +138,8 @@ export interface BookingDetailView {
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
pnrCode?: string | null;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartner?: (BookingNamedRefView & { reference?: string }) | null;
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
paymentDeadline?: string | null;
|
||||
createdAt: string;
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
|
||||
import { FLEET_SELECT_NONE, type FleetFormFieldDef } from "@/pages/fleet/config/resources";
|
||||
import {
|
||||
FLEET_SELECT_NONE,
|
||||
type FleetFormFieldDef,
|
||||
} from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
export interface FleetFormDialogProps {
|
||||
@@ -87,12 +89,19 @@ const FleetFormDialog = ({
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
|
||||
if (field.required && (stringValue === "" || stringValue === FLEET_SELECT_NONE)) {
|
||||
if (
|
||||
field.required &&
|
||||
(stringValue === "" || stringValue === FLEET_SELECT_NONE)
|
||||
) {
|
||||
next[field.name] = `${field.label} is required`;
|
||||
}
|
||||
|
||||
// Validate date format (YYYY-MM-DD) - DatePicker ensures this
|
||||
if (field.type === "date" && stringValue && stringValue !== FLEET_SELECT_NONE) {
|
||||
if (
|
||||
field.type === "date" &&
|
||||
stringValue &&
|
||||
stringValue !== FLEET_SELECT_NONE
|
||||
) {
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(stringValue)) {
|
||||
next[field.name] = `${field.label} must be a valid date`;
|
||||
@@ -115,7 +124,8 @@ const FleetFormDialog = ({
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => {
|
||||
if (value === FLEET_SELECT_NONE || value === "") return [key, undefined];
|
||||
if (value === FLEET_SELECT_NONE || value === "")
|
||||
return [key, undefined];
|
||||
return [key, value];
|
||||
})
|
||||
.filter(([, value]) => value !== undefined),
|
||||
@@ -133,20 +143,34 @@ const FleetFormDialog = ({
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
data={field.options ?? []}
|
||||
value={value == null || value === "" ? (field.noneOption ? FLEET_SELECT_NONE : null) : String(value)}
|
||||
value={
|
||||
value == null || value === ""
|
||||
? field.noneOption
|
||||
? FLEET_SELECT_NONE
|
||||
: null
|
||||
: String(value)
|
||||
}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
disabled={selectOptionsLoading}
|
||||
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
rightSection={
|
||||
selectOptionsLoading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "multiselect") {
|
||||
const arrayValue = Array.isArray(value) ? value : (typeof value === "string" && value ? [value] : []);
|
||||
const arrayValue = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string" && value
|
||||
? [value]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
@@ -162,7 +186,11 @@ const FleetFormDialog = ({
|
||||
searchable
|
||||
clearable
|
||||
disabled={selectOptionsLoading}
|
||||
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
rightSection={
|
||||
selectOptionsLoading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -194,7 +222,10 @@ const FleetFormDialog = ({
|
||||
placeholder={field.placeholder}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value }))
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: e.currentTarget?.value,
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
minRows={3}
|
||||
@@ -221,7 +252,7 @@ const FleetFormDialog = ({
|
||||
disabled={field.disabled}
|
||||
description={field.description || "Select a date"}
|
||||
rightSection={
|
||||
<ActionIcon size="sm" variant="subtle" color="green" pointer={false}>
|
||||
<ActionIcon size="sm" variant="subtle" color="green">
|
||||
<Calendar size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
@@ -250,7 +281,10 @@ const FleetFormDialog = ({
|
||||
description={field.description}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value }))
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: e.currentTarget?.value,
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
disabled={field.disabled}
|
||||
@@ -276,7 +310,11 @@ const FleetFormDialog = ({
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="edr-green" loading={isSubmitting} onClick={handleSubmit}>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -285,4 +323,4 @@ const FleetFormDialog = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetFormDialog;
|
||||
export default FleetFormDialog;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react";
|
||||
import { ActionIcon, Group, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
@@ -37,30 +37,39 @@ const FleetRecordActions = ({
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<Tooltip label="Actions">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<MoreVertical size={16} strokeWidth={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{isVehicle && onAssignDriver ? (
|
||||
<MenuItem onClick={() => onAssignDriver(record)} leftSection={<Users size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={() => onAssignDriver(record)}
|
||||
leftSection={<Users size={14} strokeWidth={2} />}
|
||||
>
|
||||
Assign Driver
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={() => onEdit(record)} leftSection={<Edit2 size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{showDetail ? (
|
||||
<MenuItem onClick={handleDetail} leftSection={<Eye size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={handleDetail}
|
||||
leftSection={<Eye size={14} strokeWidth={2} />}
|
||||
>
|
||||
View details
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem color="red" onClick={() => onRemove(record)} leftSection={<Trash2 size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
</Menu.Dropdown>
|
||||
@@ -72,30 +81,39 @@ const FleetRecordActions = ({
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<Tooltip label="Actions">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<MoreVertical size={16} strokeWidth={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{isVehicle && onAssignDriver ? (
|
||||
<MenuItem onClick={() => onAssignDriver(record)} leftSection={<Users size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={() => onAssignDriver(record)}
|
||||
leftSection={<Users size={14} strokeWidth={2} />}
|
||||
>
|
||||
Assign Driver
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={() => onEdit(record)} leftSection={<Edit2 size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{showDetail ? (
|
||||
<MenuItem onClick={handleDetail} leftSection={<Eye size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={handleDetail}
|
||||
leftSection={<Eye size={14} strokeWidth={2} />}
|
||||
>
|
||||
View details
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem color="red" onClick={() => onRemove(record)} leftSection={<Trash2 size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
</Menu.Dropdown>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||
import { Stack, Group, Text, Card, SimpleGrid, Skeleton } from "@mantine/core";
|
||||
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
@@ -85,8 +85,15 @@ const RuleEngineCardGrid = ({
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
|
||||
<Text size="lg" fw={600} c="red">Failed to load data</Text>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
p="xl"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
<Text size="lg" fw={600} c="red">
|
||||
Failed to load data
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Please refresh the page or try again later.
|
||||
</Text>
|
||||
@@ -118,8 +125,15 @@ const RuleEngineCardGrid = ({
|
||||
|
||||
if (status === "success" && rows.length === 0) {
|
||||
return (
|
||||
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
|
||||
<Text size="lg" fw={600}>{emptyMessage}</Text>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
p="xl"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
<Text size="lg" fw={600}>
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Try adjusting your search or add a new record.
|
||||
</Text>
|
||||
@@ -197,7 +211,10 @@ const RuleEngineCardGrid = ({
|
||||
{subtitle && (
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{presentation.subtitleKey === "stepOrder" ? "Step" : "Type"}:
|
||||
{presentation.subtitleKey === "stepOrder"
|
||||
? "Step"
|
||||
: "Type"}
|
||||
:
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{subtitle}
|
||||
@@ -207,7 +224,12 @@ const RuleEngineCardGrid = ({
|
||||
{presentation.detailColumns.map((col) => {
|
||||
const displayValue = getSmartValue(record, col.accessorKey);
|
||||
return (
|
||||
<Group key={col.id} justify="space-between" gap="xs" align="flex-start">
|
||||
<Group
|
||||
key={col.id}
|
||||
justify="space-between"
|
||||
gap="xs"
|
||||
align="flex-start"
|
||||
>
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{col.header}:
|
||||
</Text>
|
||||
@@ -220,14 +242,19 @@ const RuleEngineCardGrid = ({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="xs" pt="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap="xs"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<RuleEngineRecordActions
|
||||
record={record}
|
||||
config={config}
|
||||
layout="compact"
|
||||
readOnly={readOnly}
|
||||
onEdit={onEdit ?? (() => {})}
|
||||
onDelete={onDelete ?? (() => {})}
|
||||
onEdit={onEdit ?? (() => { })}
|
||||
onDelete={onDelete ?? (() => { })}
|
||||
onViewChain={onViewChain}
|
||||
onSubmitRate={onSubmitRate}
|
||||
onApproveRate={onApproveRate}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||
import { Group, Pagination, Select, Text } from "@mantine/core";
|
||||
|
||||
export interface RuleEngineListFooterProps {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
|
||||
export interface BookingDetailData {
|
||||
bookingId: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||
|
||||
interface InteractiveTrainConsistProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Group, Modal, Stack, Text, Badge } from "@mantine/core";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
type WagonWithAllocation = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type WagonWithAllocation = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
|
||||
interface RemoveBookingModalProps {
|
||||
opened: boolean;
|
||||
@@ -21,7 +21,6 @@ export const RemoveBookingModal = ({
|
||||
if (!wagon || !wagon.allocations?.[0]) return null;
|
||||
|
||||
const allocation = wagon.allocations[0];
|
||||
const booking = allocation.booking;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
|
||||
@@ -32,12 +31,12 @@ export const RemoveBookingModal = ({
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<strong>Reference:</strong> {booking?.reference || "N/A"}
|
||||
<strong>Reference:</strong> {allocation.bookingReference || "N/A"}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Freight Type:</strong>{" "}
|
||||
<Badge size="sm" variant="light">
|
||||
{booking?.freightType || "N/A"}
|
||||
{allocation.loadType || "N/A"}
|
||||
</Badge>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
|
||||
interface TrainConsistViewProps {
|
||||
scheduleDetail: TrainScheduleDetail;
|
||||
@@ -103,9 +103,9 @@ export const TrainConsistView = ({
|
||||
<Stack gap="md" style={{ width: "100%" }}>
|
||||
<TrainStatsBar
|
||||
weightUsed={weightUsed}
|
||||
weightMax={scheduleDetail.locomotive?.maxWeightTons ?? trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||
lengthUsed={lengthUsed}
|
||||
lengthMax={scheduleDetail.locomotive?.maxLengthMeters ?? trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||
wagonCount={wagons.length}
|
||||
wagonMax={maxWagons}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
|
||||
interface WagonCardProps {
|
||||
wagon: Wagon;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { getNextInventoryAction } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import {
|
||||
INVENTORY_NEXT_ACTION,
|
||||
type InventoryAction,
|
||||
type WarehouseInventoryItem,
|
||||
} from "@/types/warehouse";
|
||||
import { InventoryStatusBadge } from "./badges";
|
||||
import { formatDate, formatNumber, humanizeEnum } from "./options";
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -27,21 +30,21 @@ interface WarehouseInventoryTableProps {
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
if (item.containerId) return { label: 'Container', color: 'blue' };
|
||||
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
|
||||
if (item.goodsId) return { label: 'Goods', color: 'orange' };
|
||||
return { label: '—', color: 'gray' };
|
||||
if (item.containerId) return { label: "Container", color: "blue" };
|
||||
if (item.cargoId) return { label: "Cargo", color: "grape" };
|
||||
if (item.goodsId) return { label: "Goods", color: "orange" };
|
||||
return { label: "—", color: "gray" };
|
||||
};
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
'ready-for-pickup': 'orange',
|
||||
release: 'yellow',
|
||||
deliver: 'green',
|
||||
store: "blue",
|
||||
reserve: "grape",
|
||||
"ready-for-loading": "cyan",
|
||||
load: "teal",
|
||||
dispatch: "edr-green",
|
||||
"ready-for-pickup": "orange",
|
||||
release: "yellow",
|
||||
deliver: "green",
|
||||
};
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
@@ -52,18 +55,12 @@ export function WarehouseInventoryTable({
|
||||
onHistory,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'booking',
|
||||
header: 'Booking',
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) =>
|
||||
row.original.bookingId ? (
|
||||
<Tooltip label={row.original.bookingId} withArrow>
|
||||
@@ -78,16 +75,28 @@ export function WarehouseInventoryTable({
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'facility',
|
||||
header: 'Facility',
|
||||
cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—',
|
||||
id: "facility",
|
||||
header: "Facility",
|
||||
cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—",
|
||||
},
|
||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
|
||||
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.code ?? '—' },
|
||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
|
||||
{
|
||||
id: 'item',
|
||||
header: 'Item',
|
||||
id: "warehouse",
|
||||
header: "Warehouse",
|
||||
cell: ({ row }) => row.original.warehouse?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "yard",
|
||||
header: "Yard",
|
||||
cell: ({ row }) => row.original.yard?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "zone",
|
||||
header: "Zone",
|
||||
cell: ({ row }) => row.original.zone?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
header: "Item",
|
||||
cell: ({ row }) => {
|
||||
const kind = itemKind(row.original);
|
||||
return (
|
||||
@@ -97,27 +106,44 @@ export function WarehouseInventoryTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
|
||||
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
|
||||
id: "qty",
|
||||
header: "Qty",
|
||||
cell: ({ row }) => formatNumber(row.original.quantity),
|
||||
},
|
||||
{
|
||||
id: 'arrived',
|
||||
header: 'Arrived',
|
||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
|
||||
id: "weight",
|
||||
header: "Weight",
|
||||
cell: ({ row }) => formatNumber(row.original.weight),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<InventoryStatusBadge status={row.original.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "arrived",
|
||||
header: "Arrived",
|
||||
cell: ({ row }) => (
|
||||
<Text size="xs">{formatDate(row.original.arrivedAt)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = INVENTORY_NEXT_ACTION[item.status];
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<Group
|
||||
gap="xs"
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{nextAction && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
@@ -126,32 +152,48 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{humanizeEnum(nextAction.replace(/-/g, "_"))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
{item.status !== "DISPATCHED" && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onMove(item)}
|
||||
>
|
||||
<ArrowRightLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onInspect && (
|
||||
<Tooltip label="Inspection / Report" withArrow>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => onInspect(item)}
|
||||
>
|
||||
<ClipboardList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFeePreview && (
|
||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
onClick={() => onFeePreview(item)}
|
||||
>
|
||||
<Coins size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onHistory(item)}
|
||||
>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,53 +1,80 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import type { InventoryStatus, WarehouseStatus, WarehouseType } from '@/types/warehouse';
|
||||
import type {
|
||||
InventoryStatus,
|
||||
WarehouseStatus,
|
||||
WarehouseType,
|
||||
} from "@/types/warehouse";
|
||||
|
||||
const humanize = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.split("_")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
.join(" ");
|
||||
|
||||
const badgeStyle = {
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.04em',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.04em",
|
||||
whiteSpace: "nowrap" as const,
|
||||
};
|
||||
|
||||
export function WarehouseTypeBadge({ type }: { type: WarehouseType }) {
|
||||
const color = type === 'CLOSED_WAREHOUSE' ? 'indigo' : 'teal';
|
||||
const color = type === "CLOSED_WAREHOUSE" ? "indigo" : "teal";
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{humanize(type)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
||||
const color = status === 'ACTIVE' ? 'edr-green' : 'gray';
|
||||
const color = status === "ACTIVE" ? "edr-green" : "gray";
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" tt="uppercase" fw={600} style={badgeStyle}>
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
UNLOADED: 'indigo',
|
||||
RECEIVED: 'yellow',
|
||||
STORED: 'blue',
|
||||
RESERVED: 'grape',
|
||||
READY_FOR_LOADING: 'cyan',
|
||||
LOADED: 'teal',
|
||||
DISPATCHED: 'edr-green',
|
||||
DELIVERED: 'edr-green',
|
||||
UNLOADED: "indigo",
|
||||
RECEIVED: "yellow",
|
||||
STORED: "blue",
|
||||
RESERVED: "grape",
|
||||
READY_FOR_LOADING: "cyan",
|
||||
LOADED: "teal",
|
||||
READY_FOR_PICKUP: "teal",
|
||||
DISPATCHED: "edr-green",
|
||||
DELIVERED: "edr-green",
|
||||
};
|
||||
|
||||
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
|
||||
const color = inventoryStatusColor[status] ?? 'gray';
|
||||
const color = inventoryStatusColor[status] ?? "gray";
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{humanize(status)}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,11 @@ export interface BookingActionDef {
|
||||
|
||||
export type BookingActionContext = Pick<
|
||||
BookingDetail,
|
||||
"status" | "paymentCurrency" | "approvalSteps" | "reference" | "schedulingStatus"
|
||||
| "status"
|
||||
| "paymentCurrency"
|
||||
| "approvalSteps"
|
||||
| "reference"
|
||||
| "schedulingStatus"
|
||||
>;
|
||||
|
||||
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
|
||||
@@ -67,7 +71,9 @@ const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
|
||||
"",
|
||||
]);
|
||||
|
||||
export function canAllocateBooking(booking: Pick<BookingDetail, "status" | "schedulingStatus">) {
|
||||
export function canAllocateBooking(
|
||||
booking: Pick<BookingDetail, "status" | "schedulingStatus">,
|
||||
) {
|
||||
return (
|
||||
booking.status === "PAID" &&
|
||||
ALLOCATABLE_SCHEDULING_STATUSES.has(booking.schedulingStatus ?? undefined)
|
||||
@@ -286,10 +292,18 @@ export function getBookingActions(
|
||||
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
|
||||
break;
|
||||
case "FULLY_EXECUTED":
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, label: "View executed contract", primary: true }];
|
||||
actions = [
|
||||
{
|
||||
...VIEW_CONTRACT_ACTION,
|
||||
label: "View executed contract",
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
break;
|
||||
case "PAID":
|
||||
if (canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })) {
|
||||
if (
|
||||
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
|
||||
) {
|
||||
actions = [
|
||||
{
|
||||
id: "allocateBooking",
|
||||
@@ -384,7 +398,7 @@ export function listRowHasActions(
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: "",
|
||||
approvalSteps: row.approvalSteps ?? undefined,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
schedulingStatus: row.status,
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -104,7 +104,6 @@ const BookingDetailPage = () => {
|
||||
const approvedCount = approvalSteps.filter(
|
||||
(s) => s.status === "APPROVED",
|
||||
).length;
|
||||
const totalSteps = approvalSteps.length;
|
||||
|
||||
return (
|
||||
<div style={detailStyles.page}>
|
||||
|
||||
@@ -34,7 +34,10 @@ import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import {
|
||||
useBookingDetail,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Signature / generated-contract files are surfaced on the contract page, not
|
||||
@@ -49,7 +52,13 @@ const SIGNATURE_FILE_CODES = new Set([
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
|
||||
const {
|
||||
data: booking,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
|
||||
const handleDownloadFile = async (file: BookingFileView) => {
|
||||
@@ -79,7 +88,13 @@ export default function BookingRequestDetailPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Container size="sm" py="xl">
|
||||
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
||||
<Paper
|
||||
radius="md"
|
||||
withBorder
|
||||
p="xl"
|
||||
ta="center"
|
||||
style={detailStyles.card}
|
||||
>
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
@@ -137,75 +152,83 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
<BookingRequestHero
|
||||
booking={booking}
|
||||
customerLabel={row.customerLabel}
|
||||
onBack={() => navigate("/dashboard/booking-requests")}
|
||||
onRefresh={() => refetch()}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
<BookingRequestHero
|
||||
booking={booking}
|
||||
customerLabel={row.customerLabel}
|
||||
onBack={() => navigate("/dashboard/booking-requests")}
|
||||
onRefresh={() => refetch()}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
|
||||
<BookingWorkflowStepper
|
||||
status={booking.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
titleColor={statusMeta.color}
|
||||
/>
|
||||
<BookingWorkflowStepper
|
||||
status={booking.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
titleColor={statusMeta.color}
|
||||
/>
|
||||
|
||||
{booking.status === "PENDING_CONSOLIDATION" && (
|
||||
<ConsolidationWaitingBanner bookingId={booking.id} />
|
||||
)}
|
||||
{booking.status === "PENDING_CONSOLIDATION" && (
|
||||
<ConsolidationWaitingBanner bookingId={booking.id} />
|
||||
)}
|
||||
|
||||
<Grid gutter="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingRouteServiceCard
|
||||
booking={booking}
|
||||
originLabel={row.originLabel}
|
||||
destinationLabel={row.destinationLabel}
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingRouteServiceCard
|
||||
booking={booking}
|
||||
originLabel={row.originLabel}
|
||||
destinationLabel={row.destinationLabel}
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
<BookingDocumentsCard
|
||||
files={(booking.files ?? []).filter(
|
||||
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||
)}
|
||||
<BookingDocumentsCard
|
||||
files={(booking.files ?? []).filter(
|
||||
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||
)}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} />
|
||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<WarehouseInfoCard
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
/>
|
||||
<BookingActionsToolbar
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/booking-requests/${booking.id}/contract`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -662,7 +662,7 @@ export default function NewBookingPage() {
|
||||
placeholder="Select bulk cargo type"
|
||||
data={cargoData}
|
||||
value={cargoTypeId}
|
||||
onChange={setCargoTypeId}
|
||||
onChange={(value) => setCargoTypeId(value as string | null)}
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
@@ -93,7 +93,7 @@ const OverviewPage = () => {
|
||||
|
||||
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
|
||||
if (!summary?.kpis) return 0;
|
||||
const group = summary.kpis[tab.kpiKey] as Record<string, number>;
|
||||
const group = summary.kpis[tab.kpiKey] as unknown as Record<string, number>;
|
||||
return group[tab.metricKey] ?? 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -140,30 +140,38 @@ const PositionTypesPage = () => {
|
||||
const [loadingOrganizations, setLoadingOrganizations] = useState(true);
|
||||
const [loadingUnits, setLoadingUnits] = useState(false);
|
||||
const [loadingPositionTypes, setLoadingPositionTypes] = useState(false);
|
||||
const [loadingPermissionsCatalog, setLoadingPermissionsCatalog] = useState(false);
|
||||
const [loadingPermissionsCatalog, setLoadingPermissionsCatalog] =
|
||||
useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedPositionType, setSelectedPositionType] = useState<PositionTypeRecord | null>(null);
|
||||
const [positionTypePermissions, setPositionTypePermissions] = useState<PermissionRecord[]>([]);
|
||||
const [selectedPositionType, setSelectedPositionType] =
|
||||
useState<PositionTypeRecord | null>(null);
|
||||
const [allPermissions, setAllPermissions] = useState<PermissionRecord[]>([]);
|
||||
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
||||
const [permissionsError, setPermissionsError] = useState<string | null>(null);
|
||||
const [permissionSearch, setPermissionSearch] = useState("");
|
||||
const [selectedPermissionIds, setSelectedPermissionIds] = useState<string[]>([]);
|
||||
const [selectedPermissionIds, setSelectedPermissionIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||
const [createPermissionSearch, setCreatePermissionSearch] = useState("");
|
||||
const [createPermissionIds, setCreatePermissionIds] = useState<string[]>([]);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState<PositionTypeEditFormState>(emptyEditForm);
|
||||
const [editForm, setEditForm] =
|
||||
useState<PositionTypeEditFormState>(emptyEditForm);
|
||||
|
||||
const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin"));
|
||||
const isSuperAdmin = Boolean(
|
||||
user?.roles?.some((role) => role.key === "super_admin"),
|
||||
);
|
||||
const allowedOrgIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(user?.employee ?? [])
|
||||
.map((employee) => employee.organizationId)
|
||||
.filter((organizationId): organizationId is string => Boolean(organizationId)),
|
||||
.filter((organizationId): organizationId is string =>
|
||||
Boolean(organizationId),
|
||||
),
|
||||
),
|
||||
[user?.employee],
|
||||
);
|
||||
@@ -173,14 +181,21 @@ const PositionTypesPage = () => {
|
||||
return organizations;
|
||||
}
|
||||
|
||||
return organizations.filter((organization) => allowedOrgIds.has(organization.id));
|
||||
return organizations.filter((organization) =>
|
||||
allowedOrgIds.has(organization.id),
|
||||
);
|
||||
}, [allowedOrgIds, isSuperAdmin, organizations]);
|
||||
|
||||
const selectedOrganization =
|
||||
visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null;
|
||||
visibleOrganizations.find(
|
||||
(organization) => organization.id === selectedOrgId,
|
||||
) ?? null;
|
||||
const selectedUnit = units.find((unit) => unit.id === selectedUnitId) ?? null;
|
||||
const availableCopySources = useMemo(
|
||||
() => positionTypes.filter((positionType) => positionType.id !== selectedPositionType?.id),
|
||||
() =>
|
||||
positionTypes.filter(
|
||||
(positionType) => positionType.id !== selectedPositionType?.id,
|
||||
),
|
||||
[positionTypes, selectedPositionType?.id],
|
||||
);
|
||||
const filteredPermissions = useMemo(() => {
|
||||
@@ -191,8 +206,13 @@ const PositionTypesPage = () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
const label = getLocaleLabel(permission.name, permission.key).toLowerCase();
|
||||
return label.includes(query) || permission.key.toLowerCase().includes(query);
|
||||
const label = getLocaleLabel(
|
||||
permission.name,
|
||||
permission.key,
|
||||
).toLowerCase();
|
||||
return (
|
||||
label.includes(query) || permission.key.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [allPermissions, permissionSearch]);
|
||||
const filteredCreatePermissions = useMemo(() => {
|
||||
@@ -203,18 +223,29 @@ const PositionTypesPage = () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
const label = getLocaleLabel(permission.name, permission.key).toLowerCase();
|
||||
return label.includes(query) || permission.key.toLowerCase().includes(query);
|
||||
const label = getLocaleLabel(
|
||||
permission.name,
|
||||
permission.key,
|
||||
).toLowerCase();
|
||||
return (
|
||||
label.includes(query) || permission.key.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [allPermissions, createPermissionSearch]);
|
||||
const allFilteredPermissionIds = filteredPermissions.map((permission) => permission.id);
|
||||
const allFilteredCreatePermissionIds = filteredCreatePermissions.map((permission) => permission.id);
|
||||
const allFilteredPermissionIds = filteredPermissions.map(
|
||||
(permission) => permission.id,
|
||||
);
|
||||
const allFilteredCreatePermissionIds = filteredCreatePermissions.map(
|
||||
(permission) => permission.id,
|
||||
);
|
||||
const areAllFilteredPermissionsSelected =
|
||||
allFilteredPermissionIds.length > 0 &&
|
||||
allFilteredPermissionIds.every((id) => selectedPermissionIds.includes(id));
|
||||
const areAllFilteredCreatePermissionsSelected =
|
||||
allFilteredCreatePermissionIds.length > 0 &&
|
||||
allFilteredCreatePermissionIds.every((id) => createPermissionIds.includes(id));
|
||||
allFilteredCreatePermissionIds.every((id) =>
|
||||
createPermissionIds.includes(id),
|
||||
);
|
||||
|
||||
const loadPositionTypes = async (unitId: string) => {
|
||||
const response = await api.get<ListResponse<PositionTypeRecord>>(
|
||||
@@ -247,7 +278,8 @@ const PositionTypesPage = () => {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<OrganizationRecord>>("/organizations");
|
||||
const response =
|
||||
await api.get<ListResponse<OrganizationRecord>>("/organizations");
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
@@ -261,7 +293,7 @@ const PositionTypesPage = () => {
|
||||
|
||||
setErrorMessage(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load organizations."
|
||||
? (error.response?.data?.message ?? "Unable to load organizations.")
|
||||
: "Unable to load organizations.",
|
||||
);
|
||||
} finally {
|
||||
@@ -285,12 +317,15 @@ const PositionTypesPage = () => {
|
||||
setLoadingPermissionsCatalog(true);
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<PermissionRecord>>("/permissions", {
|
||||
params: {
|
||||
skip: 0,
|
||||
take: 2000,
|
||||
const response = await api.get<ListResponse<PermissionRecord>>(
|
||||
"/permissions",
|
||||
{
|
||||
params: {
|
||||
skip: 0,
|
||||
take: 2000,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
@@ -326,7 +361,12 @@ const PositionTypesPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) {
|
||||
if (
|
||||
selectedOrgId &&
|
||||
visibleOrganizations.some(
|
||||
(organization) => organization.id === selectedOrgId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,7 +390,9 @@ const PositionTypesPage = () => {
|
||||
setPositionTypes([]);
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<UnitRecord>>(`/units/list/${selectedOrgId}`);
|
||||
const response = await api.get<ListResponse<UnitRecord>>(
|
||||
`/units/list/${selectedOrgId}`,
|
||||
);
|
||||
const items = getItems(response.data);
|
||||
|
||||
if (!isMounted) {
|
||||
@@ -367,7 +409,7 @@ const PositionTypesPage = () => {
|
||||
setUnits([]);
|
||||
setErrorMessage(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load units."
|
||||
? (error.response?.data?.message ?? "Unable to load units.")
|
||||
: "Unable to load units.",
|
||||
);
|
||||
} finally {
|
||||
@@ -412,7 +454,8 @@ const PositionTypesPage = () => {
|
||||
setPositionTypes([]);
|
||||
setErrorMessage(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load position types."
|
||||
? (error.response?.data?.message ??
|
||||
"Unable to load position types.")
|
||||
: "Unable to load position types.",
|
||||
);
|
||||
} finally {
|
||||
@@ -431,7 +474,6 @@ const PositionTypesPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPositionType) {
|
||||
setPositionTypePermissions([]);
|
||||
setSelectedPermissionIds([]);
|
||||
setEditForm(emptyEditForm);
|
||||
setPermissionsError(null);
|
||||
@@ -453,23 +495,24 @@ const PositionTypesPage = () => {
|
||||
setPermissionsError(null);
|
||||
|
||||
try {
|
||||
const items = await loadPermissionsForPositionType(selectedPositionType.id);
|
||||
const items = await loadPermissionsForPositionType(
|
||||
selectedPositionType.id,
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPositionTypePermissions(items);
|
||||
setSelectedPermissionIds(items.map((permission) => permission.id));
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPositionTypePermissions([]);
|
||||
setPermissionsError(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to load position type permissions."
|
||||
? (error.response?.data?.message ??
|
||||
"Unable to load position type permissions.")
|
||||
: "Unable to load position type permissions.",
|
||||
);
|
||||
} finally {
|
||||
@@ -499,13 +542,16 @@ const PositionTypesPage = () => {
|
||||
setPositionTypes(items);
|
||||
|
||||
if (selectedPositionType) {
|
||||
const nextSelected = items.find((item) => item.id === selectedPositionType.id) ?? selectedPositionType;
|
||||
const nextSelected =
|
||||
items.find((item) => item.id === selectedPositionType.id) ??
|
||||
selectedPositionType;
|
||||
setSelectedPositionType(nextSelected);
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to refresh position types."
|
||||
? (error.response?.data?.message ??
|
||||
"Unable to refresh position types.")
|
||||
: "Unable to refresh position types.",
|
||||
);
|
||||
} finally {
|
||||
@@ -522,7 +568,10 @@ const PositionTypesPage = () => {
|
||||
};
|
||||
|
||||
const handleSelectCopySource = async (positionTypeId: string) => {
|
||||
setCreateForm((current) => ({ ...current, copyPermissionFromId: positionTypeId }));
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
copyPermissionFromId: positionTypeId,
|
||||
}));
|
||||
|
||||
if (!positionTypeId) {
|
||||
setCreatePermissionIds([]);
|
||||
@@ -530,18 +579,23 @@ const PositionTypesPage = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const copiedPermissions = await loadPermissionsForPositionType(positionTypeId);
|
||||
setCreatePermissionIds(copiedPermissions.map((permission) => permission.id));
|
||||
const copiedPermissions =
|
||||
await loadPermissionsForPositionType(positionTypeId);
|
||||
setCreatePermissionIds(
|
||||
copiedPermissions.map((permission) => permission.id),
|
||||
);
|
||||
} catch (error) {
|
||||
setCreateError(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to copy permissions."
|
||||
? (error.response?.data?.message ?? "Unable to copy permissions.")
|
||||
: "Unable to copy permissions.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePositionType = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleCreatePositionType = async (
|
||||
event: React.FormEvent<HTMLFormElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedUnitId) {
|
||||
@@ -576,7 +630,7 @@ const PositionTypesPage = () => {
|
||||
} catch (error) {
|
||||
setCreateError(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to create position type."
|
||||
? (error.response?.data?.message ?? "Unable to create position type.")
|
||||
: "Unable to create position type.",
|
||||
);
|
||||
} finally {
|
||||
@@ -611,21 +665,26 @@ const PositionTypesPage = () => {
|
||||
|
||||
const [refreshedPermissions, refreshedPositionTypes] = await Promise.all([
|
||||
loadPermissionsForPositionType(selectedPositionType.id),
|
||||
selectedUnitId ? loadPositionTypes(selectedUnitId) : Promise.resolve(positionTypes),
|
||||
selectedUnitId
|
||||
? loadPositionTypes(selectedUnitId)
|
||||
: Promise.resolve(positionTypes),
|
||||
]);
|
||||
|
||||
setPositionTypePermissions(refreshedPermissions);
|
||||
setSelectedPermissionIds(refreshedPermissions.map((permission) => permission.id));
|
||||
setSelectedPermissionIds(
|
||||
refreshedPermissions.map((permission) => permission.id),
|
||||
);
|
||||
setPositionTypes(refreshedPositionTypes);
|
||||
|
||||
const refreshedSelected = refreshedPositionTypes.find((item) => item.id === selectedPositionType.id);
|
||||
const refreshedSelected = refreshedPositionTypes.find(
|
||||
(item) => item.id === selectedPositionType.id,
|
||||
);
|
||||
if (refreshedSelected) {
|
||||
setSelectedPositionType(refreshedSelected);
|
||||
}
|
||||
} catch (error) {
|
||||
setPermissionsError(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to update position type."
|
||||
? (error.response?.data?.message ?? "Unable to update position type.")
|
||||
: "Unable to update position type.",
|
||||
);
|
||||
} finally {
|
||||
@@ -633,34 +692,6 @@ const PositionTypesPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedPositionType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setPermissionsError(null);
|
||||
|
||||
try {
|
||||
await api.post("/position-type-permissions/assign-seconds-for-first", {
|
||||
firstId: selectedPositionType.id,
|
||||
secondIds: selectedPermissionIds,
|
||||
});
|
||||
|
||||
const items = await loadPermissionsForPositionType(selectedPositionType.id);
|
||||
setPositionTypePermissions(items);
|
||||
setSelectedPermissionIds(items.map((permission) => permission.id));
|
||||
} catch (error) {
|
||||
setPermissionsError(
|
||||
isAxiosError(error)
|
||||
? error.response?.data?.message ?? "Unable to update position type permissions."
|
||||
: "Unable to update position type permissions.",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="p-6">
|
||||
<div className="space-y-6 rounded-2xl border border-border bg-card p-8 shadow-sm">
|
||||
@@ -670,9 +701,12 @@ const PositionTypesPage = () => {
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-foreground">Position Type</h1>
|
||||
<h1 className="text-3xl font-semibold text-foreground">
|
||||
Position Type
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
|
||||
Browse position types for a selected organization unit, add new ones, and manage their permissions.
|
||||
Browse position types for a selected organization unit, add new
|
||||
ones, and manage their permissions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -712,7 +746,13 @@ const PositionTypesPage = () => {
|
||||
disabled={loadingOrganizations || !visibleOrganizations.length}
|
||||
>
|
||||
<SelectTrigger className="w-full rounded-xl bg-background">
|
||||
<SelectValue placeholder={loadingOrganizations ? "Loading organizations..." : "Select organization"} />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingOrganizations
|
||||
? "Loading organizations..."
|
||||
: "Select organization"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{visibleOrganizations.map((organization) => (
|
||||
@@ -734,7 +774,11 @@ const PositionTypesPage = () => {
|
||||
disabled={!selectedOrgId || loadingUnits || !units.length}
|
||||
>
|
||||
<SelectTrigger className="w-full rounded-xl bg-background">
|
||||
<SelectValue placeholder={loadingUnits ? "Loading units..." : "Select unit"} />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingUnits ? "Loading units..." : "Select unit"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((unit) => (
|
||||
@@ -800,7 +844,9 @@ const PositionTypesPage = () => {
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
{getLocaleLabel(positionType.name, positionType.key)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">{positionType.key}</td>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">
|
||||
{positionType.key}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{positionType.isSystem ? "System" : "Unit"}
|
||||
</td>
|
||||
@@ -833,7 +879,10 @@ const PositionTypesPage = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedPositionType
|
||||
? getLocaleLabel(selectedPositionType.name, selectedPositionType.key)
|
||||
? getLocaleLabel(
|
||||
selectedPositionType.name,
|
||||
selectedPositionType.key,
|
||||
)
|
||||
: "Position type details"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -851,7 +900,10 @@ const PositionTypesPage = () => {
|
||||
Position type
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-foreground">
|
||||
{getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}
|
||||
{getLocaleLabel(
|
||||
selectedPositionType.name,
|
||||
selectedPositionType.key,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -883,23 +935,33 @@ const PositionTypesPage = () => {
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-4 rounded-2xl border border-border bg-muted/40 p-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">English name</span>
|
||||
<span className="font-medium text-foreground">
|
||||
English name
|
||||
</span>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={editForm.nameEn}
|
||||
onChange={(event) =>
|
||||
setEditForm((current) => ({ ...current, nameEn: event.target.value }))
|
||||
setEditForm((current) => ({
|
||||
...current,
|
||||
nameEn: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={selectedPositionType.isSystem}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">Amharic name</span>
|
||||
<span className="font-medium text-foreground">
|
||||
Amharic name
|
||||
</span>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={editForm.nameAm}
|
||||
onChange={(event) =>
|
||||
setEditForm((current) => ({ ...current, nameAm: event.target.value }))
|
||||
setEditForm((current) => ({
|
||||
...current,
|
||||
nameAm: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={selectedPositionType.isSystem}
|
||||
/>
|
||||
@@ -910,20 +972,26 @@ const PositionTypesPage = () => {
|
||||
className={inputClassName}
|
||||
value={editForm.key}
|
||||
onChange={(event) =>
|
||||
setEditForm((current) => ({ ...current, key: event.target.value }))
|
||||
setEditForm((current) => ({
|
||||
...current,
|
||||
key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={selectedPositionType.isSystem}
|
||||
/>
|
||||
</label>
|
||||
{selectedPositionType.isSystem ? (
|
||||
<p className="text-xs text-muted-foreground sm:col-span-2">
|
||||
System position types keep their name and key, but you can still manage permissions here.
|
||||
System position types keep their name and key, but you can
|
||||
still manage permissions here.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Permissions</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Permissions
|
||||
</h2>
|
||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{selectedPermissionIds.length} permissions selected
|
||||
</div>
|
||||
@@ -942,7 +1010,9 @@ const PositionTypesPage = () => {
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={permissionSearch}
|
||||
onChange={(event) => setPermissionSearch(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setPermissionSearch(event.target.value)
|
||||
}
|
||||
placeholder="Search permissions by name or key"
|
||||
/>
|
||||
|
||||
@@ -960,7 +1030,9 @@ const PositionTypesPage = () => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium text-foreground">Select all</span>
|
||||
<span className="font-medium text-foreground">
|
||||
Select all
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{loadingPermissionsCatalog ? (
|
||||
@@ -976,20 +1048,29 @@ const PositionTypesPage = () => {
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPermissionIds.includes(permission.id)}
|
||||
checked={selectedPermissionIds.includes(
|
||||
permission.id,
|
||||
)}
|
||||
onChange={(event) => {
|
||||
setSelectedPermissionIds((current) =>
|
||||
event.target.checked
|
||||
? [...current, permission.id]
|
||||
: current.filter((item) => item !== permission.id),
|
||||
: current.filter(
|
||||
(item) => item !== permission.id,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 leading-4">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{getLocaleLabel(permission.name, permission.key)}
|
||||
{getLocaleLabel(
|
||||
permission.name,
|
||||
permission.key,
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{permission.key}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{permission.key}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
@@ -1030,30 +1111,44 @@ const PositionTypesPage = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create position type</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new position type for the selected unit and optionally copy permissions from an existing one.
|
||||
Add a new position type for the selected unit and optionally copy
|
||||
permissions from an existing one.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={(event) => void handleCreatePositionType(event)}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => void handleCreatePositionType(event)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">English name</span>
|
||||
<span className="font-medium text-foreground">
|
||||
English name
|
||||
</span>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={createForm.nameEn}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, nameEn: event.target.value }))
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
nameEn: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">Amharic name</span>
|
||||
<span className="font-medium text-foreground">
|
||||
Amharic name
|
||||
</span>
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={createForm.nameAm}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, nameAm: event.target.value }))
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
nameAm: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
@@ -1064,13 +1159,18 @@ const PositionTypesPage = () => {
|
||||
className={inputClassName}
|
||||
value={createForm.key}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, key: event.target.value }))
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
key: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">Copy permissions from</span>
|
||||
<span className="font-medium text-foreground">
|
||||
Copy permissions from
|
||||
</span>
|
||||
<Select
|
||||
value={createForm.copyPermissionFromId || undefined}
|
||||
onValueChange={(value) => void handleSelectCopySource(value)}
|
||||
@@ -1091,7 +1191,9 @@ const PositionTypesPage = () => {
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-border bg-background/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Permissions</h2>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Permissions
|
||||
</h2>
|
||||
<div className="rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{createPermissionIds.length} selected
|
||||
</div>
|
||||
@@ -1100,7 +1202,9 @@ const PositionTypesPage = () => {
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={createPermissionSearch}
|
||||
onChange={(event) => setCreatePermissionSearch(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setCreatePermissionSearch(event.target.value)
|
||||
}
|
||||
placeholder="Search permissions by name or key"
|
||||
/>
|
||||
|
||||
@@ -1139,15 +1243,19 @@ const PositionTypesPage = () => {
|
||||
setCreatePermissionIds((current) =>
|
||||
event.target.checked
|
||||
? [...current, permission.id]
|
||||
: current.filter((item) => item !== permission.id),
|
||||
);
|
||||
}}
|
||||
: current.filter(
|
||||
(item) => item !== permission.id,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 leading-4">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{getLocaleLabel(permission.name, permission.key)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{permission.key}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{permission.key}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
@@ -1163,7 +1271,8 @@ const PositionTypesPage = () => {
|
||||
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700 dark:border-sky-950 dark:bg-sky-950/30 dark:text-sky-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
The new position type will inherit permissions from the selected source.
|
||||
The new position type will inherit permissions from the
|
||||
selected source.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { DesignConfig } from "@tria-plc/iamui";
|
||||
|
||||
import {
|
||||
FREIGHT_BRAND,
|
||||
FREIGHT_BRAND_DARK,
|
||||
FREIGHT_BRAND_LIGHT,
|
||||
freightBrand,
|
||||
} from "@/theme/freight-brand";
|
||||
@@ -48,7 +47,7 @@ export const iamConfig: DesignConfig = {
|
||||
},
|
||||
layout: {
|
||||
userManagementView: "classic",
|
||||
showTopBar: true,
|
||||
showTopBar: true as any,
|
||||
sidebarWidth: "280px",
|
||||
sidebarCollapsedWidth: "80px",
|
||||
headerHeight: "80px",
|
||||
|
||||
@@ -17,15 +17,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { FileUploadEntity } from "@edr/types/freight";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
// import type {
|
||||
// FileUploadEntity,
|
||||
// FileUploadSetting,
|
||||
// } from "@/types/fileUploadSettings";
|
||||
// import {
|
||||
// useCreateFileUploadSetting,
|
||||
// useUpdateFileUploadSetting,
|
||||
// } from "@/hooks/useFileUploadSettings";
|
||||
import { FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
|
||||
export interface EditFileUploadSettingDialogProps {
|
||||
mode?: "create" | "edit";
|
||||
@@ -33,19 +25,6 @@ export interface EditFileUploadSettingDialogProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
||||
|
||||
// const ENTITIES: FileUploadEntity[] = [
|
||||
// "customer",
|
||||
// "booking",
|
||||
// "consignment",
|
||||
// "shipment",
|
||||
// "invoice",
|
||||
// "train",
|
||||
// "other",
|
||||
// ];
|
||||
|
||||
export default function EditFileUploadSettingDialog({
|
||||
mode = "create",
|
||||
setting,
|
||||
@@ -62,8 +41,12 @@ export default function EditFileUploadSettingDialog({
|
||||
const [description, setDescription] = useState(setting?.description ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation(api.fileUploadSettings.create.mutationOptions());
|
||||
const updateMutation = useMutation(api.fileUploadSettings.update.mutationOptions());
|
||||
const createMutation = useMutation(
|
||||
api.fileUploadSettings.create.mutationOptions(),
|
||||
);
|
||||
const updateMutation = useMutation(
|
||||
api.fileUploadSettings.update.mutationOptions(),
|
||||
);
|
||||
const pending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const reset = () => {
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
import { FormEvent, ReactNode, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { FormEvent, ReactNode, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge as MantineBadge,
|
||||
Box,
|
||||
Button as MantineButton,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select as MantineSelect,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table as MantineTable,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
ActionIcon,
|
||||
Box,
|
||||
Group,
|
||||
Badge as MantineBadge,
|
||||
Button as MantineButton,
|
||||
Select as MantineSelect,
|
||||
Table as MantineTable,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { Cargo } from '@/services/cargoService';
|
||||
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
|
||||
import type { Container } from '@/services/containerService';
|
||||
import type { Locomotive } from '@/services/locomotives.service';
|
||||
import type { Train } from '@/services/trains.service';
|
||||
import type { Wagon } from '@/services/wagon.service';
|
||||
import type { WagonType } from '@/services/wagon-types.service';
|
||||
import type { Wagon } from '@/services/wagon.service';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||
|
||||
type FormValue = string | number | boolean | string[];
|
||||
|
||||
@@ -331,7 +331,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
<TableRow key={item.id}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={String(column.key)}>
|
||||
{column.render ? column.render(item) : String((item as Record<string, unknown>)[column.key] ?? '-')}
|
||||
{column.render ? column.render(item) : String((item as Record<string, unknown>)[String(column.key)] ?? '-')}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
@@ -410,7 +410,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
...current,
|
||||
[field.key]: selectedValue,
|
||||
...(field.onValueChange?.(selectedValue, current) ?? {}),
|
||||
}))
|
||||
}) as Record<string, FormValue>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={field.key}>
|
||||
@@ -480,12 +480,6 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
|
||||
const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'}</Badge>;
|
||||
|
||||
const activeBadge = (isActive?: boolean) => (
|
||||
<Badge variant={isActive === false ? 'secondary' : 'outline'}>
|
||||
{isActive === false ? 'Inactive' : 'Active'}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
|
||||
options.find((option) => option.value === value)?.label ?? value ?? '-';
|
||||
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core";
|
||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
Archive,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleSlash,
|
||||
Layers,
|
||||
Link2,
|
||||
Plus,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -23,37 +14,20 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
FLEET_SELECT_NONE,
|
||||
getFleetResource,
|
||||
getFleetSlugFromPath,
|
||||
type FleetFormFieldDef,
|
||||
type FleetResourceSlug,
|
||||
FLEET_SELECT_NONE,
|
||||
getFleetResource,
|
||||
getFleetSlugFromPath,
|
||||
type FleetFormFieldDef,
|
||||
type FleetResourceSlug,
|
||||
} from "@/pages/fleet/config/resources";
|
||||
import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
|
||||
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
|
||||
|
||||
const FLEET_STATUS_META: Record<
|
||||
string,
|
||||
{ label: string; icon: LucideIcon; color: string }
|
||||
> = {
|
||||
AVAILABLE: { label: "Available", icon: CircleCheck, color: "edr-green" },
|
||||
ASSIGNED: { label: "Assigned", icon: Link2, color: "blue" },
|
||||
MAINTENANCE: { label: "Maintenance", icon: Wrench, color: "yellow" },
|
||||
OUT_OF_SERVICE: { label: "Out of service", icon: CircleSlash, color: "red" },
|
||||
RETIRED: { label: "Retired", icon: Archive, color: "gray" },
|
||||
};
|
||||
|
||||
const humanizeStatus = (status: string) => {
|
||||
const text = status.replace(/_/g, " ").toLowerCase();
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
const FleetResourcePage = () => {
|
||||
const location = useLocation();
|
||||
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
|
||||
@@ -113,6 +87,9 @@ const FleetResourcePage = () => {
|
||||
const { data: yards = [], isLoading: yardsLoading } = useQuery(
|
||||
api.routes.yards.queryOptions(),
|
||||
);
|
||||
const { data: drivers = [] } = useQuery(
|
||||
api.fleet.list.queryOptions({ input: { slug: "drivers" } }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
@@ -301,39 +278,6 @@ const FleetResourcePage = () => {
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
const kpiItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
label: `Total ${config?.label.toLowerCase() ?? ""}`,
|
||||
value: allRows.length,
|
||||
icon: Layers,
|
||||
color: "edr-green",
|
||||
},
|
||||
];
|
||||
if (hasStatusColumn) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of allRows) {
|
||||
const status = String(
|
||||
(row as unknown as Record<string, unknown>).status ?? "",
|
||||
);
|
||||
if (status) counts.set(status, (counts.get(status) ?? 0) + 1);
|
||||
}
|
||||
const top = [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4);
|
||||
for (const [status, count] of top) {
|
||||
const meta = FLEET_STATUS_META[status];
|
||||
items.push({
|
||||
label: meta?.label ?? humanizeStatus(status),
|
||||
value: count,
|
||||
icon: meta?.icon ?? Circle,
|
||||
color: meta?.color ?? "gray",
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.slice(0, 5);
|
||||
}, [allRows, hasStatusColumn, config?.label]);
|
||||
|
||||
if (!config) {
|
||||
return <Navigate to="/dashboard/locomotives" replace />;
|
||||
}
|
||||
@@ -376,7 +320,7 @@ const FleetResourcePage = () => {
|
||||
const handleAssignDriver = async () => {
|
||||
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
|
||||
try {
|
||||
const selectedDriverRecord = (drivers as Array<Record<string, unknown>>).find(
|
||||
const selectedDriverRecord = (drivers as unknown as Array<Record<string, unknown>>).find(
|
||||
(d) => String(d.id) === selectedDriver
|
||||
);
|
||||
if (!selectedDriverRecord) return;
|
||||
@@ -384,6 +328,7 @@ const FleetResourcePage = () => {
|
||||
const driverName = `${selectedDriverRecord.firstName} ${selectedDriverRecord.lastName}`;
|
||||
|
||||
await update.mutateAsync({
|
||||
slug,
|
||||
id: String(assigningDriver.id),
|
||||
data: {
|
||||
assignedDriverId: selectedDriver,
|
||||
@@ -608,7 +553,7 @@ const FleetResourcePage = () => {
|
||||
clearable
|
||||
value={selectedDriver}
|
||||
onChange={(value) => setSelectedDriver(value || "")}
|
||||
data={(drivers as Array<Record<string, unknown>>).map((driver) => ({
|
||||
data={(drivers as unknown as Array<Record<string, unknown>>).map((driver) => ({
|
||||
value: String(driver.id || ""),
|
||||
label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`,
|
||||
}))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Badge,
|
||||
Badge as MantineBadge,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -28,7 +29,6 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
|
||||
import {
|
||||
Badge,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
@@ -36,7 +36,12 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
|
||||
{
|
||||
key: "all",
|
||||
label: "All",
|
||||
statuses: undefined as string | undefined,
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
|
||||
{
|
||||
key: "processing",
|
||||
@@ -44,7 +49,12 @@ const STATUS_TABS = [
|
||||
statuses: "processing,action-required",
|
||||
icon: Loader2,
|
||||
},
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
|
||||
{
|
||||
key: "failed",
|
||||
label: "Failed",
|
||||
statuses: "failed,canceled",
|
||||
icon: XCircle,
|
||||
},
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
|
||||
] as const;
|
||||
|
||||
@@ -81,13 +91,14 @@ function formatDate(iso: string | null): string {
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||
const tableHeader =
|
||||
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
@@ -124,9 +135,9 @@ export default function PaymentsPage() {
|
||||
summary === undefined
|
||||
? undefined
|
||||
: (summary.success ?? 0) +
|
||||
(summary.processing ?? 0) +
|
||||
(summary.failed ?? 0) +
|
||||
(summary.refunded ?? 0),
|
||||
(summary.processing ?? 0) +
|
||||
(summary.failed ?? 0) +
|
||||
(summary.refunded ?? 0),
|
||||
success: summary?.success,
|
||||
processing: summary?.processing,
|
||||
failed: summary?.failed,
|
||||
@@ -285,7 +296,10 @@ export default function PaymentsPage() {
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
rightSection={
|
||||
query && (
|
||||
@@ -309,7 +323,10 @@ export default function PaymentsPage() {
|
||||
value={method}
|
||||
onChange={(value) => {
|
||||
setMethod(value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
|
||||
@@ -1,48 +1,56 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Box, Card, Button, Modal, Stack, Group, Text, List, Loader } from "@mantine/core";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
||||
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
||||
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
|
||||
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
||||
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
||||
import {
|
||||
DEFAULT_CONFIGURATION_SLUG,
|
||||
DEFAULT_RULES_SLUG,
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
getRuleEngineResource,
|
||||
type RuleEngineNavCategory,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import {
|
||||
useApprovalChain,
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
useRuleEngineOrderList,
|
||||
useRuleEngineOrderMutations,
|
||||
useApprovalChain,
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
useRuleEngineOrderList,
|
||||
useRuleEngineOrderMutations,
|
||||
} from "@/hooks/rule-engine/useRuleEngine";
|
||||
import {
|
||||
DEFAULT_CONFIGURATION_SLUG,
|
||||
DEFAULT_RULES_SLUG,
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
getRuleEngineResource,
|
||||
type RuleEngineNavCategory,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
getCoreRowModel,
|
||||
usePagination,
|
||||
useReactTable,
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
|
||||
@@ -60,16 +68,17 @@ const RuleEngineResourcePage = () => {
|
||||
const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined;
|
||||
|
||||
const defaultPath = category
|
||||
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${
|
||||
category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
|
||||
}`
|
||||
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
|
||||
}`
|
||||
: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`;
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
|
||||
null,
|
||||
);
|
||||
const [chainOpen, setChainOpen] = useState(false);
|
||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||
@@ -90,9 +99,9 @@ const RuleEngineResourcePage = () => {
|
||||
pageSize: pagination.pageSize,
|
||||
...(config?.orderConfig
|
||||
? {
|
||||
sortBy: config.orderConfig.field,
|
||||
sortOrder: "ASC" as const,
|
||||
}
|
||||
sortBy: config.orderConfig.field,
|
||||
sortOrder: "ASC" as const,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[
|
||||
@@ -120,11 +129,12 @@ const RuleEngineResourcePage = () => {
|
||||
const { reorder, moveOrder } = useRuleEngineOrderMutations(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
);
|
||||
const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(orderDialogOpen && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
const { data: orderListData, isLoading: orderListLoading } =
|
||||
useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(orderDialogOpen && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
const { submit, approve } = useRateWorkflow();
|
||||
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
|
||||
chainOpen && config?.slug === "approval-rules",
|
||||
@@ -141,10 +151,7 @@ const RuleEngineResourcePage = () => {
|
||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
||||
useContainerTypeOptions(
|
||||
config?.slug === "rates",
|
||||
usesContainerTypeField,
|
||||
);
|
||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
|
||||
@@ -154,10 +161,9 @@ const RuleEngineResourcePage = () => {
|
||||
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
|
||||
return {
|
||||
...field,
|
||||
options:
|
||||
cargoParentOptions ?? [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
],
|
||||
options: cargoParentOptions ?? [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (field.name === "containerTypeId") {
|
||||
@@ -183,14 +189,16 @@ const RuleEngineResourcePage = () => {
|
||||
const pageCount = meta?.totalPages ?? 1;
|
||||
const totalCount = meta?.total ?? rows.length;
|
||||
|
||||
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(formOpen && !editing && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
const { data: createPositionList, isLoading: createPositionLoading } =
|
||||
useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(formOpen && !editing && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
|
||||
const createPositionOptions = useMemo(() => {
|
||||
if (!config?.orderConfig || !createPositionList?.data?.length) return undefined;
|
||||
if (!config?.orderConfig || !createPositionList?.data?.length)
|
||||
return undefined;
|
||||
return createPositionList.data
|
||||
.filter((row) => row.id)
|
||||
.map((row) => ({
|
||||
@@ -199,7 +207,6 @@ const RuleEngineResourcePage = () => {
|
||||
}));
|
||||
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
|
||||
|
||||
|
||||
const handleApproveRate = useCallback(
|
||||
(record: RuleEngineRecord) => {
|
||||
approve.mutate(String(record.id));
|
||||
@@ -259,7 +266,9 @@ const RuleEngineResourcePage = () => {
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
config.slug === "approval-rules"
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
@@ -270,7 +279,15 @@ const RuleEngineResourcePage = () => {
|
||||
});
|
||||
|
||||
return base;
|
||||
}, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]);
|
||||
}, [
|
||||
canManage,
|
||||
config,
|
||||
submit,
|
||||
handleApproveRate,
|
||||
handleMoveOrder,
|
||||
moveOrder.isPending,
|
||||
totalCount,
|
||||
]);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -350,15 +367,20 @@ const RuleEngineResourcePage = () => {
|
||||
onSearchChange={
|
||||
config.supportsSearch
|
||||
? (v) => {
|
||||
setSearch(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}
|
||||
setSearch(v);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showSearch={Boolean(config.supportsSearch)}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
onManageOrder={
|
||||
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
|
||||
canManage && config.orderConfig
|
||||
? () => setOrderDialogOpen(true)
|
||||
: undefined
|
||||
}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
@@ -373,10 +395,12 @@ const RuleEngineResourcePage = () => {
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load data",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
message: "Failed to load data",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage={`No ${itemLabel} found.`}
|
||||
@@ -422,7 +446,9 @@ const RuleEngineResourcePage = () => {
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
config.slug === "approval-rules"
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
@@ -434,7 +460,11 @@ const RuleEngineResourcePage = () => {
|
||||
<RuleEngineFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
title={editing ? `Edit ${config.label.replace(/s$/, "")}` : `Add ${config.label.replace(/s$/, "")}`}
|
||||
title={
|
||||
editing
|
||||
? `Edit ${config.label.replace(/s$/, "")}`
|
||||
: `Add ${config.label.replace(/s$/, "")}`
|
||||
}
|
||||
description={
|
||||
editing
|
||||
? `Update this ${config.label.toLowerCase()} record.`
|
||||
@@ -478,7 +508,8 @@ const RuleEngineResourcePage = () => {
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
This will soft-delete the selected {config.label.toLowerCase()} record.
|
||||
This will soft-delete the selected {config.label.toLowerCase()}{" "}
|
||||
record.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
@@ -515,14 +546,17 @@ const RuleEngineResourcePage = () => {
|
||||
) : (
|
||||
<>
|
||||
{(chainData ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">No approval rules configured.</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
No approval rules configured.
|
||||
</Text>
|
||||
) : (
|
||||
<List spacing="md">
|
||||
{(chainData ?? []).map((step, index) => (
|
||||
<List.Item key={String(step.id ?? index)}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
|
||||
Step {String(step.stepOrder ?? index + 1)}:{" "}
|
||||
{String(step.actionLabel ?? "")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Role: {String(step.requiredRole ?? "—")}
|
||||
|
||||
@@ -44,7 +44,10 @@ import { KpiStrip, PageContainer } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
|
||||
import {
|
||||
TrainConsistView,
|
||||
CompositionBookingTabs,
|
||||
} from "@/components/trainScheduling/compositionEditor";
|
||||
import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
@@ -67,10 +70,18 @@ const STATE_META: Record<
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
|
||||
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock },
|
||||
SELECTED_FOR_BATCH: {
|
||||
label: "Selected for batch",
|
||||
color: "orange",
|
||||
icon: Clock,
|
||||
},
|
||||
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
|
||||
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
PENDING_CONTRACT: {
|
||||
label: "Pending contract",
|
||||
color: "gray",
|
||||
icon: Hourglass,
|
||||
},
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
|
||||
@@ -93,13 +104,13 @@ const fmtMeters = (n: number) =>
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
const initials = (name: string) =>
|
||||
@@ -115,7 +126,12 @@ function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={meta.color}
|
||||
radius="sm"
|
||||
leftSection={<Icon size={11} />}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -232,7 +248,12 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
||||
{fmtDateTime(b.selectedForBatchAt)} EAT
|
||||
</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange.7" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="orange.7"
|
||||
fw={600}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -385,7 +406,9 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
|
||||
border: total ? "1px solid #FBD171" : "1px solid var(--mantine-color-gray-2)",
|
||||
border: total
|
||||
? "1px solid #FBD171"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
@@ -396,7 +419,9 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
{timeLabelOf(window.label)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
|
||||
{total
|
||||
? `${total} booking${total === 1 ? "" : "s"}`
|
||||
: "Empty window"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -443,7 +468,9 @@ export default function BatchScheduleDetailPage() {
|
||||
data?.windows.some((w) =>
|
||||
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
) ||
|
||||
data?.pendingContract.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
data?.pendingContract.bookings.some(
|
||||
(b) => b.allocationStatus === "ASSIGNED",
|
||||
),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
@@ -514,7 +541,9 @@ export default function BatchScheduleDetailPage() {
|
||||
group.hasIssues =
|
||||
group.hasIssues ||
|
||||
w.bookings.some(
|
||||
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
|
||||
(b) =>
|
||||
b.allocationStatus === "FAILED" ||
|
||||
b.allocationStatus === "DEFERRED",
|
||||
);
|
||||
}
|
||||
return [...byDate.values()];
|
||||
@@ -522,7 +551,10 @@ export default function BatchScheduleDetailPage() {
|
||||
|
||||
// Windows with bookings open by default (inside an expanded day).
|
||||
const openWindowKeys = useMemo(
|
||||
() => (data ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) : []),
|
||||
() =>
|
||||
data
|
||||
? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
@@ -541,7 +573,9 @@ export default function BatchScheduleDetailPage() {
|
||||
// day with bookings, else the first day. Keep the selection if still valid.
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!dayGroups.length) return;
|
||||
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
|
||||
@@ -562,7 +596,9 @@ export default function BatchScheduleDetailPage() {
|
||||
runAllocation
|
||||
.mutateAsync({ scheduleId: scheduleId ?? "" })
|
||||
.then((result) => {
|
||||
const failed = result.issues.filter((i) => i.status === "FAILED").length;
|
||||
const failed = result.issues.filter(
|
||||
(i) => i.status === "FAILED",
|
||||
).length;
|
||||
const deferred = result.deferred.length;
|
||||
toast({
|
||||
title: "Allocation run complete",
|
||||
@@ -589,15 +625,6 @@ export default function BatchScheduleDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const lengthPct =
|
||||
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
|
||||
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
|
||||
: null;
|
||||
const weightPct =
|
||||
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
|
||||
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
|
||||
: null;
|
||||
|
||||
const totalBookings = totalBookingCount(data.counts);
|
||||
|
||||
return (
|
||||
@@ -614,36 +641,49 @@ export default function BatchScheduleDetailPage() {
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="composition">
|
||||
Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`}
|
||||
Train Composition{" "}
|
||||
{scheduleDetailQuery.data?.trainSet?.wagons &&
|
||||
scheduleDetailQuery.data.trainSet.wagons.length > 0 &&
|
||||
`(${scheduleDetailQuery.data.trainSet.wagons.length})`}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={9} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
px="sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/operations/batch-board")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Title order={2} fw={800}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
<HeroChip>{data.status}</HeroChip>
|
||||
</Group>
|
||||
<RouteCorridor origin={data.origin} destination={data.destination} />
|
||||
<Group gap={6} wrap="wrap">
|
||||
<HeroChip icon={<CalendarDays size={12} />}>
|
||||
{data.scheduleDate
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
>
|
||||
<Stack gap={9} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
px="sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() =>
|
||||
navigate("/dashboard/operations/batch-board")
|
||||
}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Title order={2} fw={800}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
<HeroChip>{data.status}</HeroChip>
|
||||
</Group>
|
||||
<RouteCorridor
|
||||
origin={data.origin}
|
||||
destination={data.destination}
|
||||
/>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<HeroChip icon={<CalendarDays size={12} />}>
|
||||
{data.scheduleDate
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
@@ -653,317 +693,360 @@ export default function BatchScheduleDetailPage() {
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(data.scheduleDate)) + " EAT"
|
||||
: "No date"}
|
||||
</HeroChip>
|
||||
{data.locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
: "No date"}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!data.locomotive ? (
|
||||
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Allocated wagons",
|
||||
value: data.capacity.allocatedWagons,
|
||||
hint: "on this train",
|
||||
icon: Boxes,
|
||||
},
|
||||
{
|
||||
label: "Train length",
|
||||
value: data.capacity.maxLengthMeters
|
||||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||||
: fmtMeters(data.capacity.allocatedLengthMeters),
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
value: data.capacity.maxWeightTons
|
||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||
: fmtTons(data.capacity.usedWeightTons),
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
value: totalBookings,
|
||||
hint: `${data.counts.allocated} allocated · ${data.counts.expired} expired`,
|
||||
icon: Package,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Booking pipeline */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="md" variant="light" color="#F2A516">
|
||||
<Package size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Booking pipeline</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="dark.4">
|
||||
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<BookingPipeline counts={data.counts} size={14} />
|
||||
</Paper>
|
||||
|
||||
{data.allocationViolations.length ? (
|
||||
<Alert
|
||||
color="red"
|
||||
mt="lg"
|
||||
radius="lg"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Allocation constraints"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{data.allocationViolations.map((v) => (
|
||||
<Text key={v} size="sm">
|
||||
{v}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Batch windows */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
|
||||
<Clock size={19} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4}>Batch windows (EAT)</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
3-hour windows for every day from when the booking window opened through the
|
||||
departure date. Bookings appear under the date their contract was signed — open a
|
||||
day to see its windows.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{dayGroups.length && selectedDay ? (
|
||||
<>
|
||||
{/* Date stepper — page back/forward through each day in the range */}
|
||||
<Group justify="center" align="center" wrap="nowrap" gap="md" mt="md">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Previous day"
|
||||
disabled={selectedIndex <= 0}
|
||||
onClick={() => setSelectedDate(dayGroups[selectedIndex - 1]?.date ?? null)}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
radius="xl"
|
||||
px="xl"
|
||||
py="xs"
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 360,
|
||||
textAlign: "center",
|
||||
background: selectedDay.totalBookings ? "#FEF1D5" : "white",
|
||||
borderColor: selectedDay.totalBookings
|
||||
? "#FBD171"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="center" gap={8} wrap="nowrap">
|
||||
<CalendarDays size={15} color="#B26C09" />
|
||||
<Text
|
||||
fw={800}
|
||||
style={{ color: selectedDay.totalBookings ? "#8A5304" : "#0f172a" }}
|
||||
>
|
||||
{selectedDay.dateLabel}
|
||||
</Text>
|
||||
{selectedDay.date === todayEat ? (
|
||||
<Badge size="xs" variant="light" color="#F2A516">
|
||||
Today
|
||||
</Badge>
|
||||
{data.locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Loco {data.locomotive.code} ·{" "}
|
||||
{fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
</HeroChip>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{selectedDay.totalBookings
|
||||
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
|
||||
: `${selectedDay.windows.length} windows · no bookings`}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Next day"
|
||||
disabled={selectedIndex >= dayGroups.length - 1}
|
||||
onClick={() => setSelectedDate(dayGroups[selectedIndex + 1]?.date ?? null)}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center" mt="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Day {selectedIndex + 1} of {dayGroups.length}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{selectedDay.hasIssues ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<AlertTriangle size={10} />}
|
||||
>
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<WindowCountChips counts={selectedDay.counts} />
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
key={selectedDay.date}
|
||||
multiple
|
||||
defaultValue={openWindowKeys}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{selectedDay.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No batch windows for this schedule.
|
||||
</Text>
|
||||
)}
|
||||
{!data.locomotive ? (
|
||||
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={["pending-contract"]}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
<Accordion.Item value="pending-contract">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Allocated wagons",
|
||||
value: data.capacity.allocatedWagons,
|
||||
hint: "on this train",
|
||||
icon: Boxes,
|
||||
},
|
||||
{
|
||||
label: "Train length",
|
||||
value: data.capacity.maxLengthMeters
|
||||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||||
: fmtMeters(data.capacity.allocatedLengthMeters),
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
value: data.capacity.maxWeightTons
|
||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||
: fmtTons(data.capacity.usedWeightTons),
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
value: totalBookings,
|
||||
hint: `${data.counts.allocated} allocated · ${data.counts.expired} expired`,
|
||||
icon: Package,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Booking pipeline */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={32}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
>
|
||||
<Package size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Booking pipeline</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="dark.4">
|
||||
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<BookingPipeline counts={data.counts} size={14} />
|
||||
</Paper>
|
||||
|
||||
{data.allocationViolations.length ? (
|
||||
<Alert
|
||||
color="red"
|
||||
mt="lg"
|
||||
radius="lg"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Allocation constraints"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{data.allocationViolations.map((v) => (
|
||||
<Text key={v} size="sm">
|
||||
{v}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Batch windows */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
>
|
||||
<Clock size={19} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4}>Batch windows (EAT)</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
3-hour windows for every day from when the booking window
|
||||
opened through the departure date. Bookings appear under the
|
||||
date their contract was signed — open a day to see its
|
||||
windows.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{dayGroups.length && selectedDay ? (
|
||||
<>
|
||||
{/* Date stepper — page back/forward through each day in the range */}
|
||||
<Group
|
||||
justify="center"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="md"
|
||||
mt="md"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Previous day"
|
||||
disabled={selectedIndex <= 0}
|
||||
onClick={() =>
|
||||
setSelectedDate(
|
||||
dayGroups[selectedIndex - 1]?.date ?? null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
radius="xl"
|
||||
px="xl"
|
||||
py="xs"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
flex: 1,
|
||||
maxWidth: 360,
|
||||
textAlign: "center",
|
||||
background: selectedDay.totalBookings
|
||||
? "#FEF1D5"
|
||||
: "white",
|
||||
borderColor: selectedDay.totalBookings
|
||||
? "#FBD171"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<FileSignature size={16} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={700} size="sm">
|
||||
Pending contract
|
||||
<Group justify="center" gap={8} wrap="nowrap">
|
||||
<CalendarDays size={15} color="#B26C09" />
|
||||
<Text
|
||||
fw={800}
|
||||
style={{
|
||||
color: selectedDay.totalBookings
|
||||
? "#8A5304"
|
||||
: "#0f172a",
|
||||
}}
|
||||
>
|
||||
{selectedDay.dateLabel}
|
||||
</Text>
|
||||
{selectedDay.date === todayEat ? (
|
||||
<Badge size="xs" variant="light" color="#F2A516">
|
||||
Today
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{selectedDay.totalBookings
|
||||
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
|
||||
: `${selectedDay.windows.length} windows · no bookings`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Contract not signed yet — not in any window
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{data.pendingContract.bookings.length} booking
|
||||
{data.pendingContract.bookings.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={data.pendingContract.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Paper>
|
||||
</Paper>
|
||||
|
||||
{/* Train composition diagram */}
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Box mt="lg">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType="CONTAINER"
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Next day"
|
||||
disabled={selectedIndex >= dayGroups.length - 1}
|
||||
onClick={() =>
|
||||
setSelectedDate(
|
||||
dayGroups[selectedIndex + 1]?.date ?? null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center" mt="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Day {selectedIndex + 1} of {dayGroups.length}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{selectedDay.hasIssues ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<AlertTriangle size={10} />}
|
||||
>
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<WindowCountChips counts={selectedDay.counts} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
key={selectedDay.date}
|
||||
multiple
|
||||
defaultValue={openWindowKeys}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{selectedDay.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No batch windows for this schedule.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={["pending-contract"]}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
<Accordion.Item value="pending-contract">
|
||||
<Accordion.Control>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
pr="md"
|
||||
gap="sm"
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<FileSignature size={16} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={700} size="sm">
|
||||
Pending contract
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Contract not signed yet — not in any window
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{data.pendingContract.bookings.length} booking
|
||||
{data.pendingContract.bookings.length === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={data.pendingContract.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
{/* Train composition diagram */}
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Box mt="lg">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType="CONTAINER"
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={
|
||||
scheduleDetailQuery.data.trainSet?.totalLengthMeters
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ function mapCompany(dto: Record<string, unknown>): Company {
|
||||
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
|
||||
return {
|
||||
...(dto as unknown as Company),
|
||||
companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [],
|
||||
contactPersonName: (attrs.contactPersonName as string | null) ?? null,
|
||||
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
|
||||
generalManagerName: (attrs.generalManagerName as string | null) ?? null,
|
||||
|
||||
@@ -67,7 +67,11 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const defaultMeta = (dataLength: number, page = 1, pageSize = 10): RuleEngineListMeta => ({
|
||||
const defaultMeta = (
|
||||
dataLength: number,
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
): RuleEngineListMeta => ({
|
||||
total: dataLength,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -79,7 +83,7 @@ const isPaginatedListResult = <T extends RuleEngineRecord>(
|
||||
): value is RuleEngineListResult<T> =>
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
"data" in value &&
|
||||
"data" in (value ?? {}) &&
|
||||
Array.isArray((value as RuleEngineListResult<T>).data);
|
||||
|
||||
const normalizeList = <T extends RuleEngineRecord>(
|
||||
@@ -104,7 +108,10 @@ const normalizeList = <T extends RuleEngineRecord>(
|
||||
}
|
||||
|
||||
if (Array.isArray(body)) {
|
||||
return { data: body as T[], meta: defaultMeta(body.length, page, pageSize) };
|
||||
return {
|
||||
data: body as T[],
|
||||
meta: defaultMeta(body.length, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
return { data: [], meta: defaultMeta(0, page, pageSize) };
|
||||
@@ -161,7 +168,10 @@ export const ruleEngineService = {
|
||||
return normalizeEntity<T>(response.data);
|
||||
},
|
||||
|
||||
remove: async (resource: RuleEngineResourceSlug, id: string): Promise<void> => {
|
||||
remove: async (
|
||||
resource: RuleEngineResourceSlug,
|
||||
id: string,
|
||||
): Promise<void> => {
|
||||
await client.delete(byIdPath(resource, id));
|
||||
},
|
||||
|
||||
@@ -181,24 +191,36 @@ export const ruleEngineService = {
|
||||
},
|
||||
|
||||
submitRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
|
||||
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id));
|
||||
const response = await client.post(
|
||||
URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id),
|
||||
);
|
||||
return normalizeEntity<T>(response.data);
|
||||
},
|
||||
|
||||
approveRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
|
||||
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id));
|
||||
const response = await client.post(
|
||||
URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id),
|
||||
);
|
||||
return normalizeEntity<T>(response.data);
|
||||
},
|
||||
|
||||
getApprovalChain: async (
|
||||
requiresDirectorApproval = true,
|
||||
): Promise<RuleEngineRecord[]> => {
|
||||
const response = await client.get(URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_CHAIN, {
|
||||
params: { requiresDirectorApproval },
|
||||
});
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_CHAIN,
|
||||
{
|
||||
params: { requiresDirectorApproval },
|
||||
},
|
||||
);
|
||||
const body = unwrap(response.data) as unknown;
|
||||
if (Array.isArray(body)) return body as RuleEngineRecord[];
|
||||
if (body && typeof body === "object" && "data" in body && Array.isArray((body as { data: unknown }).data)) {
|
||||
if (
|
||||
body &&
|
||||
typeof body === "object" &&
|
||||
"data" in body &&
|
||||
Array.isArray((body as { data: unknown }).data)
|
||||
) {
|
||||
return (body as { data: RuleEngineRecord[] }).data;
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { api as client } from '../auth/http';
|
||||
import { unwrap } from '@/utils/endpoint';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
BatchBoardSchedule,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
AssignBookingsPayload,
|
||||
CompositionRemovalEntry,
|
||||
CompositionUnassignedBooking,
|
||||
UnassignedBookingsResponse,
|
||||
CreateTrainSchedulePayload,
|
||||
EligibleContainerBookingsResponse,
|
||||
@@ -24,7 +23,7 @@ import type {
|
||||
TrainTrackResponse,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
} from '@/types/trainScheduling';
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
interface BookingReferenceDataResponse {
|
||||
yard?: Array<YardOption & { label?: string }>;
|
||||
@@ -58,7 +57,9 @@ export const trainSchedulingService = {
|
||||
): Promise<TrainSchedulePreviewResponse> => {
|
||||
const useUnified = !freightType || freightType === "MIXED";
|
||||
const response = await client.post<TrainSchedulePreviewResponse>(
|
||||
useUnified ? URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW : pathsFor(freightType).PREVIEW,
|
||||
useUnified
|
||||
? URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW
|
||||
: pathsFor(freightType).PREVIEW,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
@@ -91,7 +92,9 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBatchBoardDetail: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
getBatchBoardDetail: async (
|
||||
scheduleId: string,
|
||||
): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.get<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD_DETAIL(scheduleId),
|
||||
);
|
||||
@@ -132,7 +135,9 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
runAllocation: async (scheduleId: string): Promise<WagonAllocationAttemptResult> => {
|
||||
runAllocation: async (
|
||||
scheduleId: string,
|
||||
): Promise<WagonAllocationAttemptResult> => {
|
||||
const response = await client.post<WagonAllocationAttemptResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_ALLOCATION(scheduleId),
|
||||
{},
|
||||
@@ -152,20 +157,29 @@ export const trainSchedulingService = {
|
||||
},
|
||||
|
||||
markBookingPaid: async (bookingId: string): Promise<void> => {
|
||||
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId), {});
|
||||
await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),
|
||||
{},
|
||||
);
|
||||
},
|
||||
|
||||
expireBooking: async (bookingId: string): Promise<void> => {
|
||||
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.EXPIRE_BOOKING(bookingId), {});
|
||||
await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.EXPIRE_BOOKING(bookingId),
|
||||
{},
|
||||
);
|
||||
},
|
||||
|
||||
moveBookingSchedule: async (
|
||||
bookingId: string,
|
||||
trainScheduleId: string,
|
||||
): Promise<void> => {
|
||||
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_BOOKING_SCHEDULE(bookingId), {
|
||||
trainScheduleId,
|
||||
});
|
||||
await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_BOOKING_SCHEDULE(bookingId),
|
||||
{
|
||||
trainScheduleId,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
getScheduleById: async (
|
||||
@@ -173,7 +187,9 @@ export const trainSchedulingService = {
|
||||
freightType?: FreightType,
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.get<TrainScheduleDetail>(
|
||||
pathsFor(freightType === "MIXED" ? undefined : freightType).SCHEDULE_BY_ID(id),
|
||||
pathsFor(
|
||||
freightType === "MIXED" ? undefined : freightType,
|
||||
).SCHEDULE_BY_ID(id),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
@@ -225,7 +241,9 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
finalizeSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||
finalizeSchedule: async (
|
||||
scheduleId: string,
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.FINALIZE(scheduleId),
|
||||
{},
|
||||
@@ -233,7 +251,9 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
dispatchSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||
dispatchSchedule: async (
|
||||
scheduleId: string,
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
|
||||
{},
|
||||
@@ -272,13 +292,17 @@ export const trainSchedulingService = {
|
||||
freightType: FreightType = "CONTAINER",
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
pathsFor(freightType === "MIXED" ? undefined : freightType).CANCEL_SCHEDULE(id),
|
||||
pathsFor(
|
||||
freightType === "MIXED" ? undefined : freightType,
|
||||
).CANCEL_SCHEDULE(id),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getAvailableLocomotives: async (routeId?: string): Promise<LocomotiveRecord[]> => {
|
||||
getAvailableLocomotives: async (
|
||||
routeId?: string,
|
||||
): Promise<LocomotiveRecord[]> => {
|
||||
if (routeId) {
|
||||
const response = await client.get<LocomotiveRecord[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_LOCOMOTIVES,
|
||||
@@ -286,9 +310,12 @@ export const trainSchedulingService = {
|
||||
);
|
||||
return unwrap(response.data);
|
||||
}
|
||||
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
|
||||
params: { status: 'AVAILABLE' },
|
||||
});
|
||||
const response = await client.get<LocomotiveRecord[]>(
|
||||
URL_CONSTANTS.LOCOMOTIVES.BASE,
|
||||
{
|
||||
params: { status: "AVAILABLE" },
|
||||
},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
@@ -380,7 +407,10 @@ export const trainSchedulingService = {
|
||||
}));
|
||||
},
|
||||
|
||||
removeWagonSlot: async (scheduleId: string, wagonId: string): Promise<TrainScheduleDetail> => {
|
||||
removeWagonSlot: async (
|
||||
scheduleId: string,
|
||||
wagonId: string,
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.delete<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.REMOVE_WAGON_SLOT(scheduleId, wagonId),
|
||||
);
|
||||
@@ -392,7 +422,10 @@ export const trainSchedulingService = {
|
||||
itemId: string,
|
||||
payload: { containerNumber: string | null },
|
||||
): Promise<{ id: string; containerNumber: string | null }> => {
|
||||
const response = await client.patch<{ id: string; containerNumber: string | null }>(
|
||||
const response = await client.patch<{
|
||||
id: string;
|
||||
containerNumber: string | null;
|
||||
}>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.UPDATE_CONTAINER_ITEM(scheduleId, itemId),
|
||||
payload,
|
||||
);
|
||||
|
||||
55
apps/edr-freight-web/backoffice/src/types/@tria-plc__iamui.d.ts
vendored
Normal file
55
apps/edr-freight-web/backoffice/src/types/@tria-plc__iamui.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
@@ -146,14 +146,12 @@ export interface TrainScheduleListItem {
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
freightType?: FreightType | null;
|
||||
locomotive:
|
||||
| {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
currentYardId?: string | null;
|
||||
}
|
||||
| null;
|
||||
locomotive: {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
currentYardId?: string | null;
|
||||
} | null;
|
||||
wagonCount: number;
|
||||
totalWeightTons: number;
|
||||
totalLengthMeters: number;
|
||||
@@ -316,7 +314,6 @@ export interface TrainScheduleWagonAllocation {
|
||||
export interface TrainScheduleDetail {
|
||||
id: string;
|
||||
status: TrainScheduleStatus | string;
|
||||
warnings?: string[];
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
freightType?: FreightType | null;
|
||||
trainNumber?: string | null;
|
||||
@@ -378,6 +375,7 @@ export interface TrainScheduleDetail {
|
||||
weightTons: number;
|
||||
status: string | null;
|
||||
schedulingStatus?: SchedulingStatus | null;
|
||||
freightType?: FreightType | string | null;
|
||||
}>;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
export interface OperationsQueueGroups {
|
||||
government: BookingListRow[];
|
||||
commercial: ThreeHourBookingBucket[];
|
||||
commercial: ThreeHourBookingBucket<BookingListRow>[];
|
||||
}
|
||||
|
||||
export function groupBookingsForOperationsQueue(
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"useDefineForClassFields": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@tria-plc/iamui": ["./src/types/@tria-plc__iamui.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Input } from "@mantine/core";
|
||||
import { forwardRef } from "react";
|
||||
import { Input, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Controller,
|
||||
type Control,
|
||||
@@ -32,17 +31,6 @@ export const toEthiopianE164 = (raw?: string | null): string => {
|
||||
return `+251${digits}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The text input rendered inside react-phone-number-input, styled to match the
|
||||
* portal's Mantine fields (44px height, 10px radius, edr border). Must forward
|
||||
* the ref and accept native input props for the library to drive it.
|
||||
*/
|
||||
const StyledInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
function StyledInput(props, ref) {
|
||||
return <input {...props} ref={ref} className="edr-phone-input" />;
|
||||
},
|
||||
);
|
||||
|
||||
export interface PhoneFieldProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
@@ -75,10 +63,10 @@ export function PhoneField({
|
||||
required={required}
|
||||
error={error}
|
||||
styles={{
|
||||
label: { fontWeight: 600, fontSize: 13, color: "#10202F", marginBottom: 6 },
|
||||
label: { fontWeight: 600, fontSize: 14, color: "#10202F", },
|
||||
}}
|
||||
>
|
||||
<div className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}>
|
||||
<div className={`edr-phone-wrapper ${error ? " edr-phone-wrapper--error" : ""}`}>
|
||||
<RPNInput
|
||||
international
|
||||
defaultCountry="ET"
|
||||
@@ -86,10 +74,9 @@ export function PhoneField({
|
||||
addInternationalOption
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
inputComponent={TextInput}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputComponent={StyledInput}
|
||||
/>
|
||||
</div>
|
||||
</Input.Wrapper>
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
import { Modal, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Globe2,
|
||||
UploadCloud,
|
||||
User,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
|
||||
import NationalitySelect from "@/pages/settings/NationalitySelect";
|
||||
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyNationality,
|
||||
@@ -12,12 +37,8 @@ import type {
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import type { UpdateProfilePayload } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
|
||||
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
|
||||
import NationalitySelect from "@/pages/settings/NationalitySelect";
|
||||
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
|
||||
|
||||
/** Form steps shared by CompanyProfileForm and ForwarderForm. */
|
||||
/** Form steps rendered by CompanyProfileForm. */
|
||||
type FormStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
@@ -34,6 +55,58 @@ const FORM_STEPS: FormStep[] = [
|
||||
"additional",
|
||||
];
|
||||
|
||||
/** The full onboarding journey: the two pre-form phases + the form steps. */
|
||||
type WizardStep = "nationality" | "role" | FormStep;
|
||||
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS];
|
||||
|
||||
/** Icon + title + description shown in the global dialog header per step. */
|
||||
const STEP_META: Record<
|
||||
WizardStep,
|
||||
{ icon: ReactNode; title: string; description: string }
|
||||
> = {
|
||||
nationality: {
|
||||
icon: <Globe2 size={20} />,
|
||||
title: "Where is your company registered?",
|
||||
description: "This determines the documents we'll ask you to provide.",
|
||||
},
|
||||
role: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "What does your company do?",
|
||||
description:
|
||||
"Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.",
|
||||
},
|
||||
company: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "Company Information",
|
||||
description: "Tell us about your company and its registration details.",
|
||||
},
|
||||
personnel: {
|
||||
icon: <User size={20} />,
|
||||
title: "General Manager",
|
||||
description: "Who is the general manager of the company?",
|
||||
},
|
||||
contact: {
|
||||
icon: <UserCheck size={20} />,
|
||||
title: "Contact Person",
|
||||
description: "Who should we reach out to about this account?",
|
||||
},
|
||||
poa: {
|
||||
icon: <FileText size={20} />,
|
||||
title: "Power of Attorney",
|
||||
description: "Optionally add a representative with power of attorney.",
|
||||
},
|
||||
documents: {
|
||||
icon: <UploadCloud size={20} />,
|
||||
title: "Upload Documents",
|
||||
description: "Provide the required company documents.",
|
||||
},
|
||||
additional: {
|
||||
icon: <CheckCircle2 size={20} />,
|
||||
title: "Business License",
|
||||
description: "Upload a business license for each operational profile.",
|
||||
},
|
||||
};
|
||||
|
||||
interface OnboardingWizardDialogProps {
|
||||
opened: boolean;
|
||||
/** Dismiss the dialog (user clicked the close icon). */
|
||||
@@ -98,6 +171,9 @@ export default function OnboardingWizardDialog({
|
||||
// Newly-selected business-license files per company_profile id.
|
||||
const [licenseFiles, setLicenseFiles] = useState<Record<string, File[]>>({});
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
// Mirror of CompanyProfileForm's active step so the global header + progress
|
||||
// pill can reflect it (the form no longer renders its own stepper).
|
||||
const [formStep, setFormStep] = useState<FormStep>(resumeFormStep);
|
||||
|
||||
// Saved profile data, for rehydrating the form fields after a refresh.
|
||||
const profileQuery = useQuery(
|
||||
@@ -164,6 +240,15 @@ export default function OnboardingWizardDialog({
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Mirror the form's step locally (for the header/pill) and persist it.
|
||||
const handleStepChange = useCallback(
|
||||
(step: string) => {
|
||||
setFormStep(step as FormStep);
|
||||
persistStep(step);
|
||||
},
|
||||
[persistStep],
|
||||
);
|
||||
|
||||
// The company query may resolve AFTER this dialog mounts (it's kept mounted by
|
||||
// the gate), so the phase/roles/nationality initial state can be stale — a
|
||||
// draft that already exists would otherwise leave us stuck on the first
|
||||
@@ -239,12 +324,10 @@ export default function OnboardingWizardDialog({
|
||||
existingFiles: p.licenseFiles ?? [],
|
||||
}));
|
||||
|
||||
const titleHint =
|
||||
phase === "nationality"
|
||||
? "Where is your company registered?"
|
||||
: phase === "role"
|
||||
? "Tell us what your company does to get started."
|
||||
: "Set up your company profile to finish.";
|
||||
// The active step across the whole journey, driving the header + progress pill.
|
||||
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
||||
const stepMeta = STEP_META[activeStep];
|
||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||
|
||||
const formProps = {
|
||||
documentSettingCode: documentSettingCode(effectiveNationality),
|
||||
@@ -257,7 +340,7 @@ export default function OnboardingWizardDialog({
|
||||
hideFirstStepBack: true,
|
||||
initialStep: resumeFormStep,
|
||||
resyncOpen: opened,
|
||||
onStepChange: persistStep,
|
||||
onStepChange: handleStepChange,
|
||||
onSaveStep: saveStep,
|
||||
rehydrate: profileQuery.data ?? null,
|
||||
roleProfiles,
|
||||
@@ -279,63 +362,98 @@ export default function OnboardingWizardDialog({
|
||||
keepMounted
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
styles={{
|
||||
header: {
|
||||
alignItems:"flex-start"
|
||||
},
|
||||
title: {
|
||||
flex: 1
|
||||
}
|
||||
}}
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
|
||||
Complete your onboarding
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{titleHint}
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Group gap="sm" mb={4}>
|
||||
{stepMeta.icon}
|
||||
<Title order={3}>{stepMeta.title}</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm">
|
||||
{stepMeta.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect value={nationality} onChange={setNationality} />
|
||||
<RoleContinueBar
|
||||
disabled={!nationality}
|
||||
onClick={handleNationalityContinue}
|
||||
/>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} />
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<RoleContinueBar
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
onClick={handleRolesContinue}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
<Stack gap="xl">
|
||||
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : <ArrowRight size={16} />
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleContinueBar({
|
||||
disabled,
|
||||
loading,
|
||||
onClick,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
/**
|
||||
* Continuous progress pill: a single rounded track that fills left-to-right as
|
||||
* the user advances, with faint ticks marking each step boundary.
|
||||
*/
|
||||
function ProgressPill({ current, total }: { current: number; total: number }) {
|
||||
const pct = total > 0 ? ((current + 1) / total) * 100 : 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Setting up…" : "Continue"}
|
||||
</button>
|
||||
<Box className="relative h-1.5 w-full overflow-hidden rounded-full bg-edr-border">
|
||||
<Box
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-[var(--mantine-color-edr-green-6)] transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
.edr-phone-wrapper .PhoneInputCountry {
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
height: 44px;
|
||||
border: 1px solid #e6ecf2;
|
||||
border-radius: 10px;
|
||||
height: 2.25rem;
|
||||
border: 0.0625rem solid #b0bfce;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -18,12 +16,6 @@ import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
FileText,
|
||||
UploadCloud,
|
||||
User,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -559,7 +551,6 @@ export default function CompanyProfileForm({
|
||||
"documents",
|
||||
"additional",
|
||||
];
|
||||
const totalSteps = stepOrder.length;
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
/** Validate + persist the current step, returning whether we may advance. */
|
||||
@@ -617,79 +608,8 @@ export default function CompanyProfileForm({
|
||||
// selection); otherwise always available.
|
||||
const showBack = !(hideFirstStepBack && step === "company");
|
||||
|
||||
const STEP_ICONS: Record<CompanyStep, React.ReactNode> = {
|
||||
company: <Building2 size={18} />,
|
||||
personnel: <User size={18} />,
|
||||
contact: <UserCheck size={18} />,
|
||||
poa: <FileText size={18} />,
|
||||
documents: <UploadCloud size={18} />,
|
||||
additional: <CheckCircle2 size={18} />,
|
||||
};
|
||||
|
||||
const STEP_TITLES: Record<CompanyStep, string> = {
|
||||
company: "Company Information",
|
||||
personnel: "General Manager",
|
||||
contact: "Contact Person",
|
||||
poa: "Power of Attorney (Optional)",
|
||||
documents: "Upload Documents",
|
||||
additional: "Business License",
|
||||
};
|
||||
|
||||
const stepLabel = `Step ${currentIdx + 1} of ${totalSteps} — ${STEP_TITLES[step]}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mb="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
c="edr-muted"
|
||||
size="sm"
|
||||
mb="sm"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={prevStep}
|
||||
>
|
||||
Change account type
|
||||
</Button>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
className="relative max-w-lg mx-auto px-2"
|
||||
>
|
||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
||||
{stepOrder.map((key, i) => {
|
||||
const done = i < currentIdx;
|
||||
const active = i === currentIdx;
|
||||
return done || active ? (
|
||||
<ThemeIcon
|
||||
key={key}
|
||||
size={40}
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
className="relative z-10"
|
||||
>
|
||||
{done ? <CheckCircle2 size={18} /> : STEP_ICONS[key]}
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<Box
|
||||
key={key}
|
||||
w={40}
|
||||
h={40}
|
||||
c="edr-slate"
|
||||
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
|
||||
>
|
||||
{STEP_ICONS[key]}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center" mt="sm">
|
||||
{stepLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "company" && (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import RoleCard from "./RoleCard";
|
||||
interface NationalitySelectProps {
|
||||
value: CompanyNationality | null;
|
||||
onChange: (next: CompanyNationality) => void;
|
||||
/** Render only the option grid — the wizard supplies its own header/card. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,29 @@ interface NationalitySelectProps {
|
||||
export default function NationalitySelect({
|
||||
value,
|
||||
onChange,
|
||||
embedded = false,
|
||||
}: NationalitySelectProps) {
|
||||
const grid = (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<RoleCard
|
||||
label="Ethiopian Company"
|
||||
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
|
||||
icon={<MapPin size={22} />}
|
||||
selected={value === "ethiopian"}
|
||||
onClick={() => onChange("ethiopian")}
|
||||
/>
|
||||
<RoleCard
|
||||
label="Foreign Company"
|
||||
description="Registered abroad. You'll provide a passport and investment license."
|
||||
icon={<Globe2 size={22} />}
|
||||
selected={value === "foreign"}
|
||||
onClick={() => onChange("foreign")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
if (embedded) return grid;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
@@ -28,23 +52,7 @@ export default function NationalitySelect({
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
This determines the documents we'll ask you to provide.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<RoleCard
|
||||
label="Ethiopian Company"
|
||||
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
|
||||
icon={<MapPin size={22} />}
|
||||
selected={value === "ethiopian"}
|
||||
onClick={() => onChange("ethiopian")}
|
||||
/>
|
||||
<RoleCard
|
||||
label="Foreign Company"
|
||||
description="Registered abroad. You'll provide a passport and investment license."
|
||||
icon={<Globe2 size={22} />}
|
||||
selected={value === "foreign"}
|
||||
onClick={() => onChange("foreign")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{grid}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ interface OnboardingRoleSelectProps {
|
||||
/** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
/** Render only the option grid — the wizard supplies its own header/card. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,7 @@ interface OnboardingRoleSelectProps {
|
||||
export default function OnboardingRoleSelect({
|
||||
value,
|
||||
onChange,
|
||||
embedded = false,
|
||||
}: OnboardingRoleSelectProps) {
|
||||
const selected = new Set(value);
|
||||
|
||||
@@ -29,6 +32,23 @@ export default function OnboardingRoleSelect({
|
||||
onChange([...next]);
|
||||
};
|
||||
|
||||
const grid = (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{CUSTOMER_ROLES.map((role) => (
|
||||
<RoleCard
|
||||
key={role.type}
|
||||
label={role.label}
|
||||
description={role.description}
|
||||
icon={role.icon}
|
||||
selected={selected.has(role.type)}
|
||||
onClick={() => toggleRole(role.type)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
if (embedded) return grid;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
@@ -39,19 +59,7 @@ export default function OnboardingRoleSelect({
|
||||
Pick any combination of Importer, Exporter and Freight Forwarder — each
|
||||
is set up with its own business license.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{CUSTOMER_ROLES.map((role) => (
|
||||
<RoleCard
|
||||
key={role.type}
|
||||
label={role.label}
|
||||
description={role.description}
|
||||
icon={role.icon}
|
||||
selected={selected.has(role.type)}
|
||||
onClick={() => toggleRole(role.type)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{grid}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
Grid,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CreateCompanyPayload,
|
||||
CompanyProfileInput,
|
||||
CompanyProfileInput,
|
||||
CreateCompanyPayload,
|
||||
} from "@/services/companies.service";
|
||||
import CompanyRolesCard from "./CompanyRolesCard";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
@@ -143,9 +142,7 @@ export default function TabCompanyProfile({
|
||||
value={selectedRoles}
|
||||
onChange={setSelectedRoles}
|
||||
/>
|
||||
) : (
|
||||
profile && <CompanyRolesCard profile={profile} />
|
||||
)}
|
||||
) : null}
|
||||
{showForm && (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
AuthUser,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
} from "@/types/auth";
|
||||
import { client } from "@/utils/api";
|
||||
import { ApiResponse } from "@edr/types";
|
||||
import type {
|
||||
AuthUser,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
} from "@/types/auth";
|
||||
|
||||
export const authService = {
|
||||
login: async (body: LoginPayload) => {
|
||||
const res = await client.post<ApiResponse<LoginResponse>>(
|
||||
const res = await client.post<LoginResponse>(
|
||||
URL_CONSTANTS.AUTH.LOGIN,
|
||||
body,
|
||||
);
|
||||
return res.data.data;
|
||||
return res.data;
|
||||
},
|
||||
|
||||
createUser: async (body: SignupPayload) => {
|
||||
const res = await client.post<ApiResponse<SignupResponse>>(
|
||||
const res = await client.post<SignupResponse & ApiResponse<void>> (
|
||||
URL_CONSTANTS.USERS.SIGN_UP,
|
||||
body,
|
||||
);
|
||||
return res.data.data;
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getMyInfo: async () => {
|
||||
const res = await client.get<ApiResponse<AuthUser>>(
|
||||
const res = await client.get<AuthUser>(
|
||||
URL_CONSTANTS.USERS.ME,
|
||||
);
|
||||
return res.data.data;
|
||||
return res.data;
|
||||
},
|
||||
|
||||
generateVerificationCode: async (body: GenerateVerificationCodePayload) => {
|
||||
|
||||
42
packages/api-common/src/constants/apiModules.ts
Normal file
42
packages/api-common/src/constants/apiModules.ts
Normal 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",
|
||||
];
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { Readable } from "stream";
|
||||
import { Observable } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
import { flatResponseModules } from "../constants/apiModules";
|
||||
|
||||
export interface StandardResponse<T> {
|
||||
success: true;
|
||||
@@ -33,6 +34,25 @@ export class ResponseTransformInterceptor<T> implements NestInterceptor<
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
const request = _context.switchToHttp().getRequest();
|
||||
const path = request.route?.path ?? request.originalUrl ?? "";
|
||||
|
||||
const shouldFlatten = flatResponseModules.some((module) =>
|
||||
path.startsWith(module),
|
||||
);
|
||||
|
||||
if (
|
||||
shouldFlatten &&
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
!Array.isArray(data)
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
...data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user