mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -1,330 +0,0 @@
|
||||
// components/baselineRatematrix/RateMatrixForm.tsx
|
||||
import React, { useState, useCallback } from 'react';
|
||||
// import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react';
|
||||
import { RateTypeSection } from './RateTypeSection';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ValidationSummary } from './ValidationSummary';
|
||||
import { LoadingScreen } from '@/ui/LoadingScreen';
|
||||
import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
|
||||
import { useReferenceData } from '@/hooks/useReferenceData';
|
||||
import { queryKeys } from '@/constants/queryKeys';
|
||||
import { API_URLS } from '@/constants/apiUrls';
|
||||
import {
|
||||
RATE_TYPES,
|
||||
RATE_TYPE_LABELS,
|
||||
REQUIRED_RATE_TYPES
|
||||
} from '@/constants/rateMatrixConstants';
|
||||
import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules';
|
||||
import type { RateEntry } from './types';
|
||||
|
||||
const formSchema = z.object({
|
||||
matrixName: z.string().min(1, 'Matrix name is required').max(200),
|
||||
effectiveDate: z.string().min(1, 'Effective date is required'),
|
||||
expiryDate: z.string().optional(),
|
||||
currency: z.string().min(1, 'Currency is required'),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const createInitialSections = (): RateEntry[] => {
|
||||
return REQUIRED_RATE_TYPES.map(rateType => ({
|
||||
rateType,
|
||||
entries: [{
|
||||
validFrom: '',
|
||||
validTo: '',
|
||||
}],
|
||||
}));
|
||||
};
|
||||
|
||||
export function RateMatrixForm() {
|
||||
const [rateSections, setRateSections] = useState<RateEntry[]>(createInitialSections());
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [savedMatrixId, setSavedMatrixId] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<any[]>([]);
|
||||
|
||||
const { isDirector } = useRateMatrixAuth();
|
||||
const { data: referenceData, isLoading: isLoadingReference } = useReferenceData();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
matrixName: '',
|
||||
effectiveDate: '',
|
||||
expiryDate: '',
|
||||
currency: 'USD',
|
||||
},
|
||||
});
|
||||
|
||||
// Save draft mutation
|
||||
const saveDraftMutation = useMutation({
|
||||
mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => {
|
||||
const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save draft');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSavedMatrixId(data.id);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
|
||||
toast.success('Draft saved successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to save draft');
|
||||
},
|
||||
});
|
||||
|
||||
// Submit for approval mutation
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async (matrixId: string) => {
|
||||
const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to submit');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
|
||||
toast.success('Rate matrix submitted for executive approval and locked!');
|
||||
setShowConfirmation(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to submit for approval');
|
||||
setShowConfirmation(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleValidate = useCallback(() => {
|
||||
const validation = rateMatrixRulesEngine.validate(rateSections);
|
||||
setValidationErrors([...validation.errors, ...validation.warnings]);
|
||||
|
||||
if (validation.isValid) {
|
||||
toast.success('All validations passed!');
|
||||
}
|
||||
}, [rateSections]);
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
const formData = form.getValues();
|
||||
await saveDraftMutation.mutateAsync({
|
||||
...formData,
|
||||
rateSections,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitClick = async () => {
|
||||
const isFormValid = await form.trigger();
|
||||
if (!isFormValid) return;
|
||||
|
||||
const validation = rateMatrixRulesEngine.validate(rateSections);
|
||||
setValidationErrors([...validation.errors, ...validation.warnings]);
|
||||
|
||||
if (!validation.isValid) {
|
||||
toast.error('Please fix validation errors before submitting');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowConfirmation(true);
|
||||
};
|
||||
|
||||
const handleConfirmSubmit = async () => {
|
||||
const formData = form.getValues();
|
||||
|
||||
try {
|
||||
let matrixId = savedMatrixId;
|
||||
|
||||
if (!matrixId) {
|
||||
const draftResult = await saveDraftMutation.mutateAsync({
|
||||
...formData,
|
||||
rateSections,
|
||||
});
|
||||
matrixId = draftResult.id;
|
||||
}
|
||||
|
||||
await submitMutation.mutateAsync(matrixId!);
|
||||
} catch (error) {
|
||||
// Error handling done in mutations
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingReference) {
|
||||
return <LoadingScreen message="Loading reference data..." />;
|
||||
}
|
||||
|
||||
if (!isDirector) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Access Denied</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only Directors can access the rate matrix registration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Baseline Rate Matrix Registration
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Submit a comprehensive rate matrix for executive approval
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Director Warning */}
|
||||
<Alert variant="warning" className="mb-6 border-amber-500 bg-amber-50">
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertTitle>Director Notice</AlertTitle>
|
||||
<AlertDescription>
|
||||
Once submitted, this matrix will be locked pending Chief Executive approval.
|
||||
No edits can be made by any user until authorization is granted.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
{/* Matrix Metadata */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Matrix Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="matrixName">Matrix Name *</Label>
|
||||
<Input
|
||||
id="matrixName"
|
||||
{...form.register('matrixName')}
|
||||
placeholder="e.g., Q4 2026 Baseline Matrix"
|
||||
className={form.formState.errors.matrixName ? 'border-destructive' : ''}
|
||||
/>
|
||||
{form.formState.errors.matrixName && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.matrixName.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="effectiveDate">Effective Date *</Label>
|
||||
<Input
|
||||
id="effectiveDate"
|
||||
type="date"
|
||||
{...form.register('effectiveDate')}
|
||||
className={form.formState.errors.effectiveDate ? 'border-destructive' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiryDate">Expiry Date</Label>
|
||||
<Input
|
||||
id="expiryDate"
|
||||
type="date"
|
||||
{...form.register('expiryDate')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency">Currency *</Label>
|
||||
<select
|
||||
id="currency"
|
||||
{...form.register('currency')}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
|
||||
>
|
||||
{referenceData?.currencies?.map((currency: any) => (
|
||||
<option key={currency.code} value={currency.code}>
|
||||
{currency.code} - {currency.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rate Type Sections */}
|
||||
<div className="space-y-6">
|
||||
{rateSections.map((section, index) => (
|
||||
<RateTypeSection
|
||||
key={section.rateType}
|
||||
section={section}
|
||||
sectionIndex={index}
|
||||
onUpdate={(updatedSection) => {
|
||||
const newSections = [...rateSections];
|
||||
newSections[index] = updatedSection;
|
||||
setRateSections(newSections);
|
||||
}}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Validation Errors */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<ValidationSummary errors={validationErrors} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="sticky bottom-6 mt-8 p-6 bg-background border rounded-lg shadow-lg flex gap-4 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={saveDraftMutation.isPending}
|
||||
>
|
||||
{saveDraftMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save as Draft
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleValidate}
|
||||
>
|
||||
Validate All Rates
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmitClick}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Submit for Executive Approval
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={showConfirmation}
|
||||
onOpenChange={setShowConfirmation}
|
||||
onConfirm={handleConfirmSubmit}
|
||||
isLoading={submitMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,20 +21,6 @@ import type {
|
||||
BookingListSummaryTabs,
|
||||
} from "@/services/bookings.service";
|
||||
|
||||
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
|
||||
const PIPELINE_STAGES: Array<{
|
||||
key: keyof BookingListSummaryTabs;
|
||||
label: string;
|
||||
color: string;
|
||||
}> = [
|
||||
{ key: "intake", label: "Intake", color: "#38bdf8" },
|
||||
{ key: "in_approval", label: "Approval", color: "#f59e0b" },
|
||||
{ key: "approved_contract", label: "Contract", color: "#8b5cf6" },
|
||||
{ key: "payment", label: "Payment", color: "#fb923c" },
|
||||
{ key: "operations", label: "Operations", color: "#14b8a6" },
|
||||
{ key: "completed", label: "Completed", color: "#22c55e" },
|
||||
];
|
||||
|
||||
const CARD_STYLE = {
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
@@ -62,7 +48,12 @@ export function BookingRequestsHeader({
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button color="edr-green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
leftSection={<Plus size={18} />}
|
||||
onClick={onCreate}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
@@ -90,7 +81,9 @@ export function BookingRequestsHeader({
|
||||
label="Needs action"
|
||||
value={val(metrics?.needsAction)}
|
||||
hint="Submitted or pending"
|
||||
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
|
||||
ratio={
|
||||
metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0
|
||||
}
|
||||
accent="orange"
|
||||
/>
|
||||
<HeroStat
|
||||
@@ -137,12 +130,18 @@ function HeroStat({
|
||||
accent?: OverviewAccent;
|
||||
variant?: "area" | "line";
|
||||
}) {
|
||||
const [, accentDeep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const [, accentDeep] =
|
||||
overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
|
||||
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
|
||||
const pct =
|
||||
ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box
|
||||
@@ -161,7 +160,13 @@ function HeroStat({
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
|
||||
<Text
|
||||
fw={800}
|
||||
size="24px"
|
||||
lh={1.05}
|
||||
style={{ color: "#0f172a" }}
|
||||
truncate
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||
@@ -179,66 +184,15 @@ function HeroStat({
|
||||
</Group>
|
||||
|
||||
{pct == null ? (
|
||||
<MiniSparkline variant={variant} accent={accent} baseline={0.5} seed={label} height={22} />
|
||||
<MiniSparkline
|
||||
variant={variant}
|
||||
accent={accent}
|
||||
baseline={0.5}
|
||||
seed={label}
|
||||
height={22}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 }));
|
||||
const total = segments.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="lg" style={CARD_STYLE}>
|
||||
<Group justify="space-between" mb={10}>
|
||||
<Text size="sm" fw={700} style={{ color: "#0f172a" }}>
|
||||
Booking pipeline
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} active
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
height: 14,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
background: "var(--mantine-color-gray-2)",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{total > 0 ? (
|
||||
segments.map((s) =>
|
||||
s.count > 0 ? (
|
||||
<Box
|
||||
key={s.key}
|
||||
title={`${s.label}: ${s.count}`}
|
||||
style={{ width: `${(s.count / total) * 100}%`, background: s.color, transition: "width 200ms ease" }}
|
||||
/>
|
||||
) : null,
|
||||
)
|
||||
) : (
|
||||
<Box style={{ width: "100%" }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group gap="md" mt={10} wrap="wrap">
|
||||
{segments.map((s) => (
|
||||
<Group key={s.key} gap={6} wrap="nowrap">
|
||||
<Box style={{ width: 9, height: 9, borderRadius: 3, background: s.color }} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={700} style={{ color: "#0f172a" }}>
|
||||
{s.count}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -77,7 +85,12 @@ export function BookingRequestHero({
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: 1, color: "#B26C09" }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "#B26C09" }}
|
||||
>
|
||||
Booking reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
@@ -99,8 +112,14 @@ export function BookingRequestHero({
|
||||
|
||||
<Group gap="lg" mt={4}>
|
||||
<MetaItem icon={Building2} text={customerLabel} strong />
|
||||
<MetaItem icon={Calendar} text={`Scheduled ${booking.scheduledDate}`} />
|
||||
<MetaItem icon={Clock} text={`Created ${formatDate(booking.createdAt)}`} />
|
||||
<MetaItem
|
||||
icon={Calendar}
|
||||
text={`Scheduled ${booking.scheduledDate}`}
|
||||
/>
|
||||
<MetaItem
|
||||
icon={Clock}
|
||||
text={`Created ${formatDate(booking.createdAt)}`}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -110,7 +129,10 @@ export function BookingRequestHero({
|
||||
radius="lg"
|
||||
p={4}
|
||||
maw={640}
|
||||
style={{ background: "var(--mantine-color-gray-0)", border: "1px solid var(--mantine-color-gray-2)" }}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<NextStepBanner nextStep={booking.nextStep} />
|
||||
</Paper>
|
||||
@@ -120,13 +142,22 @@ export function BookingRequestHero({
|
||||
<HeroTile
|
||||
icon={Wallet}
|
||||
label="Total value"
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
minimumFractionDigits: 2,
|
||||
},
|
||||
)}`}
|
||||
hint={booking.paymentStatus}
|
||||
accent="edr-green"
|
||||
/>
|
||||
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
|
||||
<HeroTile
|
||||
icon={Weight}
|
||||
label="Cargo weight"
|
||||
value={`${weight} T`}
|
||||
hint="VGM total"
|
||||
accent="blue"
|
||||
/>
|
||||
<HeroTile
|
||||
icon={ContainerIcon}
|
||||
label="Containers"
|
||||
@@ -195,7 +226,13 @@ function HeroTile({
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: 0.4 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
|
||||
|
||||
@@ -138,6 +138,8 @@ export interface BookingDetailView {
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
pnrCode?: string | null;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartner?: (BookingNamedRefView & { reference?: string }) | null;
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
paymentDeadline?: string | null;
|
||||
createdAt: string;
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
|
||||
import { FLEET_SELECT_NONE, type FleetFormFieldDef } from "@/pages/fleet/config/resources";
|
||||
import {
|
||||
FLEET_SELECT_NONE,
|
||||
type FleetFormFieldDef,
|
||||
} from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
export interface FleetFormDialogProps {
|
||||
@@ -87,12 +89,19 @@ const FleetFormDialog = ({
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
|
||||
if (field.required && (stringValue === "" || stringValue === FLEET_SELECT_NONE)) {
|
||||
if (
|
||||
field.required &&
|
||||
(stringValue === "" || stringValue === FLEET_SELECT_NONE)
|
||||
) {
|
||||
next[field.name] = `${field.label} is required`;
|
||||
}
|
||||
|
||||
// Validate date format (YYYY-MM-DD) - DatePicker ensures this
|
||||
if (field.type === "date" && stringValue && stringValue !== FLEET_SELECT_NONE) {
|
||||
if (
|
||||
field.type === "date" &&
|
||||
stringValue &&
|
||||
stringValue !== FLEET_SELECT_NONE
|
||||
) {
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(stringValue)) {
|
||||
next[field.name] = `${field.label} must be a valid date`;
|
||||
@@ -115,7 +124,8 @@ const FleetFormDialog = ({
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => {
|
||||
if (value === FLEET_SELECT_NONE || value === "") return [key, undefined];
|
||||
if (value === FLEET_SELECT_NONE || value === "")
|
||||
return [key, undefined];
|
||||
return [key, value];
|
||||
})
|
||||
.filter(([, value]) => value !== undefined),
|
||||
@@ -133,20 +143,34 @@ const FleetFormDialog = ({
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
data={field.options ?? []}
|
||||
value={value == null || value === "" ? (field.noneOption ? FLEET_SELECT_NONE : null) : String(value)}
|
||||
value={
|
||||
value == null || value === ""
|
||||
? field.noneOption
|
||||
? FLEET_SELECT_NONE
|
||||
: null
|
||||
: String(value)
|
||||
}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
disabled={selectOptionsLoading}
|
||||
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
rightSection={
|
||||
selectOptionsLoading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "multiselect") {
|
||||
const arrayValue = Array.isArray(value) ? value : (typeof value === "string" && value ? [value] : []);
|
||||
const arrayValue = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string" && value
|
||||
? [value]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
@@ -162,7 +186,11 @@ const FleetFormDialog = ({
|
||||
searchable
|
||||
clearable
|
||||
disabled={selectOptionsLoading}
|
||||
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
rightSection={
|
||||
selectOptionsLoading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -194,7 +222,10 @@ const FleetFormDialog = ({
|
||||
placeholder={field.placeholder}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value }))
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: e.currentTarget?.value,
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
minRows={3}
|
||||
@@ -221,7 +252,7 @@ const FleetFormDialog = ({
|
||||
disabled={field.disabled}
|
||||
description={field.description || "Select a date"}
|
||||
rightSection={
|
||||
<ActionIcon size="sm" variant="subtle" color="green" pointer={false}>
|
||||
<ActionIcon size="sm" variant="subtle" color="green">
|
||||
<Calendar size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
@@ -250,7 +281,10 @@ const FleetFormDialog = ({
|
||||
description={field.description}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value }))
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: e.currentTarget?.value,
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
disabled={field.disabled}
|
||||
@@ -276,7 +310,11 @@ const FleetFormDialog = ({
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="edr-green" loading={isSubmitting} onClick={handleSubmit}>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -285,4 +323,4 @@ const FleetFormDialog = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetFormDialog;
|
||||
export default FleetFormDialog;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react";
|
||||
import { ActionIcon, Group, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
@@ -37,30 +37,39 @@ const FleetRecordActions = ({
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<Tooltip label="Actions">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<MoreVertical size={16} strokeWidth={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{isVehicle && onAssignDriver ? (
|
||||
<MenuItem onClick={() => onAssignDriver(record)} leftSection={<Users size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={() => onAssignDriver(record)}
|
||||
leftSection={<Users size={14} strokeWidth={2} />}
|
||||
>
|
||||
Assign Driver
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={() => onEdit(record)} leftSection={<Edit2 size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{showDetail ? (
|
||||
<MenuItem onClick={handleDetail} leftSection={<Eye size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={handleDetail}
|
||||
leftSection={<Eye size={14} strokeWidth={2} />}
|
||||
>
|
||||
View details
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem color="red" onClick={() => onRemove(record)} leftSection={<Trash2 size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
</Menu.Dropdown>
|
||||
@@ -72,30 +81,39 @@ const FleetRecordActions = ({
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<Tooltip label="Actions">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<MoreVertical size={16} strokeWidth={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{isVehicle && onAssignDriver ? (
|
||||
<MenuItem onClick={() => onAssignDriver(record)} leftSection={<Users size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={() => onAssignDriver(record)}
|
||||
leftSection={<Users size={14} strokeWidth={2} />}
|
||||
>
|
||||
Assign Driver
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={() => onEdit(record)} leftSection={<Edit2 size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{showDetail ? (
|
||||
<MenuItem onClick={handleDetail} leftSection={<Eye size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
onClick={handleDetail}
|
||||
leftSection={<Eye size={14} strokeWidth={2} />}
|
||||
>
|
||||
View details
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem color="red" onClick={() => onRemove(record)} leftSection={<Trash2 size={14} strokeWidth={2} />}>
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
</Menu.Dropdown>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||
import { Stack, Group, Text, Card, SimpleGrid, Skeleton } from "@mantine/core";
|
||||
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
@@ -85,8 +85,15 @@ const RuleEngineCardGrid = ({
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
|
||||
<Text size="lg" fw={600} c="red">Failed to load data</Text>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
p="xl"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
<Text size="lg" fw={600} c="red">
|
||||
Failed to load data
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Please refresh the page or try again later.
|
||||
</Text>
|
||||
@@ -118,8 +125,15 @@ const RuleEngineCardGrid = ({
|
||||
|
||||
if (status === "success" && rows.length === 0) {
|
||||
return (
|
||||
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
|
||||
<Text size="lg" fw={600}>{emptyMessage}</Text>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
p="xl"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
<Text size="lg" fw={600}>
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Try adjusting your search or add a new record.
|
||||
</Text>
|
||||
@@ -197,7 +211,10 @@ const RuleEngineCardGrid = ({
|
||||
{subtitle && (
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{presentation.subtitleKey === "stepOrder" ? "Step" : "Type"}:
|
||||
{presentation.subtitleKey === "stepOrder"
|
||||
? "Step"
|
||||
: "Type"}
|
||||
:
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{subtitle}
|
||||
@@ -207,7 +224,12 @@ const RuleEngineCardGrid = ({
|
||||
{presentation.detailColumns.map((col) => {
|
||||
const displayValue = getSmartValue(record, col.accessorKey);
|
||||
return (
|
||||
<Group key={col.id} justify="space-between" gap="xs" align="flex-start">
|
||||
<Group
|
||||
key={col.id}
|
||||
justify="space-between"
|
||||
gap="xs"
|
||||
align="flex-start"
|
||||
>
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{col.header}:
|
||||
</Text>
|
||||
@@ -220,14 +242,19 @@ const RuleEngineCardGrid = ({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="xs" pt="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap="xs"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<RuleEngineRecordActions
|
||||
record={record}
|
||||
config={config}
|
||||
layout="compact"
|
||||
readOnly={readOnly}
|
||||
onEdit={onEdit ?? (() => {})}
|
||||
onDelete={onDelete ?? (() => {})}
|
||||
onEdit={onEdit ?? (() => { })}
|
||||
onDelete={onDelete ?? (() => { })}
|
||||
onViewChain={onViewChain}
|
||||
onSubmitRate={onSubmitRate}
|
||||
onApproveRate={onApproveRate}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||
import { Group, Pagination, Select, Text } from "@mantine/core";
|
||||
|
||||
export interface RuleEngineListFooterProps {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
|
||||
export interface BookingDetailData {
|
||||
bookingId: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||
|
||||
interface InteractiveTrainConsistProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Group, Modal, Stack, Text, Badge } from "@mantine/core";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
type WagonWithAllocation = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type WagonWithAllocation = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
|
||||
interface RemoveBookingModalProps {
|
||||
opened: boolean;
|
||||
@@ -21,7 +21,6 @@ export const RemoveBookingModal = ({
|
||||
if (!wagon || !wagon.allocations?.[0]) return null;
|
||||
|
||||
const allocation = wagon.allocations[0];
|
||||
const booking = allocation.booking;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
|
||||
@@ -32,12 +31,12 @@ export const RemoveBookingModal = ({
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<strong>Reference:</strong> {booking?.reference || "N/A"}
|
||||
<strong>Reference:</strong> {allocation.bookingReference || "N/A"}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Freight Type:</strong>{" "}
|
||||
<Badge size="sm" variant="light">
|
||||
{booking?.freightType || "N/A"}
|
||||
{allocation.loadType || "N/A"}
|
||||
</Badge>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
|
||||
interface TrainConsistViewProps {
|
||||
scheduleDetail: TrainScheduleDetail;
|
||||
@@ -103,9 +103,9 @@ export const TrainConsistView = ({
|
||||
<Stack gap="md" style={{ width: "100%" }}>
|
||||
<TrainStatsBar
|
||||
weightUsed={weightUsed}
|
||||
weightMax={scheduleDetail.locomotive?.maxWeightTons ?? trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||
lengthUsed={lengthUsed}
|
||||
lengthMax={scheduleDetail.locomotive?.maxLengthMeters ?? trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||
wagonCount={wagons.length}
|
||||
wagonMax={maxWagons}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
|
||||
interface WagonCardProps {
|
||||
wagon: Wagon;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { getNextInventoryAction } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import {
|
||||
INVENTORY_NEXT_ACTION,
|
||||
type InventoryAction,
|
||||
type WarehouseInventoryItem,
|
||||
} from "@/types/warehouse";
|
||||
import { InventoryStatusBadge } from "./badges";
|
||||
import { formatDate, formatNumber, humanizeEnum } from "./options";
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -27,21 +30,21 @@ interface WarehouseInventoryTableProps {
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
if (item.containerId) return { label: 'Container', color: 'blue' };
|
||||
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
|
||||
if (item.goodsId) return { label: 'Goods', color: 'orange' };
|
||||
return { label: '—', color: 'gray' };
|
||||
if (item.containerId) return { label: "Container", color: "blue" };
|
||||
if (item.cargoId) return { label: "Cargo", color: "grape" };
|
||||
if (item.goodsId) return { label: "Goods", color: "orange" };
|
||||
return { label: "—", color: "gray" };
|
||||
};
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
'ready-for-pickup': 'orange',
|
||||
release: 'yellow',
|
||||
deliver: 'green',
|
||||
store: "blue",
|
||||
reserve: "grape",
|
||||
"ready-for-loading": "cyan",
|
||||
load: "teal",
|
||||
dispatch: "edr-green",
|
||||
"ready-for-pickup": "orange",
|
||||
release: "yellow",
|
||||
deliver: "green",
|
||||
};
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
@@ -52,18 +55,12 @@ export function WarehouseInventoryTable({
|
||||
onHistory,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'booking',
|
||||
header: 'Booking',
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) =>
|
||||
row.original.bookingId ? (
|
||||
<Tooltip label={row.original.bookingId} withArrow>
|
||||
@@ -78,16 +75,28 @@ export function WarehouseInventoryTable({
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'facility',
|
||||
header: 'Facility',
|
||||
cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—',
|
||||
id: "facility",
|
||||
header: "Facility",
|
||||
cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—",
|
||||
},
|
||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
|
||||
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.code ?? '—' },
|
||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
|
||||
{
|
||||
id: 'item',
|
||||
header: 'Item',
|
||||
id: "warehouse",
|
||||
header: "Warehouse",
|
||||
cell: ({ row }) => row.original.warehouse?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "yard",
|
||||
header: "Yard",
|
||||
cell: ({ row }) => row.original.yard?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "zone",
|
||||
header: "Zone",
|
||||
cell: ({ row }) => row.original.zone?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
header: "Item",
|
||||
cell: ({ row }) => {
|
||||
const kind = itemKind(row.original);
|
||||
return (
|
||||
@@ -97,27 +106,44 @@ export function WarehouseInventoryTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
|
||||
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
|
||||
id: "qty",
|
||||
header: "Qty",
|
||||
cell: ({ row }) => formatNumber(row.original.quantity),
|
||||
},
|
||||
{
|
||||
id: 'arrived',
|
||||
header: 'Arrived',
|
||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
|
||||
id: "weight",
|
||||
header: "Weight",
|
||||
cell: ({ row }) => formatNumber(row.original.weight),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<InventoryStatusBadge status={row.original.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "arrived",
|
||||
header: "Arrived",
|
||||
cell: ({ row }) => (
|
||||
<Text size="xs">{formatDate(row.original.arrivedAt)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = INVENTORY_NEXT_ACTION[item.status];
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<Group
|
||||
gap="xs"
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{nextAction && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
@@ -126,32 +152,48 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{humanizeEnum(nextAction.replace(/-/g, "_"))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
{item.status !== "DISPATCHED" && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onMove(item)}
|
||||
>
|
||||
<ArrowRightLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onInspect && (
|
||||
<Tooltip label="Inspection / Report" withArrow>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => onInspect(item)}
|
||||
>
|
||||
<ClipboardList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFeePreview && (
|
||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
onClick={() => onFeePreview(item)}
|
||||
>
|
||||
<Coins size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onHistory(item)}
|
||||
>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,53 +1,80 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import type { InventoryStatus, WarehouseStatus, WarehouseType } from '@/types/warehouse';
|
||||
import type {
|
||||
InventoryStatus,
|
||||
WarehouseStatus,
|
||||
WarehouseType,
|
||||
} from "@/types/warehouse";
|
||||
|
||||
const humanize = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.split("_")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
.join(" ");
|
||||
|
||||
const badgeStyle = {
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.04em',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.04em",
|
||||
whiteSpace: "nowrap" as const,
|
||||
};
|
||||
|
||||
export function WarehouseTypeBadge({ type }: { type: WarehouseType }) {
|
||||
const color = type === 'CLOSED_WAREHOUSE' ? 'indigo' : 'teal';
|
||||
const color = type === "CLOSED_WAREHOUSE" ? "indigo" : "teal";
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{humanize(type)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
||||
const color = status === 'ACTIVE' ? 'edr-green' : 'gray';
|
||||
const color = status === "ACTIVE" ? "edr-green" : "gray";
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" tt="uppercase" fw={600} style={badgeStyle}>
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
UNLOADED: 'indigo',
|
||||
RECEIVED: 'yellow',
|
||||
STORED: 'blue',
|
||||
RESERVED: 'grape',
|
||||
READY_FOR_LOADING: 'cyan',
|
||||
LOADED: 'teal',
|
||||
DISPATCHED: 'edr-green',
|
||||
DELIVERED: 'edr-green',
|
||||
UNLOADED: "indigo",
|
||||
RECEIVED: "yellow",
|
||||
STORED: "blue",
|
||||
RESERVED: "grape",
|
||||
READY_FOR_LOADING: "cyan",
|
||||
LOADED: "teal",
|
||||
READY_FOR_PICKUP: "teal",
|
||||
DISPATCHED: "edr-green",
|
||||
DELIVERED: "edr-green",
|
||||
};
|
||||
|
||||
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
|
||||
const color = inventoryStatusColor[status] ?? 'gray';
|
||||
const color = inventoryStatusColor[status] ?? "gray";
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{humanize(status)}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user