mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: type errors
This commit is contained in:
@@ -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" }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { getNextInventoryAction } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import type {
|
||||
InventoryAction,
|
||||
WarehouseInventoryItem,
|
||||
} from "@/types/warehouse";
|
||||
import { InventoryStatusBadge } from "./badges";
|
||||
import { formatDate, formatNumber, humanizeEnum } from "./options";
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -27,21 +29,21 @@ interface WarehouseInventoryTableProps {
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
if (item.containerId) return { label: 'Container', color: 'blue' };
|
||||
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
|
||||
if (item.goodsId) return { label: 'Goods', color: 'orange' };
|
||||
return { label: '—', color: 'gray' };
|
||||
if (item.containerId) return { label: "Container", color: "blue" };
|
||||
if (item.cargoId) return { label: "Cargo", color: "grape" };
|
||||
if (item.goodsId) return { label: "Goods", color: "orange" };
|
||||
return { label: "—", color: "gray" };
|
||||
};
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
'ready-for-pickup': 'orange',
|
||||
release: 'yellow',
|
||||
deliver: 'green',
|
||||
store: "blue",
|
||||
reserve: "grape",
|
||||
"ready-for-loading": "cyan",
|
||||
load: "teal",
|
||||
dispatch: "edr-green",
|
||||
"ready-for-pickup": "orange",
|
||||
release: "yellow",
|
||||
deliver: "green",
|
||||
};
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
@@ -62,8 +64,8 @@ export function WarehouseInventoryTable({
|
||||
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'booking',
|
||||
header: 'Booking',
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) =>
|
||||
row.original.bookingId ? (
|
||||
<Tooltip label={row.original.bookingId} withArrow>
|
||||
@@ -78,16 +80,28 @@ export function WarehouseInventoryTable({
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'facility',
|
||||
header: 'Facility',
|
||||
cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—',
|
||||
id: "facility",
|
||||
header: "Facility",
|
||||
cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—",
|
||||
},
|
||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
|
||||
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.code ?? '—' },
|
||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
|
||||
{
|
||||
id: 'item',
|
||||
header: 'Item',
|
||||
id: "warehouse",
|
||||
header: "Warehouse",
|
||||
cell: ({ row }) => row.original.warehouse?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "yard",
|
||||
header: "Yard",
|
||||
cell: ({ row }) => row.original.yard?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "zone",
|
||||
header: "Zone",
|
||||
cell: ({ row }) => row.original.zone?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
header: "Item",
|
||||
cell: ({ row }) => {
|
||||
const kind = itemKind(row.original);
|
||||
return (
|
||||
@@ -97,27 +111,44 @@ export function WarehouseInventoryTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
|
||||
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
|
||||
id: "qty",
|
||||
header: "Qty",
|
||||
cell: ({ row }) => formatNumber(row.original.quantity),
|
||||
},
|
||||
{
|
||||
id: 'arrived',
|
||||
header: 'Arrived',
|
||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
|
||||
id: "weight",
|
||||
header: "Weight",
|
||||
cell: ({ row }) => formatNumber(row.original.weight),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<InventoryStatusBadge status={row.original.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "arrived",
|
||||
header: "Arrived",
|
||||
cell: ({ row }) => (
|
||||
<Text size="xs">{formatDate(row.original.arrivedAt)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = INVENTORY_NEXT_ACTION[item.status];
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<Group
|
||||
gap="xs"
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{nextAction && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
@@ -126,32 +157,48 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{humanizeEnum(nextAction.replace(/-/g, "_"))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
{item.status !== "DISPATCHED" && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onMove(item)}
|
||||
>
|
||||
<ArrowRightLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onInspect && (
|
||||
<Tooltip label="Inspection / Report" withArrow>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => onInspect(item)}
|
||||
>
|
||||
<ClipboardList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFeePreview && (
|
||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
onClick={() => onFeePreview(item)}
|
||||
>
|
||||
<Coins size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onHistory(item)}
|
||||
>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,18 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Box, Card, Button, Modal, Stack, Group, Text, List, Loader } from "@mantine/core";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Button,
|
||||
Modal,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
List,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
@@ -60,16 +70,17 @@ const RuleEngineResourcePage = () => {
|
||||
const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined;
|
||||
|
||||
const defaultPath = category
|
||||
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${
|
||||
category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
|
||||
}`
|
||||
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
|
||||
}`
|
||||
: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`;
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
|
||||
null,
|
||||
);
|
||||
const [chainOpen, setChainOpen] = useState(false);
|
||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||
@@ -90,9 +101,9 @@ const RuleEngineResourcePage = () => {
|
||||
pageSize: pagination.pageSize,
|
||||
...(config?.orderConfig
|
||||
? {
|
||||
sortBy: config.orderConfig.field,
|
||||
sortOrder: "ASC" as const,
|
||||
}
|
||||
sortBy: config.orderConfig.field,
|
||||
sortOrder: "ASC" as const,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[
|
||||
@@ -120,11 +131,12 @@ const RuleEngineResourcePage = () => {
|
||||
const { reorder, moveOrder } = useRuleEngineOrderMutations(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
);
|
||||
const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(orderDialogOpen && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
const { data: orderListData, isLoading: orderListLoading } =
|
||||
useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(orderDialogOpen && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
const { submit, approve } = useRateWorkflow();
|
||||
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
|
||||
chainOpen && config?.slug === "approval-rules",
|
||||
@@ -141,10 +153,7 @@ const RuleEngineResourcePage = () => {
|
||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
||||
useContainerTypeOptions(
|
||||
config?.slug === "rates",
|
||||
usesContainerTypeField,
|
||||
);
|
||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
|
||||
@@ -154,10 +163,9 @@ const RuleEngineResourcePage = () => {
|
||||
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
|
||||
return {
|
||||
...field,
|
||||
options:
|
||||
cargoParentOptions ?? [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
],
|
||||
options: cargoParentOptions ?? [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (field.name === "containerTypeId") {
|
||||
@@ -183,14 +191,16 @@ const RuleEngineResourcePage = () => {
|
||||
const pageCount = meta?.totalPages ?? 1;
|
||||
const totalCount = meta?.total ?? rows.length;
|
||||
|
||||
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(formOpen && !editing && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
const { data: createPositionList, isLoading: createPositionLoading } =
|
||||
useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(formOpen && !editing && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
|
||||
const createPositionOptions = useMemo(() => {
|
||||
if (!config?.orderConfig || !createPositionList?.data?.length) return undefined;
|
||||
if (!config?.orderConfig || !createPositionList?.data?.length)
|
||||
return undefined;
|
||||
return createPositionList.data
|
||||
.filter((row) => row.id)
|
||||
.map((row) => ({
|
||||
@@ -199,7 +209,6 @@ const RuleEngineResourcePage = () => {
|
||||
}));
|
||||
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
|
||||
|
||||
|
||||
const handleApproveRate = useCallback(
|
||||
(record: RuleEngineRecord) => {
|
||||
approve.mutate(String(record.id));
|
||||
@@ -259,7 +268,9 @@ const RuleEngineResourcePage = () => {
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
config.slug === "approval-rules"
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
@@ -270,7 +281,15 @@ const RuleEngineResourcePage = () => {
|
||||
});
|
||||
|
||||
return base;
|
||||
}, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]);
|
||||
}, [
|
||||
canManage,
|
||||
config,
|
||||
submit,
|
||||
handleApproveRate,
|
||||
handleMoveOrder,
|
||||
moveOrder.isPending,
|
||||
totalCount,
|
||||
]);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -350,15 +369,20 @@ const RuleEngineResourcePage = () => {
|
||||
onSearchChange={
|
||||
config.supportsSearch
|
||||
? (v) => {
|
||||
setSearch(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}
|
||||
setSearch(v);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showSearch={Boolean(config.supportsSearch)}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
onManageOrder={
|
||||
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
|
||||
canManage && config.orderConfig
|
||||
? () => setOrderDialogOpen(true)
|
||||
: undefined
|
||||
}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
@@ -373,10 +397,12 @@ const RuleEngineResourcePage = () => {
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load data",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
message: "Failed to load data",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage={`No ${itemLabel} found.`}
|
||||
@@ -422,7 +448,9 @@ const RuleEngineResourcePage = () => {
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
config.slug === "approval-rules"
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
@@ -434,7 +462,11 @@ const RuleEngineResourcePage = () => {
|
||||
<RuleEngineFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
title={editing ? `Edit ${config.label.replace(/s$/, "")}` : `Add ${config.label.replace(/s$/, "")}`}
|
||||
title={
|
||||
editing
|
||||
? `Edit ${config.label.replace(/s$/, "")}`
|
||||
: `Add ${config.label.replace(/s$/, "")}`
|
||||
}
|
||||
description={
|
||||
editing
|
||||
? `Update this ${config.label.toLowerCase()} record.`
|
||||
@@ -478,7 +510,8 @@ const RuleEngineResourcePage = () => {
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
This will soft-delete the selected {config.label.toLowerCase()} record.
|
||||
This will soft-delete the selected {config.label.toLowerCase()}{" "}
|
||||
record.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
@@ -515,14 +548,17 @@ const RuleEngineResourcePage = () => {
|
||||
) : (
|
||||
<>
|
||||
{(chainData ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">No approval rules configured.</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
No approval rules configured.
|
||||
</Text>
|
||||
) : (
|
||||
<List spacing="md">
|
||||
{(chainData ?? []).map((step, index) => (
|
||||
<List.Item key={String(step.id ?? index)}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
|
||||
Step {String(step.stepOrder ?? index + 1)}:{" "}
|
||||
{String(step.actionLabel ?? "")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Role: {String(step.requiredRole ?? "—")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"useDefineForClassFields": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@tria-plc/iamui": ["./src/types/@tria-plc__iamui.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
Reference in New Issue
Block a user